gitacc-switcher 1.1.0__tar.gz → 1.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. {gitacc_switcher-1.1.0/gitacc_switcher.egg-info → gitacc_switcher-1.2.0}/PKG-INFO +14 -3
  2. {gitacc_switcher-1.1.0 → gitacc_switcher-1.2.0}/README.md +13 -2
  3. {gitacc_switcher-1.1.0 → gitacc_switcher-1.2.0}/gitacc_switcher/__init__.py +1 -1
  4. {gitacc_switcher-1.1.0 → gitacc_switcher-1.2.0}/gitacc_switcher/account_manager.py +40 -3
  5. {gitacc_switcher-1.1.0 → gitacc_switcher-1.2.0}/gitacc_switcher/cli.py +31 -2
  6. {gitacc_switcher-1.1.0 → gitacc_switcher-1.2.0}/gitacc_switcher/config_manager.py +120 -0
  7. {gitacc_switcher-1.1.0 → gitacc_switcher-1.2.0/gitacc_switcher.egg-info}/PKG-INFO +14 -3
  8. {gitacc_switcher-1.1.0 → gitacc_switcher-1.2.0}/pyproject.toml +1 -1
  9. {gitacc_switcher-1.1.0 → gitacc_switcher-1.2.0}/setup.py +1 -1
  10. {gitacc_switcher-1.1.0 → gitacc_switcher-1.2.0}/tests/test_account_manager.py +132 -0
  11. {gitacc_switcher-1.1.0 → gitacc_switcher-1.2.0}/tests/test_cli.py +38 -0
  12. {gitacc_switcher-1.1.0 → gitacc_switcher-1.2.0}/tests/test_config_manager.py +93 -0
  13. {gitacc_switcher-1.1.0 → gitacc_switcher-1.2.0}/LICENSE +0 -0
  14. {gitacc_switcher-1.1.0 → gitacc_switcher-1.2.0}/MANIFEST.in +0 -0
  15. {gitacc_switcher-1.1.0 → gitacc_switcher-1.2.0}/gitacc_switcher/completion.py +0 -0
  16. {gitacc_switcher-1.1.0 → gitacc_switcher-1.2.0}/gitacc_switcher/hook_manager.py +0 -0
  17. {gitacc_switcher-1.1.0 → gitacc_switcher-1.2.0}/gitacc_switcher/ssh_manager.py +0 -0
  18. {gitacc_switcher-1.1.0 → gitacc_switcher-1.2.0}/gitacc_switcher/utils.py +0 -0
  19. {gitacc_switcher-1.1.0 → gitacc_switcher-1.2.0}/gitacc_switcher.egg-info/SOURCES.txt +0 -0
  20. {gitacc_switcher-1.1.0 → gitacc_switcher-1.2.0}/gitacc_switcher.egg-info/dependency_links.txt +0 -0
  21. {gitacc_switcher-1.1.0 → gitacc_switcher-1.2.0}/gitacc_switcher.egg-info/entry_points.txt +0 -0
  22. {gitacc_switcher-1.1.0 → gitacc_switcher-1.2.0}/gitacc_switcher.egg-info/requires.txt +0 -0
  23. {gitacc_switcher-1.1.0 → gitacc_switcher-1.2.0}/gitacc_switcher.egg-info/top_level.txt +0 -0
  24. {gitacc_switcher-1.1.0 → gitacc_switcher-1.2.0}/requirements.txt +0 -0
  25. {gitacc_switcher-1.1.0 → gitacc_switcher-1.2.0}/setup.cfg +0 -0
  26. {gitacc_switcher-1.1.0 → gitacc_switcher-1.2.0}/tests/__init__.py +0 -0
  27. {gitacc_switcher-1.1.0 → gitacc_switcher-1.2.0}/tests/test_completion.py +0 -0
  28. {gitacc_switcher-1.1.0 → gitacc_switcher-1.2.0}/tests/test_hook_manager.py +0 -0
  29. {gitacc_switcher-1.1.0 → gitacc_switcher-1.2.0}/tests/test_ssh_manager.py +0 -0
  30. {gitacc_switcher-1.1.0 → gitacc_switcher-1.2.0}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitacc-switcher
3
- Version: 1.1.0
3
+ Version: 1.2.0
4
4
  Summary: Git Account Switcher - Manage multiple Git SSH accounts easily
5
5
  Home-page: https://github.com/ktechhub/gitacc_switcher
6
6
  Author: ktechhub
@@ -46,6 +46,7 @@ A Python CLI tool to easily switch between multiple Git SSH accounts. Manage mul
46
46
  - Switch between accounts easily — one command, no fuss
47
47
  - Generate SSH keys automatically (rsa, ed25519, ecdsa, and more)
48
48
  - Optional passphrase support for SSH keys
49
+ - Opt-in SSH commit signing per account
49
50
  - Colored terminal output with active account indicator
50
51
  - Pre-commit hook to prevent commits with the wrong account
51
52
  - Shell autocomplete for bash and zsh
@@ -87,7 +88,7 @@ gitacc verify # manual check
87
88
 
88
89
  ## Commands
89
90
 
90
- ### `gitacc add [--type TYPE]`
91
+ ### `gitacc add [--type TYPE] [--sign]`
91
92
 
92
93
  Add a new Git account. Prompts for:
93
94
  - Account identifier (used in key filename and as a reference)
@@ -100,8 +101,15 @@ Available key types: `rsa` (default), `ed25519`, `ecdsa`, `ecdsa-sk`, `ed25519-s
100
101
  ```bash
101
102
  gitacc add
102
103
  gitacc add --type ed25519
104
+ gitacc add --sign # opt in to SSH commit signing for this account
103
105
  ```
104
106
 
107
+ Pass `--sign` to opt this account into SSH commit signing. When you later `gitacc switch` to it,
108
+ `user.signingkey` is set to the account's public key and `gpg.format=ssh` / `commit.gpgsign=true`
109
+ are enabled (locally too, with `--local`), so every commit made under this identity is signed.
110
+ Switching to an account without `--sign` clears any signing config left by a previous account.
111
+ Toggle it later with `gitacc update <account> --sign` / `--no-sign`.
112
+
105
113
  ### `gitacc switch <account> [--local]` / `gitacc <account>`
106
114
 
107
115
  Switch to a registered account. Clears all keys from the SSH agent, loads only this account's key, and sets `git config --global user.name/email`.
@@ -139,7 +147,7 @@ gitacc remove mywork
139
147
  gitacc remove # prompts for account name
140
148
  ```
141
149
 
142
- ### `gitacc update <account> [--name NAME] [--email EMAIL]`
150
+ ### `gitacc update <account> [--name NAME] [--email EMAIL] [--sign | --no-sign]`
143
151
 
144
152
  Update the Git display name and/or email for an existing account. Prompts for any field not provided as a flag.
145
153
 
@@ -147,6 +155,8 @@ Update the Git display name and/or email for an existing account. Prompts for an
147
155
  gitacc update mywork --name "Jane Doe"
148
156
  gitacc update mywork --email "jane@newcompany.com"
149
157
  gitacc update mywork # prompts for both
158
+ gitacc update mywork --sign # enable SSH commit signing for this account
159
+ gitacc update mywork --no-sign # disable it
150
160
  ```
151
161
 
152
162
  ### `gitacc init <account>`
@@ -213,6 +223,7 @@ gitacc --version
213
223
  email = jane@company.com
214
224
  private_key = /home/jane/.ssh/id_ed25519_mywork
215
225
  public_key = /home/jane/.ssh/id_ed25519_mywork.pub
226
+ sign = false
216
227
  ```
217
228
 
218
229
  ## Requirements
@@ -13,6 +13,7 @@ A Python CLI tool to easily switch between multiple Git SSH accounts. Manage mul
13
13
  - Switch between accounts easily — one command, no fuss
14
14
  - Generate SSH keys automatically (rsa, ed25519, ecdsa, and more)
15
15
  - Optional passphrase support for SSH keys
16
+ - Opt-in SSH commit signing per account
16
17
  - Colored terminal output with active account indicator
17
18
  - Pre-commit hook to prevent commits with the wrong account
18
19
  - Shell autocomplete for bash and zsh
@@ -54,7 +55,7 @@ gitacc verify # manual check
54
55
 
55
56
  ## Commands
56
57
 
57
- ### `gitacc add [--type TYPE]`
58
+ ### `gitacc add [--type TYPE] [--sign]`
58
59
 
59
60
  Add a new Git account. Prompts for:
60
61
  - Account identifier (used in key filename and as a reference)
@@ -67,8 +68,15 @@ Available key types: `rsa` (default), `ed25519`, `ecdsa`, `ecdsa-sk`, `ed25519-s
67
68
  ```bash
68
69
  gitacc add
69
70
  gitacc add --type ed25519
71
+ gitacc add --sign # opt in to SSH commit signing for this account
70
72
  ```
71
73
 
74
+ Pass `--sign` to opt this account into SSH commit signing. When you later `gitacc switch` to it,
75
+ `user.signingkey` is set to the account's public key and `gpg.format=ssh` / `commit.gpgsign=true`
76
+ are enabled (locally too, with `--local`), so every commit made under this identity is signed.
77
+ Switching to an account without `--sign` clears any signing config left by a previous account.
78
+ Toggle it later with `gitacc update <account> --sign` / `--no-sign`.
79
+
72
80
  ### `gitacc switch <account> [--local]` / `gitacc <account>`
73
81
 
74
82
  Switch to a registered account. Clears all keys from the SSH agent, loads only this account's key, and sets `git config --global user.name/email`.
@@ -106,7 +114,7 @@ gitacc remove mywork
106
114
  gitacc remove # prompts for account name
107
115
  ```
108
116
 
109
- ### `gitacc update <account> [--name NAME] [--email EMAIL]`
117
+ ### `gitacc update <account> [--name NAME] [--email EMAIL] [--sign | --no-sign]`
110
118
 
111
119
  Update the Git display name and/or email for an existing account. Prompts for any field not provided as a flag.
112
120
 
@@ -114,6 +122,8 @@ Update the Git display name and/or email for an existing account. Prompts for an
114
122
  gitacc update mywork --name "Jane Doe"
115
123
  gitacc update mywork --email "jane@newcompany.com"
116
124
  gitacc update mywork # prompts for both
125
+ gitacc update mywork --sign # enable SSH commit signing for this account
126
+ gitacc update mywork --no-sign # disable it
117
127
  ```
118
128
 
119
129
  ### `gitacc init <account>`
@@ -180,6 +190,7 @@ gitacc --version
180
190
  email = jane@company.com
181
191
  private_key = /home/jane/.ssh/id_ed25519_mywork
182
192
  public_key = /home/jane/.ssh/id_ed25519_mywork.pub
193
+ sign = false
183
194
  ```
184
195
 
185
196
  ## Requirements
@@ -1,4 +1,4 @@
1
1
  """Git Account Switcher - Manage multiple Git SSH accounts easily."""
2
2
 
3
- __version__ = "1.1.0"
3
+ __version__ = "1.2.0"
4
4
  __author__ = "ktechhub"
@@ -18,11 +18,14 @@ class AccountManager:
18
18
  self.ssh_manager = SSHManager()
19
19
  self.hook_manager = HookManager()
20
20
 
21
- def add_account(self, key_type: str = "rsa") -> bool:
21
+ def add_account(self, key_type: str = "rsa", sign: bool = False) -> bool:
22
22
  """Add a new Git account.
23
23
 
24
24
  Args:
25
25
  key_type: SSH key type (default: rsa)
26
+ sign: Opt-in to SSH commit signing with this account's key when
27
+ switched to (sets user.signingkey, gpg.format=ssh,
28
+ commit.gpgsign=true)
26
29
 
27
30
  Returns:
28
31
  True if successful, False otherwise
@@ -93,7 +96,7 @@ class AccountManager:
93
96
 
94
97
  # Add to .gitacc file
95
98
  success = self.config_manager.add_account(
96
- account_name, git_name, email, private_key, public_key
99
+ account_name, git_name, email, private_key, public_key, sign=sign
97
100
  )
98
101
  if not success:
99
102
  echo_color("r", "Failed to save account info!")
@@ -233,6 +236,24 @@ class AccountManager:
233
236
  echo_color("r", "Failed to set Git config!")
234
237
  return False
235
238
 
239
+ # Apply (or clear) this account's SSH commit-signing preference
240
+ sign_enabled = str(account_info.get("sign", "false")).lower() == "true"
241
+ public_key = account_info.get("public_key")
242
+ if local:
243
+ if sign_enabled and public_key:
244
+ signing_success = self.config_manager.set_git_signing_config_local(
245
+ public_key
246
+ )
247
+ else:
248
+ signing_success = self.config_manager.unset_git_signing_config_local()
249
+ else:
250
+ if sign_enabled and public_key:
251
+ signing_success = self.config_manager.set_git_signing_config(public_key)
252
+ else:
253
+ signing_success = self.config_manager.unset_git_signing_config()
254
+ if not signing_success:
255
+ echo_color("y", "Warning: Could not update commit-signing config.")
256
+
236
257
  scope = "this repository only" if local else "globally"
237
258
  echo_color(
238
259
  "g",
@@ -250,6 +271,7 @@ class AccountManager:
250
271
  self.ssh_manager.kill_ssh_agent()
251
272
 
252
273
  self.config_manager.unset_git_config()
274
+ self.config_manager.unset_git_signing_config()
253
275
  echo_color("g", "Logged out successfully!")
254
276
  return True
255
277
 
@@ -285,13 +307,16 @@ class AccountManager:
285
307
  account_name: str,
286
308
  new_git_name: Optional[str] = None,
287
309
  new_email: Optional[str] = None,
310
+ new_sign: Optional[bool] = None,
288
311
  ) -> bool:
289
- """Update the Git name and/or email for an existing account.
312
+ """Update the Git name, email, and/or commit-signing preference for an
313
+ existing account.
290
314
 
291
315
  Args:
292
316
  account_name: Account identifier to update
293
317
  new_git_name: New Git name (if None, will prompt)
294
318
  new_email: New email (if None, will prompt)
319
+ new_sign: New commit-signing preference (if None, leave unchanged)
295
320
 
296
321
  Returns:
297
322
  True if successful, False otherwise
@@ -340,6 +365,18 @@ class AccountManager:
340
365
  )
341
366
  changed = True
342
367
 
368
+ if new_sign is not None:
369
+ current_sign = str(account_info.get("sign", "false")).lower() == "true"
370
+ if new_sign != current_sign:
371
+ if not self.config_manager.update_account_field(
372
+ account_name, "sign", "true" if new_sign else "false"
373
+ ):
374
+ echo_color("r", "Failed to update commit-signing setting!")
375
+ return False
376
+ state = "enabled" if new_sign else "disabled"
377
+ echo_color("g", f'Commit signing {state} for "{account_name}"')
378
+ changed = True
379
+
343
380
  if not changed:
344
381
  echo_color("y", "No changes made.")
345
382
 
@@ -67,6 +67,7 @@ Examples:
67
67
  gitacc add Add a new Git account
68
68
  gitacc add --type ed25519 Add account with specific SSH key type
69
69
  Available types: {key_types}
70
+ gitacc add --sign Add account with SSH commit signing enabled
70
71
  gitacc switch myaccount Switch to an account
71
72
  gitacc switch myaccount -l Switch to an account for this repo only
72
73
  gitacc myaccount Switch to an account (short form)
@@ -93,6 +94,13 @@ Examples:
93
94
  choices=get_ssh_key_types(),
94
95
  help="SSH key type (default: rsa). Available: dsa, ecdsa, ecdsa-sk, ed25519, ed25519-sk, rsa",
95
96
  )
97
+ parser.add_argument(
98
+ "--sign",
99
+ dest="sign",
100
+ action="store_true",
101
+ help="Sign commits with this account's SSH key when switched to "
102
+ "(sets user.signingkey, gpg.format=ssh, commit.gpgsign=true)",
103
+ )
96
104
  parser.set_defaults(func=self._handle_add)
97
105
 
98
106
  def _add_remove_command(self, subparsers: argparse._SubParsersAction) -> None:
@@ -195,6 +203,22 @@ Examples:
195
203
  dest="new_email",
196
204
  help="New email address (will prompt if not provided)",
197
205
  )
206
+ sign_group = parser.add_mutually_exclusive_group()
207
+ sign_group.add_argument(
208
+ "--sign",
209
+ dest="sign",
210
+ action="store_const",
211
+ const=True,
212
+ default=None,
213
+ help="Enable SSH commit signing for this account",
214
+ )
215
+ sign_group.add_argument(
216
+ "--no-sign",
217
+ dest="sign",
218
+ action="store_const",
219
+ const=False,
220
+ help="Disable SSH commit signing for this account",
221
+ )
198
222
  if argcomplete:
199
223
  account_arg.completer = lambda **kwargs: get_account_names()
200
224
  parser.set_defaults(func=self._handle_update)
@@ -318,7 +342,9 @@ Examples:
318
342
 
319
343
  def _handle_add(self, args: argparse.Namespace) -> int:
320
344
  """Handle the 'add' command."""
321
- success = self.account_manager.add_account(key_type=args.key_type)
345
+ success = self.account_manager.add_account(
346
+ key_type=args.key_type, sign=getattr(args, "sign", False)
347
+ )
322
348
  return 0 if success else 1
323
349
 
324
350
  def _handle_remove(self, args: argparse.Namespace) -> int:
@@ -383,7 +409,10 @@ Examples:
383
409
  def _handle_update(self, args: argparse.Namespace) -> int:
384
410
  """Handle the 'update' command."""
385
411
  success = self.account_manager.update_account(
386
- args.account_name, args.new_git_name, args.new_email
412
+ args.account_name,
413
+ args.new_git_name,
414
+ args.new_email,
415
+ new_sign=getattr(args, "sign", None),
387
416
  )
388
417
  return 0 if success else 1
389
418
 
@@ -93,6 +93,122 @@ class ConfigManager:
93
93
  except subprocess.CalledProcessError:
94
94
  return False
95
95
 
96
+ def set_git_signing_config(self, signing_key: str) -> bool:
97
+ """Set Git global SSH commit signing config using an account's SSH key.
98
+
99
+ Args:
100
+ signing_key: Path to the SSH public key to sign commits with
101
+
102
+ Returns:
103
+ True if successful, False otherwise
104
+ """
105
+ try:
106
+ subprocess.run(
107
+ ["git", "config", "--global", "user.signingkey", signing_key],
108
+ check=True,
109
+ capture_output=True,
110
+ )
111
+ subprocess.run(
112
+ ["git", "config", "--global", "gpg.format", "ssh"],
113
+ check=True,
114
+ capture_output=True,
115
+ )
116
+ subprocess.run(
117
+ ["git", "config", "--global", "commit.gpgsign", "true"],
118
+ check=True,
119
+ capture_output=True,
120
+ )
121
+ return True
122
+ except subprocess.CalledProcessError:
123
+ return False
124
+
125
+ def unset_git_signing_config(self) -> bool:
126
+ """Unset Git global SSH commit signing config.
127
+
128
+ Returns:
129
+ True if successful, False otherwise
130
+ """
131
+ try:
132
+ subprocess.run(
133
+ ["git", "config", "--global", "--unset", "user.signingkey"],
134
+ capture_output=True,
135
+ )
136
+ subprocess.run(
137
+ ["git", "config", "--global", "--unset", "gpg.format"],
138
+ capture_output=True,
139
+ )
140
+ subprocess.run(
141
+ ["git", "config", "--global", "--unset", "commit.gpgsign"],
142
+ capture_output=True,
143
+ )
144
+ return True
145
+ except subprocess.CalledProcessError:
146
+ return False
147
+
148
+ def set_git_signing_config_local(
149
+ self, signing_key: str, repo_path: Optional[Path] = None
150
+ ) -> bool:
151
+ """Set Git local (repository-scoped) SSH commit signing config.
152
+
153
+ Args:
154
+ signing_key: Path to the SSH public key to sign commits with
155
+ repo_path: Path to repository (default: current directory)
156
+
157
+ Returns:
158
+ True if successful, False otherwise
159
+ """
160
+ try:
161
+ subprocess.run(
162
+ ["git", "config", "user.signingkey", signing_key],
163
+ cwd=repo_path,
164
+ check=True,
165
+ capture_output=True,
166
+ )
167
+ subprocess.run(
168
+ ["git", "config", "gpg.format", "ssh"],
169
+ cwd=repo_path,
170
+ check=True,
171
+ capture_output=True,
172
+ )
173
+ subprocess.run(
174
+ ["git", "config", "commit.gpgsign", "true"],
175
+ cwd=repo_path,
176
+ check=True,
177
+ capture_output=True,
178
+ )
179
+ return True
180
+ except subprocess.CalledProcessError:
181
+ return False
182
+
183
+ def unset_git_signing_config_local(self, repo_path: Optional[Path] = None) -> bool:
184
+ """Unset Git local (repository-scoped) SSH commit signing config.
185
+
186
+ Args:
187
+ repo_path: Path to repository (default: current directory)
188
+
189
+ Returns:
190
+ True if successful, False otherwise
191
+ """
192
+ try:
193
+ subprocess.run(
194
+ ["git", "config", "--unset", "user.signingkey"],
195
+ cwd=repo_path,
196
+ capture_output=True,
197
+ )
198
+ subprocess.run(
199
+ ["git", "config", "--unset", "gpg.format"],
200
+ cwd=repo_path,
201
+ capture_output=True,
202
+ )
203
+ subprocess.run(
204
+ ["git", "config", "--unset", "commit.gpgsign"],
205
+ cwd=repo_path,
206
+ capture_output=True,
207
+ )
208
+ return True
209
+ except subprocess.CalledProcessError:
210
+ return False
211
+
96
212
  def get_current_git_config(self) -> Dict[str, Optional[str]]:
97
213
  """Get current Git global config.
98
214
 
@@ -187,6 +303,7 @@ class ConfigManager:
187
303
  email: str,
188
304
  private_key: str,
189
305
  public_key: str,
306
+ sign: bool = False,
190
307
  ) -> bool:
191
308
  """Add account to .gitacc file.
192
309
 
@@ -196,6 +313,8 @@ class ConfigManager:
196
313
  email: Git user email
197
314
  private_key: Path to private SSH key
198
315
  public_key: Path to public SSH key
316
+ sign: Whether to sign commits with this account's SSH key when
317
+ switched to (sets user.signingkey/gpg.format/commit.gpgsign)
199
318
 
200
319
  Returns:
201
320
  True if successful, False otherwise
@@ -207,6 +326,7 @@ class ConfigManager:
207
326
  f.write(f"\temail = {email}\n")
208
327
  f.write(f"\tprivate_key = {private_key}\n")
209
328
  f.write(f"\tpublic_key = {public_key}\n")
329
+ f.write(f"\tsign = {'true' if sign else 'false'}\n")
210
330
  return True
211
331
  except Exception:
212
332
  return False
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitacc-switcher
3
- Version: 1.1.0
3
+ Version: 1.2.0
4
4
  Summary: Git Account Switcher - Manage multiple Git SSH accounts easily
5
5
  Home-page: https://github.com/ktechhub/gitacc_switcher
6
6
  Author: ktechhub
@@ -46,6 +46,7 @@ A Python CLI tool to easily switch between multiple Git SSH accounts. Manage mul
46
46
  - Switch between accounts easily — one command, no fuss
47
47
  - Generate SSH keys automatically (rsa, ed25519, ecdsa, and more)
48
48
  - Optional passphrase support for SSH keys
49
+ - Opt-in SSH commit signing per account
49
50
  - Colored terminal output with active account indicator
50
51
  - Pre-commit hook to prevent commits with the wrong account
51
52
  - Shell autocomplete for bash and zsh
@@ -87,7 +88,7 @@ gitacc verify # manual check
87
88
 
88
89
  ## Commands
89
90
 
90
- ### `gitacc add [--type TYPE]`
91
+ ### `gitacc add [--type TYPE] [--sign]`
91
92
 
92
93
  Add a new Git account. Prompts for:
93
94
  - Account identifier (used in key filename and as a reference)
@@ -100,8 +101,15 @@ Available key types: `rsa` (default), `ed25519`, `ecdsa`, `ecdsa-sk`, `ed25519-s
100
101
  ```bash
101
102
  gitacc add
102
103
  gitacc add --type ed25519
104
+ gitacc add --sign # opt in to SSH commit signing for this account
103
105
  ```
104
106
 
107
+ Pass `--sign` to opt this account into SSH commit signing. When you later `gitacc switch` to it,
108
+ `user.signingkey` is set to the account's public key and `gpg.format=ssh` / `commit.gpgsign=true`
109
+ are enabled (locally too, with `--local`), so every commit made under this identity is signed.
110
+ Switching to an account without `--sign` clears any signing config left by a previous account.
111
+ Toggle it later with `gitacc update <account> --sign` / `--no-sign`.
112
+
105
113
  ### `gitacc switch <account> [--local]` / `gitacc <account>`
106
114
 
107
115
  Switch to a registered account. Clears all keys from the SSH agent, loads only this account's key, and sets `git config --global user.name/email`.
@@ -139,7 +147,7 @@ gitacc remove mywork
139
147
  gitacc remove # prompts for account name
140
148
  ```
141
149
 
142
- ### `gitacc update <account> [--name NAME] [--email EMAIL]`
150
+ ### `gitacc update <account> [--name NAME] [--email EMAIL] [--sign | --no-sign]`
143
151
 
144
152
  Update the Git display name and/or email for an existing account. Prompts for any field not provided as a flag.
145
153
 
@@ -147,6 +155,8 @@ Update the Git display name and/or email for an existing account. Prompts for an
147
155
  gitacc update mywork --name "Jane Doe"
148
156
  gitacc update mywork --email "jane@newcompany.com"
149
157
  gitacc update mywork # prompts for both
158
+ gitacc update mywork --sign # enable SSH commit signing for this account
159
+ gitacc update mywork --no-sign # disable it
150
160
  ```
151
161
 
152
162
  ### `gitacc init <account>`
@@ -213,6 +223,7 @@ gitacc --version
213
223
  email = jane@company.com
214
224
  private_key = /home/jane/.ssh/id_ed25519_mywork
215
225
  public_key = /home/jane/.ssh/id_ed25519_mywork.pub
226
+ sign = false
216
227
  ```
217
228
 
218
229
  ## Requirements
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gitacc-switcher"
7
- version = "1.1.0"
7
+ version = "1.2.0"
8
8
  description = "Git Account Switcher - Manage multiple Git SSH accounts easily"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.7"
@@ -9,7 +9,7 @@ long_description = readme_file.read_text() if readme_file.exists() else ""
9
9
 
10
10
  setup(
11
11
  name="gitacc-switcher",
12
- version="1.1.0",
12
+ version="1.2.0",
13
13
  description="Git Account Switcher - Manage multiple Git SSH accounts easily",
14
14
  long_description=long_description,
15
15
  long_description_content_type="text/markdown",
@@ -90,6 +90,59 @@ class TestSwitchAccount:
90
90
  "Work User", "work@company.com"
91
91
  )
92
92
 
93
+ def test_unsigned_account_unsets_signing_config(self, manager, tmp_path):
94
+ key = tmp_path / "id_rsa_work"
95
+ key.write_text("key")
96
+ account = {**WORK_ACCOUNT, "private_key": str(key), "sign": "false"}
97
+ manager.config_manager.get_account.return_value = account
98
+ manager.ssh_manager.is_ssh_agent_running.return_value = True
99
+ manager.ssh_manager.clear_all_keys.return_value = True
100
+ manager.ssh_manager.add_key_to_agent.return_value = (True, None)
101
+ manager.config_manager.set_git_config.return_value = True
102
+ with patch("subprocess.run") as mock_run:
103
+ mock_run.return_value = MagicMock(returncode=0, stdout="1 key\n")
104
+ result = manager.switch_account("work")
105
+ assert result is True
106
+ manager.config_manager.unset_git_signing_config.assert_called_once()
107
+ manager.config_manager.set_git_signing_config.assert_not_called()
108
+
109
+ def test_signed_account_sets_signing_config(self, manager, tmp_path):
110
+ key = tmp_path / "id_rsa_work"
111
+ key.write_text("key")
112
+ account = {**WORK_ACCOUNT, "private_key": str(key), "sign": "true"}
113
+ manager.config_manager.get_account.return_value = account
114
+ manager.ssh_manager.is_ssh_agent_running.return_value = True
115
+ manager.ssh_manager.clear_all_keys.return_value = True
116
+ manager.ssh_manager.add_key_to_agent.return_value = (True, None)
117
+ manager.config_manager.set_git_config.return_value = True
118
+ with patch("subprocess.run") as mock_run:
119
+ mock_run.return_value = MagicMock(returncode=0, stdout="1 key\n")
120
+ result = manager.switch_account("work")
121
+ assert result is True
122
+ manager.config_manager.set_git_signing_config.assert_called_once_with(
123
+ "/home/user/.ssh/id_rsa_work.pub"
124
+ )
125
+ manager.config_manager.unset_git_signing_config.assert_not_called()
126
+
127
+ def test_signed_account_local_sets_local_signing_config(self, manager, tmp_path):
128
+ key = tmp_path / "id_rsa_work"
129
+ key.write_text("key")
130
+ account = {**WORK_ACCOUNT, "private_key": str(key), "sign": "true"}
131
+ manager.config_manager.get_account.return_value = account
132
+ manager.config_manager.is_git_repo.return_value = True
133
+ manager.ssh_manager.is_ssh_agent_running.return_value = True
134
+ manager.ssh_manager.clear_all_keys.return_value = True
135
+ manager.ssh_manager.add_key_to_agent.return_value = (True, None)
136
+ manager.config_manager.set_git_config_local.return_value = True
137
+ with patch("subprocess.run") as mock_run:
138
+ mock_run.return_value = MagicMock(returncode=0, stdout="1 key\n")
139
+ result = manager.switch_account("work", local=True)
140
+ assert result is True
141
+ manager.config_manager.set_git_signing_config_local.assert_called_once_with(
142
+ "/home/user/.ssh/id_rsa_work.pub"
143
+ )
144
+ manager.config_manager.set_git_signing_config.assert_not_called()
145
+
93
146
  def test_clears_all_keys_before_adding(self, manager, tmp_path):
94
147
  key = tmp_path / "id_rsa_work"
95
148
  key.write_text("key")
@@ -159,6 +212,11 @@ class TestLogout:
159
212
  manager.logout()
160
213
  manager.config_manager.unset_git_config.assert_called_once()
161
214
 
215
+ def test_unsets_signing_config(self, manager):
216
+ manager.ssh_manager.is_ssh_agent_running.return_value = False
217
+ manager.logout()
218
+ manager.config_manager.unset_git_signing_config.assert_called_once()
219
+
162
220
 
163
221
  class TestUpdateAccount:
164
222
  def test_update_name(self, manager):
@@ -216,6 +274,58 @@ class TestUpdateAccount:
216
274
  manager.config_manager.get_account.return_value = None
217
275
  assert manager.update_account("nobody", "name", "email") is False
218
276
 
277
+ def test_enables_signing(self, manager):
278
+ manager.config_manager.get_account.return_value = {
279
+ "name": "Work User",
280
+ "email": "work@co.com",
281
+ "sign": "false",
282
+ }
283
+ manager.config_manager.update_account_field.return_value = True
284
+ result = manager.update_account(
285
+ "work", new_git_name="Work User", new_email="work@co.com", new_sign=True
286
+ )
287
+ assert result is True
288
+ manager.config_manager.update_account_field.assert_called_once_with(
289
+ "work", "sign", "true"
290
+ )
291
+
292
+ def test_disables_signing(self, manager):
293
+ manager.config_manager.get_account.return_value = {
294
+ "name": "Work User",
295
+ "email": "work@co.com",
296
+ "sign": "true",
297
+ }
298
+ manager.config_manager.update_account_field.return_value = True
299
+ result = manager.update_account(
300
+ "work", new_git_name="Work User", new_email="work@co.com", new_sign=False
301
+ )
302
+ assert result is True
303
+ manager.config_manager.update_account_field.assert_called_once_with(
304
+ "work", "sign", "false"
305
+ )
306
+
307
+ def test_no_sign_change_does_not_write(self, manager):
308
+ manager.config_manager.get_account.return_value = {
309
+ "name": "Work User",
310
+ "email": "work@co.com",
311
+ "sign": "true",
312
+ }
313
+ manager.update_account(
314
+ "work", new_git_name="Work User", new_email="work@co.com", new_sign=True
315
+ )
316
+ manager.config_manager.update_account_field.assert_not_called()
317
+
318
+ def test_sign_none_leaves_setting_unchanged(self, manager):
319
+ manager.config_manager.get_account.return_value = {
320
+ "name": "Work User",
321
+ "email": "work@co.com",
322
+ "sign": "true",
323
+ }
324
+ manager.update_account(
325
+ "work", new_git_name="Work User", new_email="work@co.com", new_sign=None
326
+ )
327
+ manager.config_manager.update_account_field.assert_not_called()
328
+
219
329
 
220
330
  class TestRemoveAccount:
221
331
  def test_returns_false_for_missing_account(self, manager):
@@ -294,3 +404,25 @@ class TestAddAccount:
294
404
  "gitacc_switcher.account_manager.ask_yes_no", return_value=True
295
405
  ), patch("getpass.getpass", side_effect=["pass1", "pass2"]):
296
406
  assert manager.add_account() is False
407
+
408
+ def test_sign_flag_passed_to_config_manager(self, manager, tmp_path):
409
+ self._setup_add(manager, tmp_path)
410
+ inputs = ["mywork", "My Work", "work@co.com"]
411
+ with patch("builtins.input", side_effect=inputs), patch(
412
+ "gitacc_switcher.account_manager.ask_yes_no", return_value=False
413
+ ):
414
+ result = manager.add_account(sign=True)
415
+ assert result is True
416
+ _, kwargs = manager.config_manager.add_account.call_args
417
+ assert kwargs["sign"] is True
418
+
419
+ def test_sign_defaults_to_false(self, manager, tmp_path):
420
+ self._setup_add(manager, tmp_path)
421
+ inputs = ["mywork", "My Work", "work@co.com"]
422
+ with patch("builtins.input", side_effect=inputs), patch(
423
+ "gitacc_switcher.account_manager.ask_yes_no", return_value=False
424
+ ):
425
+ result = manager.add_account()
426
+ assert result is True
427
+ _, kwargs = manager.config_manager.add_account.call_args
428
+ assert kwargs["sign"] is False
@@ -33,6 +33,14 @@ class TestParserCommands:
33
33
  with pytest.raises(SystemExit):
34
34
  cli.parser.parse_args(["add", "--type", "invalid"])
35
35
 
36
+ def test_add_sign_defaults_to_false(self, cli):
37
+ args = cli.parser.parse_args(["add"])
38
+ assert args.sign is False
39
+
40
+ def test_add_sign_flag(self, cli):
41
+ args = cli.parser.parse_args(["add", "--sign"])
42
+ assert args.sign is True
43
+
36
44
  def test_switch_parses_account(self, cli):
37
45
  args = cli.parser.parse_args(["switch", "mywork"])
38
46
  assert args.command == "switch"
@@ -73,6 +81,19 @@ class TestParserCommands:
73
81
  assert args.account_name == "mywork"
74
82
  assert args.new_git_name is None
75
83
  assert args.new_email is None
84
+ assert args.sign is None
85
+
86
+ def test_update_sign_flag(self, cli):
87
+ args = cli.parser.parse_args(["update", "mywork", "--sign"])
88
+ assert args.sign is True
89
+
90
+ def test_update_no_sign_flag(self, cli):
91
+ args = cli.parser.parse_args(["update", "mywork", "--no-sign"])
92
+ assert args.sign is False
93
+
94
+ def test_update_sign_and_no_sign_mutually_exclusive(self, cli):
95
+ with pytest.raises(SystemExit):
96
+ cli.parser.parse_args(["update", "mywork", "--sign", "--no-sign"])
76
97
 
77
98
  def test_update_with_name_flag(self, cli):
78
99
  args = cli.parser.parse_args(["update", "mywork", "--name", "New Name"])
@@ -110,6 +131,13 @@ class TestHandlers:
110
131
  cli.account_manager.add_account.return_value = False
111
132
  assert cli._handle_add(argparse.Namespace(key_type="rsa")) == 1
112
133
 
134
+ def test_handle_add_passes_sign_flag(self, cli):
135
+ cli.account_manager.add_account.return_value = True
136
+ cli._handle_add(argparse.Namespace(key_type="rsa", sign=True))
137
+ cli.account_manager.add_account.assert_called_once_with(
138
+ key_type="rsa", sign=True
139
+ )
140
+
113
141
  def test_handle_switch_success(self, cli):
114
142
  cli.account_manager.switch_account.return_value = True
115
143
  args = argparse.Namespace(account_name="work", local=False)
@@ -160,6 +188,16 @@ class TestHandlers:
160
188
  )
161
189
  assert cli._handle_update(args) == 1
162
190
 
191
+ def test_handle_update_passes_sign_flag(self, cli):
192
+ cli.account_manager.update_account.return_value = True
193
+ args = argparse.Namespace(
194
+ account_name="work", new_git_name=None, new_email=None, sign=True
195
+ )
196
+ cli._handle_update(args)
197
+ cli.account_manager.update_account.assert_called_once_with(
198
+ "work", None, None, new_sign=True
199
+ )
200
+
163
201
 
164
202
  # ---------------------------------------------------------------------------
165
203
  # List shows active account marker
@@ -109,6 +109,14 @@ class TestAddAccount:
109
109
  config.add_account("personal", "Personal", "p@p.com", "/k2", "/k2.pub")
110
110
  assert len(config.read_accounts()) == 2
111
111
 
112
+ def test_defaults_sign_to_false(self, config):
113
+ config.add_account("test", "Test User", "t@t.com", "/k", "/k.pub")
114
+ assert config.get_account("test")["sign"] == "false"
115
+
116
+ def test_persists_sign_true_when_enabled(self, config):
117
+ config.add_account("test", "Test User", "t@t.com", "/k", "/k.pub", sign=True)
118
+ assert config.get_account("test")["sign"] == "true"
119
+
112
120
 
113
121
  class TestRemoveAccount:
114
122
  def test_removes_account(self, config_with_accounts):
@@ -205,6 +213,91 @@ class TestSetGitConfigLocal:
205
213
  assert config.set_git_config_local("Test", "test@test.com") is False
206
214
 
207
215
 
216
+ class TestSetGitSigningConfig:
217
+ def test_sets_signingkey_format_and_gpgsign(self, config):
218
+ with patch("subprocess.run") as mock_run:
219
+ mock_run.return_value = MagicMock(returncode=0)
220
+ result = config.set_git_signing_config("/home/user/.ssh/id_rsa.pub")
221
+ assert result is True
222
+ assert mock_run.call_count == 3
223
+ calls = [call.args[0] for call in mock_run.call_args_list]
224
+ assert [
225
+ "git",
226
+ "config",
227
+ "--global",
228
+ "user.signingkey",
229
+ "/home/user/.ssh/id_rsa.pub",
230
+ ] in calls
231
+ assert ["git", "config", "--global", "gpg.format", "ssh"] in calls
232
+ assert ["git", "config", "--global", "commit.gpgsign", "true"] in calls
233
+
234
+ def test_returns_false_on_error(self, config):
235
+ with patch(
236
+ "subprocess.run", side_effect=subprocess.CalledProcessError(1, "git")
237
+ ):
238
+ assert config.set_git_signing_config("/k.pub") is False
239
+
240
+
241
+ class TestUnsetGitSigningConfig:
242
+ def test_unsets_all_signing_keys(self, config):
243
+ with patch("subprocess.run") as mock_run:
244
+ mock_run.return_value = MagicMock(returncode=0)
245
+ result = config.unset_git_signing_config()
246
+ assert result is True
247
+ assert mock_run.call_count == 3
248
+ calls = [call.args[0] for call in mock_run.call_args_list]
249
+ assert ["git", "config", "--global", "--unset", "user.signingkey"] in calls
250
+ assert ["git", "config", "--global", "--unset", "gpg.format"] in calls
251
+ assert ["git", "config", "--global", "--unset", "commit.gpgsign"] in calls
252
+
253
+ def test_succeeds_even_when_key_was_unset(self, config):
254
+ # git config --unset on a missing key returns non-zero, but we don't
255
+ # pass check=True for unset, so this should not raise/fail.
256
+ with patch("subprocess.run") as mock_run:
257
+ mock_run.return_value = MagicMock(returncode=5)
258
+ assert config.unset_git_signing_config() is True
259
+
260
+
261
+ class TestSetGitSigningConfigLocal:
262
+ def test_calls_git_config_without_global_flag(self, config):
263
+ with patch("subprocess.run") as mock_run:
264
+ mock_run.return_value = MagicMock(returncode=0)
265
+ result = config.set_git_signing_config_local("/k.pub")
266
+ assert result is True
267
+ for call in mock_run.call_args_list:
268
+ assert "--global" not in call.args[0]
269
+
270
+ def test_passes_repo_path_as_cwd(self, config, tmp_path):
271
+ with patch("subprocess.run") as mock_run:
272
+ mock_run.return_value = MagicMock(returncode=0)
273
+ config.set_git_signing_config_local("/k.pub", tmp_path)
274
+ for call in mock_run.call_args_list:
275
+ assert call.kwargs["cwd"] == tmp_path
276
+
277
+ def test_returns_false_on_error(self, config):
278
+ with patch(
279
+ "subprocess.run", side_effect=subprocess.CalledProcessError(1, "git")
280
+ ):
281
+ assert config.set_git_signing_config_local("/k.pub") is False
282
+
283
+
284
+ class TestUnsetGitSigningConfigLocal:
285
+ def test_calls_git_config_without_global_flag(self, config):
286
+ with patch("subprocess.run") as mock_run:
287
+ mock_run.return_value = MagicMock(returncode=0)
288
+ result = config.unset_git_signing_config_local()
289
+ assert result is True
290
+ for call in mock_run.call_args_list:
291
+ assert "--global" not in call.args[0]
292
+
293
+ def test_passes_repo_path_as_cwd(self, config, tmp_path):
294
+ with patch("subprocess.run") as mock_run:
295
+ mock_run.return_value = MagicMock(returncode=0)
296
+ config.unset_git_signing_config_local(tmp_path)
297
+ for call in mock_run.call_args_list:
298
+ assert call.kwargs["cwd"] == tmp_path
299
+
300
+
208
301
  class TestGetCurrentGitConfig:
209
302
  def test_returns_name_and_email_keys(self, config):
210
303
  with patch("subprocess.run") as mock_run:
File without changes