C · 17666 bytes Raw Blame History
1 /* gitswitch-c: Safe git identity switching with SSH/GPG isolation
2 * Complete CLI with account management and authentication isolation
3 */
4
5 #include <stdio.h>
6 #include <stdlib.h>
7 #include <string.h>
8 #include <ctype.h>
9 #include <getopt.h>
10 #include <unistd.h>
11
12 #include "gitswitch.h"
13 #include "config.h"
14 #include "accounts.h"
15 #include "display.h"
16 #include "error.h"
17 #include "utils.h"
18 #include "git_ops.h"
19 #include "ssh_manager.h"
20
21 /* Long-only options (no short form). Values above 0xff avoid colliding with
22 * ASCII short options handled by getopt_long. */
23 #define OPT_SSH_AGENT_INFO 0x100
24
25 static void print_usage(const char *prog_name) {
26 printf("Usage: %s [OPTIONS] [COMMAND] [ARGS]\n", prog_name);
27 printf("\nComplete Git Identity Management\n");
28 printf("Safe git identity switching with actual git configuration management\n");
29 printf("\nCommands:\n");
30 printf(" add Add new account interactively\n");
31 printf(" list, ls List all configured accounts\n");
32 printf(" remove <account> Remove specified account\n");
33 printf(" status Show current account status\n");
34 printf(" doctor, health Run comprehensive health check\n");
35 printf(" config Show configuration file information\n");
36 printf(" init <shell> Emit shell integration (fish|bash|zsh|sh)\n");
37 printf(" <account> Switch to specified account\n");
38 printf("\nOptions:\n");
39 printf(" --global, -g Use global git scope\n");
40 printf(" --local, -l Use local git scope (default)\n");
41 printf(" --dry-run, -n Show what would be done without executing\n");
42 printf(" --verbose, -V Enable verbose output\n");
43 printf(" --debug, -d Enable debug logging\n");
44 printf(" --color, -c Force color output\n");
45 printf(" --no-color, -C Disable color output\n");
46 printf(" --help, -h Show this help message\n");
47 printf(" --version, -v Show version information\n");
48 printf("\nExamples:\n");
49 printf(" %s add # Add new account interactively\n", prog_name);
50 printf(" %s list # List all accounts\n", prog_name);
51 printf(" %s 1 # Switch to account ID 1\n", prog_name);
52 printf(" %s work # Switch to account matching 'work'\n", prog_name);
53 printf(" %s remove 2 # Remove account ID 2\n", prog_name);
54 printf(" %s doctor # Run health check\n", prog_name);
55 printf("\nKey Features:\n");
56 printf("- Secure TOML configuration management\n");
57 printf("- Interactive account creation with validation\n");
58 printf("- Comprehensive account health checking\n");
59 printf("- SSH/GPG key validation and security checks\n");
60 printf("- Atomic configuration file operations\n");
61 printf("- Safe file permission handling\n");
62 printf("- Actual git configuration switching\n");
63 printf("- Repository detection and scope management\n");
64 printf("- Git configuration validation and testing\n");
65 }
66 static void print_version(void) {
67 printf("%s %s (%s)\n", GITSWITCH_NAME, GITSWITCH_VERSION, GITSWITCH_COMMIT);
68 }
69 static int handle_add_command(gitswitch_ctx_t *ctx);
70 static int handle_list_command(gitswitch_ctx_t *ctx);
71 static int handle_remove_command(gitswitch_ctx_t *ctx, const char *identifier);
72 static int handle_status_command(gitswitch_ctx_t *ctx);
73 static int handle_switch_command(gitswitch_ctx_t *ctx, const char *identifier);
74 static int handle_doctor_command(gitswitch_ctx_t *ctx);
75 static int handle_config_command(gitswitch_ctx_t *ctx);
76 static int handle_init_command(const char *shell);
77 static const char *detect_shell_from_env(void);
78
79 int main(int argc, char *argv[]) {
80 gitswitch_ctx_t ctx;
81 int opt;
82 bool force_color = false;
83 bool no_color = false;
84 bool show_help = false;
85 bool show_version = false;
86 bool dry_run = false;
87 int exit_code = EXIT_SUCCESS;
88
89 static struct option long_options[] = {
90 {"help", no_argument, 0, 'h'},
91 {"version", no_argument, 0, 'v'},
92 {"color", no_argument, 0, 'c'},
93 {"no-color", no_argument, 0, 'C'},
94 {"verbose", no_argument, 0, 'V'},
95 {"debug", no_argument, 0, 'd'},
96 {"dry-run", no_argument, 0, 'n'},
97 {"global", no_argument, 0, 'g'},
98 {"local", no_argument, 0, 'l'},
99 /* Compat alias for the Python gitswitch era. Dispatches to `init`
100 * with shell auto-detected from $SHELL so stale rc lines keep working. */
101 {"ssh-agent-info", no_argument, 0, OPT_SSH_AGENT_INFO},
102 {0, 0, 0, 0}
103 };
104
105 /* Initialize error handling - use WARN level for release builds, INFO for debug */
106 #ifdef DEBUG
107 if (error_init(LOG_LEVEL_INFO, NULL) != 0) {
108 #else
109 if (error_init(LOG_LEVEL_WARNING, NULL) != 0) {
110 #endif
111 fprintf(stderr, "Failed to initialize error handling\n");
112 return EXIT_FAILURE;
113 }
114
115 /* Parse command line options */
116 while ((opt = getopt_long(argc, argv, "hvccVdngl", long_options, NULL)) != -1) {
117 switch (opt) {
118 case 'h':
119 show_help = true;
120 break;
121 case 'v':
122 show_version = true;
123 break;
124 case 'c':
125 force_color = true;
126 break;
127 case 'C':
128 no_color = true;
129 break;
130 case 'V':
131 case 'd':
132 set_log_level(LOG_LEVEL_DEBUG);
133 break;
134 case 'n':
135 dry_run = true;
136 break;
137 case 'g':
138 /* Global scope - will be handled by command handlers */
139 break;
140 case 'l':
141 /* Local scope - will be handled by command handlers */
142 break;
143 case OPT_SSH_AGENT_INFO: {
144 int rc = handle_init_command(detect_shell_from_env());
145 error_cleanup();
146 return rc;
147 }
148 default:
149 print_usage(argv[0]);
150 error_cleanup();
151 return EXIT_FAILURE;
152 }
153 }
154
155 /* Initialize display system */
156 if (display_init(force_color, no_color) != 0) {
157 log_error("Failed to initialize display system");
158 error_cleanup();
159 return EXIT_FAILURE;
160 }
161
162 /* Handle special commands that don't need config */
163 if (show_version) {
164 print_version();
165 error_cleanup();
166 return EXIT_SUCCESS;
167 }
168
169 if (show_help) {
170 print_usage(argv[0]);
171 error_cleanup();
172 return EXIT_SUCCESS;
173 }
174
175 /* Initialize configuration system */
176 log_info("Initializing gitswitch-c configuration system");
177 if (config_init(&ctx) != 0) {
178 display_error("Configuration initialization failed", get_last_error()->message);
179 error_cleanup();
180 return EXIT_CONFIG_ERROR;
181 }
182
183 /* Set dry run mode if requested */
184 ctx.config.dry_run = dry_run;
185 ctx.config.verbose = (get_last_error() != NULL && should_log(LOG_LEVEL_DEBUG));
186
187 /* Parse command and arguments */
188 const char *command = NULL;
189 const char *arg1 = NULL;
190
191 if (optind < argc) {
192 command = argv[optind];
193 if (optind + 1 < argc) {
194 arg1 = argv[optind + 1];
195 }
196 }
197
198 /* Execute command */
199 if (command == NULL) {
200 /* No command specified - interactive mode or help */
201 if (ctx.account_count == 0) {
202 display_header("Welcome to gitswitch-c");
203 display_warning("No accounts configured yet");
204 printf("\nTo get started:\n");
205 printf(" 1. Run 'gitswitch add' to create your first account\n");
206 printf(" 2. Run 'gitswitch list' to see all accounts\n");
207 printf(" 3. Run 'gitswitch <account>' to switch accounts\n");
208 printf(" 4. Run 'gitswitch --help' for more options\n\n");
209 } else {
210 /* Show account list */
211 exit_code = handle_list_command(&ctx);
212 }
213 } else if (strcmp(command, "add") == 0) {
214 exit_code = handle_add_command(&ctx);
215 } else if (strcmp(command, "list") == 0 || strcmp(command, "ls") == 0) {
216 exit_code = handle_list_command(&ctx);
217 } else if (strcmp(command, "remove") == 0 || strcmp(command, "rm") == 0 || strcmp(command, "delete") == 0) {
218 if (!arg1) {
219 display_error("Missing account identifier", "Usage: gitswitch remove <account>");
220 exit_code = EXIT_FAILURE;
221 } else {
222 exit_code = handle_remove_command(&ctx, arg1);
223 }
224 } else if (strcmp(command, "status") == 0) {
225 exit_code = handle_status_command(&ctx);
226 } else if (strcmp(command, "doctor") == 0 || strcmp(command, "health") == 0) {
227 exit_code = handle_doctor_command(&ctx);
228 } else if (strcmp(command, "config") == 0) {
229 exit_code = handle_config_command(&ctx);
230 } else if (strcmp(command, "init") == 0) {
231 exit_code = handle_init_command(arg1 ? arg1 : detect_shell_from_env());
232 } else {
233 /* Assume it's an account identifier for switching */
234 exit_code = handle_switch_command(&ctx, command);
235 }
236
237 /* Save configuration only for commands that modify accounts */
238 bool should_save = false;
239 if (command && exit_code == EXIT_SUCCESS && !dry_run) {
240 if (strcmp(command, "add") == 0 ||
241 strcmp(command, "remove") == 0 ||
242 strcmp(command, "rm") == 0 ||
243 strcmp(command, "delete") == 0) {
244 should_save = true;
245 } else if (strcmp(command, "list") != 0 &&
246 strcmp(command, "ls") != 0 &&
247 strcmp(command, "status") != 0 &&
248 strcmp(command, "doctor") != 0 &&
249 strcmp(command, "health") != 0 &&
250 strcmp(command, "config") != 0 &&
251 strcmp(command, "init") != 0) {
252 /* Assume it's a switch command - may have modified default scope */
253 should_save = true;
254 }
255
256 if (should_save) {
257 log_debug("Saving configuration after %s command (account_count=%zu)",
258 command, ctx.account_count);
259 if (config_save(&ctx, ctx.config.config_path) != 0) {
260 display_warning("Failed to save configuration changes");
261 /* Don't fail the command, just warn */
262 }
263 }
264 }
265
266 /* Note: We intentionally do NOT clean up SSH agents on exit.
267 * The agent should persist so subsequent git commands can use it.
268 * Cleanup happens at the start of the next account switch. */
269
270 /* Cleanup error handling */
271 error_cleanup();
272 return exit_code == EXIT_SUCCESS ? EXIT_SUCCESS : EXIT_FAILURE;
273 }
274
275 /* Command handler implementations */
276
277 static int handle_add_command(gitswitch_ctx_t *ctx) {
278 if (!ctx) return EXIT_FAILURE;
279
280 if (accounts_add_interactive(ctx) != 0) {
281 display_error("Failed to add account", get_last_error()->message);
282 return EXIT_FAILURE;
283 }
284
285 return EXIT_SUCCESS;
286 }
287
288 static int handle_list_command(gitswitch_ctx_t *ctx) {
289 if (!ctx) return EXIT_FAILURE;
290
291 return accounts_list(ctx) == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
292 }
293
294 static int handle_remove_command(gitswitch_ctx_t *ctx, const char *identifier) {
295 if (!ctx || !identifier) return EXIT_FAILURE;
296
297 if (accounts_remove(ctx, identifier) != 0) {
298 display_error("Failed to remove account", get_last_error()->message);
299 return EXIT_FAILURE;
300 }
301
302 return EXIT_SUCCESS;
303 }
304
305 static int handle_status_command(gitswitch_ctx_t *ctx) {
306 if (!ctx) return EXIT_FAILURE;
307
308 return accounts_show_status(ctx) == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
309 }
310
311 static int handle_switch_command(gitswitch_ctx_t *ctx, const char *identifier) {
312 if (!ctx || !identifier) return EXIT_FAILURE;
313
314 if (ctx->config.dry_run) {
315 display_info("DRY RUN MODE - No actual changes will be made");
316 }
317
318 if (accounts_switch(ctx, identifier) != 0) {
319 display_error("Failed to switch account", get_last_error()->message);
320 return EXIT_FAILURE;
321 }
322
323 /* accounts_switch already prints detailed status, just confirm success */
324 display_success("Switched to: %s", ctx->current_account->name);
325
326 return EXIT_SUCCESS;
327 }
328
329 static int handle_doctor_command(gitswitch_ctx_t *ctx) {
330 if (!ctx) return EXIT_FAILURE;
331
332 /* Check system requirements */
333 printf("[INFO]: Checking system requirements...\n");
334
335 if (command_exists("git")) {
336 display_success("Git command found");
337 } else {
338 display_error("Git not found", "Please install git to use gitswitch");
339 return EXIT_FAILURE;
340 }
341
342 if (command_exists("ssh-agent")) {
343 display_success("SSH agent found");
344 } else {
345 display_warning("SSH agent not found - SSH key management may not work");
346 }
347
348 if (command_exists("gpg") || command_exists("gpg2")) {
349 display_success("GPG found");
350 } else {
351 display_warning("GPG not found - GPG signing will not work");
352 }
353
354 /* Check configuration */
355 printf("\n[INFO]: Checking configuration...\n");
356
357 if (config_validate(ctx) == 0) {
358 display_success("Configuration validation passed");
359 } else {
360 display_error("Configuration validation failed", get_last_error()->message);
361 return EXIT_FAILURE;
362 }
363
364 /* Check all accounts */
365 return accounts_health_check(ctx) == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
366 }
367
368 static int handle_config_command(gitswitch_ctx_t *ctx) {
369 if (!ctx) return EXIT_FAILURE;
370
371 printf("📁 Configuration file: %s\n", ctx->config.config_path);
372
373 if (!path_exists(ctx->config.config_path)) {
374 display_warning("Configuration file does not exist");
375 printf("Create default configuration? (y/N): ");
376 fflush(stdout);
377
378 char input[64];
379 if (fgets(input, sizeof(input), stdin)) {
380 input[strcspn(input, "\n")] = '\0';
381 trim_whitespace(input);
382
383 if (tolower(input[0]) == 'y') {
384 if (config_create_default(ctx->config.config_path) == 0) {
385 display_success("Default configuration created");
386 printf("Please edit the file to add your accounts.\n");
387 } else {
388 display_error("Failed to create default configuration", get_last_error()->message);
389 return EXIT_FAILURE;
390 }
391 }
392 }
393 return EXIT_SUCCESS;
394 }
395
396 /* Show configuration info */
397 printf("Accounts: %zu configured\n", ctx->account_count);
398 printf("Default scope: %s\n", config_scope_to_string(ctx->config.default_scope));
399
400 /* Check permissions */
401 mode_t file_mode;
402 if (get_file_permissions(ctx->config.config_path, &file_mode) == 0) {
403 if ((file_mode & 077) == 0) {
404 display_success("Configuration file permissions are secure (600)");
405 } else {
406 display_warning("Configuration file has unsafe permissions (%o)", file_mode & 0777);
407 }
408 }
409
410 return EXIT_SUCCESS;
411 }
412
413 /* Return the basename of $SHELL, or NULL if it can't be determined. The
414 * pointer aliases into the environment string — callers must not free it. */
415 static const char *detect_shell_from_env(void) {
416 const char *shell = getenv("SHELL");
417 if (!shell || !*shell) {
418 return NULL;
419 }
420 const char *slash = strrchr(shell, '/');
421 return slash ? slash + 1 : shell;
422 }
423
424 /* Emit shell-integration snippet for `shell` on stdout. The snippet sets
425 * SSH_AUTH_SOCK to the stable gitswitch symlink, guarded by a socket test so
426 * sourcing before the first switch (or after /tmp is wiped) is silent. */
427 static int handle_init_command(const char *shell) {
428 char sock_path[MAX_PATH_LEN];
429 if (ssh_manager_get_auth_sock_path(sock_path, sizeof(sock_path)) != 0) {
430 fprintf(stderr, "gitswitch: failed to compute SSH_AUTH_SOCK path: %s\n",
431 get_last_error()->message);
432 return EXIT_FAILURE;
433 }
434
435 if (!shell || !*shell) {
436 fprintf(stderr,
437 "gitswitch: could not detect shell; pass one explicitly:\n"
438 " gitswitch init fish | source\n"
439 " eval \"$(gitswitch init bash)\"\n"
440 " eval \"$(gitswitch init zsh)\"\n");
441 return EXIT_FAILURE;
442 }
443
444 if (strcmp(shell, "fish") == 0) {
445 printf("# gitswitch shell integration (fish)\n");
446 printf("set -l __gitswitch_auth_sock %s\n", sock_path);
447 printf("if test -S $__gitswitch_auth_sock\n");
448 printf(" set -gx SSH_AUTH_SOCK $__gitswitch_auth_sock\n");
449 printf("end\n");
450 printf("set -e __gitswitch_auth_sock\n");
451 return EXIT_SUCCESS;
452 }
453
454 if (strcmp(shell, "bash") == 0 || strcmp(shell, "zsh") == 0 ||
455 strcmp(shell, "sh") == 0 || strcmp(shell, "dash") == 0 ||
456 strcmp(shell, "ksh") == 0) {
457 printf("# gitswitch shell integration (%s)\n", shell);
458 printf("__gitswitch_auth_sock=%s\n", sock_path);
459 printf("[ -S \"$__gitswitch_auth_sock\" ] && export SSH_AUTH_SOCK=\"$__gitswitch_auth_sock\"\n");
460 printf("unset __gitswitch_auth_sock\n");
461 return EXIT_SUCCESS;
462 }
463
464 fprintf(stderr,
465 "gitswitch: unsupported shell '%s' (supported: fish, bash, zsh, sh, dash, ksh)\n",
466 shell);
467 return EXIT_FAILURE;
468 }