| 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 | } |