From f9b2f90ea3809b5514f15a09af692453c5df99dd Mon Sep 17 00:00:00 2001 From: Joshua Ike <79171733+kxng0109@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:07:43 +0100 Subject: [PATCH] feat(cli): add yes, dry-run, amend, stage-all, model/provider flags, completions Fail-closed arg parser, per-run provider/model overrides, amend + stage-tracked flows, preview mode, bundled shell completions with native-image metadata. 49 new tests; 217 total, gate 95.8pct line / 92.4pct branch. --- CHANGELOG.md | 6 + README.md | 31 ++ TEST_SUMMARY.md | 11 +- .../java/io/github/kxng0109/AiCommitCli.java | 292 ++++++++++++++++-- .../io/github/kxng0109/config/Config.java | 54 ++++ .../kxng0109/service/AiProviderFactory.java | 124 +++++--- .../kxng0109/service/CommitService.java | 80 ++++- .../github/kxng0109/service/GitService.java | 29 ++ .../ai-commit-cli/reachability-metadata.json | 12 + src/main/resources/completions/ai-commit.bash | 40 +++ src/main/resources/completions/ai-commit.fish | 15 + src/main/resources/completions/ai-commit.ps1 | 26 ++ src/main/resources/completions/ai-commit.zsh | 37 +++ .../github/kxng0109/AiCommitCliArgsTest.java | 140 +++++++++ .../kxng0109/AiCommitCliDispatchTest.java | 190 ++++++++++++ .../io/github/kxng0109/CompletionTest.java | 62 ++++ .../kxng0109/config/ConfigWithModelTest.java | 79 +++++ .../AiProviderFactorySelectionTest.java | 113 +++++++ .../service/CommitServiceFlowTest.java | 113 +++++++ .../kxng0109/service/GitServiceWriteTest.java | 90 ++++++ 20 files changed, 1463 insertions(+), 81 deletions(-) create mode 100644 src/main/resources/completions/ai-commit.bash create mode 100644 src/main/resources/completions/ai-commit.fish create mode 100644 src/main/resources/completions/ai-commit.ps1 create mode 100644 src/main/resources/completions/ai-commit.zsh create mode 100644 src/test/java/io/github/kxng0109/AiCommitCliArgsTest.java create mode 100644 src/test/java/io/github/kxng0109/AiCommitCliDispatchTest.java create mode 100644 src/test/java/io/github/kxng0109/CompletionTest.java create mode 100644 src/test/java/io/github/kxng0109/config/ConfigWithModelTest.java create mode 100644 src/test/java/io/github/kxng0109/service/AiProviderFactorySelectionTest.java create mode 100644 src/test/java/io/github/kxng0109/service/CommitServiceFlowTest.java create mode 100644 src/test/java/io/github/kxng0109/service/GitServiceWriteTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 71b9b24..3311f5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `--yes`/`-y` commits without prompting (one-shot non-interactive mode); `--dry-run` prints the message without committing. +- `--amend` regenerates the message and amends the previous commit; `-a`/`--all` stages tracked modifications first. +- `--provider` and `--model` overrides per run; unknown options/commands are now rejected instead of ignored. +- `completion` command printing bash/zsh/fish/powershell scripts (bundled resources, native-image ready). + ### Changed - Native binary caps heap at 256MB (`-R:MaxHeapSize=256m`, overridable per run with `-Xmx`); SBOM emits JSON only. diff --git a/README.md b/README.md index 7353d09..8090e69 100644 --- a/README.md +++ b/README.md @@ -265,6 +265,37 @@ ai-commit # If auto-commit is enabled, it commits instantly AND pushes instantl # Note: auto-push does not require auto-commit to work ``` +### Command-Line Flags + +Flags work on any run without changing saved settings: + +```bash +git add . +ai-commit --yes # Commit immediately, no prompt (great for scripts) +ai-commit --dry-run # Print the message, commit nothing +ai-commit --amend # Regenerate the message and amend the last commit +ai-commit -a # Stage tracked modifications first (like git commit -a) +ai-commit --provider anthropic # Use one provider for this run only +ai-commit --model gpt-4o # Override the model for this run only +ai-commit --provider ollama --model llama3 +``` + +Unknown options and commands are rejected instead of ignored. `--model` is +rejected for DeepSeek, which uses a fixed model. + +### Shell Completions + +```bash +# Bash +ai-commit completion bash >> ~/.bash_completion +# Zsh +ai-commit completion zsh > ~/.zsh/completions/_ai-commit +# Fish +ai-commit completion fish > ~/.config/fish/completions/ai-commit.fish +# PowerShell +ai-commit completion powershell >> $PROFILE +``` + ## Examples **OpenAI:** diff --git a/TEST_SUMMARY.md b/TEST_SUMMARY.md index 1253ec1..ac607ff 100644 --- a/TEST_SUMMARY.md +++ b/TEST_SUMMARY.md @@ -1,8 +1,8 @@ # Test Suite Summary -## Total Tests: 168 +## Total Tests: 217 -JaCoCo gate on `verify`: 90% line + branch minimum (currently 96.2% line, 90.5% branch). +JaCoCo gate on `verify`: 90% line + branch minimum (currently 95.8% line, 92.4% branch). --- @@ -109,22 +109,29 @@ mvn clean verify | ConfigTest | 9 | ~100% provider/config logic and guards | | ConfigPolicyTest | 10 | URL policy, loopback, truthy, timeout range | | ConfigParsingTest | 10 | temperature/timeout parsing, isConfigured | +| ConfigWithModelTest | 6 | per-run model overrides | | UserPreferencesTest | 10 | ~100% preference/state/formatting | | AiCommitCliTest | 4 | toggle parsing, error description | | AiCommitCliRunTest | 18 | run() paths: version/help/config/commit-flow| +| AiCommitCliArgsTest | 11 | flag parsing, fail-closed rejects | +| AiCommitCliDispatchTest | 11 | flow dispatch, overrides, shells | | AiProviderFactoryTest | 4 | ~100% provider selection/prioritization | | AiProviderFactoryReliabilityTest | 6 | per-provider type, output caps, priority | +| AiProviderFactorySelectionTest | 6 | forced selection, priority, rejects | | GitServiceTest | 6 | ~90% public GitService usage scenarios | | GitServiceEnvTest | 2 | secret env scrubbing | | GitServiceValidationTest | 7 | timeout/dir/message validation | | GitServiceWorkingDirTest | 6 | directory resolution matrix | | GitServiceProcessTest | 8 | timeout/interrupt/truncation/process errors | +| GitServiceWriteTest | 3 | stage tracked, amend | | SecretScannerTest | 17 | secret patterns, caps, summary hygiene | | CommitServiceTest | 15 | ~95% workflow, interactive, AI | | CommitServiceAutoPushTest | 12 | ~100% all auto-commit/push paths | | CommitServiceGuardTest | 9 | fail-closed consent, diff policy | | CommitServiceReliabilityTest | 9 | AI error mapping, null stages, interrupt | | CommitServiceEdgeTest | 6 | null diff/output, 5xx, edit edges | +| CommitServiceFlowTest | 5 | preview, forced auto, amend flows | +| CompletionTest | 7 | bundled scripts, shell dispatch | --- diff --git a/src/main/java/io/github/kxng0109/AiCommitCli.java b/src/main/java/io/github/kxng0109/AiCommitCli.java index dee6790..bec7005 100644 --- a/src/main/java/io/github/kxng0109/AiCommitCli.java +++ b/src/main/java/io/github/kxng0109/AiCommitCli.java @@ -11,6 +11,8 @@ import java.io.IOException; import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; import java.util.Locale; import java.util.Properties; @@ -70,35 +72,50 @@ public static void main(String[] args) { * @return 0 on success, 1 on failure */ static int run(String[] args) { - if (args == null) { - args = new String[0]; + CliOptions options; + try { + options = parseArgs(args); + } catch (IllegalArgumentException e) { + System.err.println(e.getMessage()); + return 1; + } + if (options.version()) { + System.out.println("ai-commit version " + VERSION); + return 0; } - if (args.length > 0) { - String arg = args[0]; - if (arg.equals("--version") || arg.equals("-v")) { - System.out.println("ai-commit version " + VERSION); - return 0; - } - if (arg.equals("--help") || arg.equals("-h")) { - printHelp(); - return 0; - } + if (options.help()) { + printHelp(); + return 0; + } - if(arg.equals("config")){ - handleConfigCommand(args); - return 0; + if ("config".equals(options.subcommand())) { + List subArgs = new ArrayList<>(); + subArgs.add("config"); + subArgs.addAll(options.subArgs()); + handleConfigCommand(subArgs.toArray(new String[0])); + return 0; + } + + if ("completion".equals(options.subcommand())) { + try { + printCompletion(options.subArgs()); + } catch (IllegalArgumentException e) { + System.err.println(e.getMessage()); + return 1; + } catch (IOException e) { + System.err.println("Failed to load completion script: " + describeError(e)); + return 1; } + return 0; } try { Config config = Config.loadFromEnv(); GitService gitService = new GitService(config.commandTimeoutSeconds()); - ChatModel chatModel = AiProviderFactory.createChatModel(config); - CommitService commitService = new CommitService(gitService, chatModel); - - commitService.generateAndCommit(); + ChatModel chatModel = resolveChatModel(config, options); + executeCommit(options, gitService, chatModel); return 0; } catch (IllegalStateException e) { System.err.println("\nAn error occurred: " + describeError(e)); @@ -110,6 +127,233 @@ static int run(String[] args) { } } + /** + * Executes the commit flow for parsed options. + *

+ * Separated from {@link #run} so dispatch branches are unit-testable with + * mocked services instead of live providers. + *

+ * + * @param options the parsed options, must not be {@code null} + * @param gitService the git service, must not be {@code null} + * @param chatModel the chat model, must not be {@code null} + */ + static void executeCommit(CliOptions options, GitService gitService, ChatModel chatModel) { + CommitService commitService = new CommitService(gitService, chatModel); + if (options.stageAll()) { + gitService.stageTracked(); + } + if (options.dryRun()) { + System.out.println(commitService.previewMessage()); + return; + } + if (options.amend()) { + commitService.generateAndAmend(options.yes()); + return; + } + if (options.yes()) { + commitService.generateAndCommit(true); + return; + } + commitService.generateAndCommit(); + } + + /** + * Parsed command-line options for one invocation. + * + * @param version true when {@code --version} was passed + * @param help true when {@code --help} was passed + * @param yes true when {@code --yes} was passed + * @param dryRun true when {@code --dry-run} was passed + * @param amend true when {@code --amend} was passed + * @param stageAll true when {@code -a} or {@code --all} was passed + * @param model the {@code --model} value, or {@code null} when absent + * @param provider the {@code --provider} value, or {@code null} when absent + * @param subcommand the positional subcommand ({@code config}, {@code completion}), or {@code null} + * @param subArgs positional arguments following the subcommand + */ + record CliOptions( + boolean version, + boolean help, + boolean yes, + boolean dryRun, + boolean amend, + boolean stageAll, + String model, + String provider, + String subcommand, + List subArgs) { + } + + /** + * Parses command-line arguments into {@link CliOptions}. + *

+ * Fail-closed: unknown options, unknown subcommands, and flags missing + * values are rejected with an actionable message instead of being ignored. + *

+ * + * @param args the raw arguments, may be {@code null} + * @return the parsed options + * @throws IllegalArgumentException when the arguments are invalid + */ + static CliOptions parseArgs(String[] args) { + boolean version = false; + boolean help = false; + boolean yes = false; + boolean dryRun = false; + boolean amend = false; + boolean stageAll = false; + String model = null; + String provider = null; + String subcommand = null; + List subArgs = new ArrayList<>(); + + if (args == null) { + args = new String[0]; + } + for (int i = 0; i < args.length; i++) { + String arg = args[i]; + switch (arg) { + case "--version", "-v": + version = true; + break; + case "--help", "-h": + help = true; + break; + case "--yes", "-y": + yes = true; + break; + case "--dry-run": + dryRun = true; + break; + case "--amend": + amend = true; + break; + case "-a", "--all": + stageAll = true; + break; + case "--model", "--provider": { + if (i + 1 >= args.length || args[i + 1].startsWith("-")) { + throw new IllegalArgumentException( + "Option '" + arg + "' requires a value. See ai-commit --help."); + } + String value = args[++i].trim(); + if (value.isEmpty()) { + throw new IllegalArgumentException( + "Option '" + arg + "' requires a value. See ai-commit --help."); + } + if (arg.equals("--model")) { + model = value; + } else { + provider = value; + } + break; + } + default: + if (subcommand != null) { + subArgs.add(arg); + break; + } + if (arg.startsWith("--model=")) { + model = inlineValue(arg, "--model="); + } else if (arg.startsWith("--provider=")) { + provider = inlineValue(arg, "--provider="); + } else if (arg.startsWith("-")) { + throw new IllegalArgumentException( + "Unknown option '" + arg + "'. See ai-commit --help."); + } else { + if (!arg.equals("config") && !arg.equals("completion")) { + throw new IllegalArgumentException( + "Unknown command '" + arg + "'. See ai-commit --help."); + } + subcommand = arg; + } + break; + } + } + return new CliOptions(version, help, yes, dryRun, amend, stageAll, model, provider, + subcommand, List.copyOf(subArgs)); + } + + /** + * Extracts the value of an inline {@code --flag=value} argument. + * + * @param arg the raw argument, must start with {@code prefix} + * @param prefix the flag prefix including the equals sign + * @return the trimmed non-empty value + * @throws IllegalArgumentException when the value is missing or blank + */ + private static String inlineValue(String arg, String prefix) { + String value = arg.substring(prefix.length()).trim(); + if (value.isEmpty()) { + throw new IllegalArgumentException( + "Option '" + prefix.substring(0, prefix.length() - 1) + "' requires a value. See ai-commit --help."); + } + return value; + } + + /** + * Resolves the chat model, applying per-run provider and model overrides. + * + * @param config the loaded configuration, must not be {@code null} + * @param options the parsed options, must not be {@code null} + * @return the chat model for the selected provider + * @throws IllegalStateException when no provider is available or the override is invalid + */ + static ChatModel resolveChatModel(Config config, CliOptions options) { + if (options.provider() == null && options.model() == null) { + return AiProviderFactory.createChatModel(config); + } + String provider = options.provider() != null + ? options.provider() + : AiProviderFactory.selectedProvider(config); + if (options.model() != null) { + config = config.withModel(provider, options.model()); + } + return AiProviderFactory.createChatModel(config, provider); + } + + /** + * Prints the completion script for the requested shell. + * + * @param subArgs the shell name as the first element + * @throws IllegalArgumentException when the shell is missing or unsupported + * @throws IOException when the bundled script cannot be read + */ + private static void printCompletion(List subArgs) throws IOException { + if (subArgs.isEmpty()) { + throw new IllegalArgumentException( + "Usage: ai-commit completion [bash|zsh|fish|powershell]"); + } + String shell = subArgs.get(0).trim().toLowerCase(Locale.ROOT); + String resource; + switch (shell) { + case "bash": + resource = "completions/ai-commit.bash"; + break; + case "zsh": + resource = "completions/ai-commit.zsh"; + break; + case "fish": + resource = "completions/ai-commit.fish"; + break; + case "powershell": + resource = "completions/ai-commit.ps1"; + break; + default: + throw new IllegalArgumentException( + "Unsupported shell '" + subArgs.get(0).trim() + + "'. Supported: bash, zsh, fish, powershell."); + } + try (InputStream input = AiCommitCli.class.getClassLoader().getResourceAsStream(resource)) { + if (input == null) { + throw new IOException("completion script not found: " + resource); + } + System.out.write(input.readAllBytes()); + System.out.flush(); + } + } + /** * Resolves a raw log level name to an allowlisted slf4j-simple level. * @@ -395,6 +639,16 @@ private static void printHelp() { OPTIONS: -h, --help Show this help message -v, --version Show version information + -y, --yes Commit without prompting (non-interactive) + --dry-run Print the message without committing + --amend Amend the previous commit with staged changes + -a, --all Stage tracked modifications first (like git commit -a) + --model Use this model for the run (e.g., --model gpt-4o) + --provider Use this provider: openai, anthropic, google, deepseek, ollama + + COMMANDS: + config Manage settings (see CONFIGURATION) + completion Print shell completions: bash, zsh, fish, powershell ENVIRONMENT VARIABLES: diff --git a/src/main/java/io/github/kxng0109/config/Config.java b/src/main/java/io/github/kxng0109/config/Config.java index 81cf2b2..96f65fd 100644 --- a/src/main/java/io/github/kxng0109/config/Config.java +++ b/src/main/java/io/github/kxng0109/config/Config.java @@ -276,6 +276,60 @@ static int parseInt(String value) { } } + /** + * Returns a copy of this config with the model replaced for one provider. + *

+ * Supported providers are {@code openai}, {@code anthropic}, {@code google}, + * {@code ollama}, and {@code deepseek}. The DeepSeek provider uses a fixed + * model and rejects overrides. + *

+ * + * @param provider the provider name (case-insensitive), must not be {@code null} + * @param model the replacement model, must not be blank + * @return a new config with the model replaced + * @throws IllegalArgumentException when the provider is unknown, blank, or fixed-model, + * or when the model is blank + */ + public Config withModel(String provider, String model) { + if (provider == null || provider.isBlank()) { + throw new IllegalArgumentException( + "Unknown provider ''. Supported: openai, anthropic, google, deepseek, ollama."); + } + if (model == null || model.isBlank()) { + throw new IllegalArgumentException("Model must not be blank."); + } + String name = provider.trim().toLowerCase(Locale.ROOT); + String trimmedModel = model.trim(); + switch (name) { + case "openai": + return new Config( + new OpenAiConfig(openai.apiKey(), openai.baseUrl(), trimmedModel), + anthropic, google, deepseek, ollama, temperature, commandTimeoutSeconds); + case "anthropic": + return new Config( + openai, + new AnthropicConfig(anthropic.apiKey(), trimmedModel), + google, deepseek, ollama, temperature, commandTimeoutSeconds); + case "google": + return new Config( + openai, anthropic, + new GoogleConfig(google.apiKey(), trimmedModel), + deepseek, ollama, temperature, commandTimeoutSeconds); + case "ollama": + return new Config( + openai, anthropic, google, deepseek, + new OllamaConfig(trimmedModel, ollama.baseUrl()), + temperature, commandTimeoutSeconds); + case "deepseek": + throw new IllegalArgumentException( + "The deepseek provider uses a fixed model and does not accept --model."); + default: + throw new IllegalArgumentException( + "Unknown provider '" + provider.trim() + + "'. Supported: openai, anthropic, google, deepseek, ollama."); + } + } + /** * Represents the configuration settings for OpenAI. * This configuration includes the API key, base URL, and model name diff --git a/src/main/java/io/github/kxng0109/service/AiProviderFactory.java b/src/main/java/io/github/kxng0109/service/AiProviderFactory.java index daefa9d..504f230 100644 --- a/src/main/java/io/github/kxng0109/service/AiProviderFactory.java +++ b/src/main/java/io/github/kxng0109/service/AiProviderFactory.java @@ -25,6 +25,7 @@ import org.springframework.web.client.RestClient; import java.time.Duration; +import java.util.Locale; /** * Factory class for creating instances of {@code ChatModel} based on configuration. @@ -98,62 +99,95 @@ private static RestClient.Builder timedRestClient(Duration connect, Duration rea * @throws IllegalStateException if no AI provider is configured. */ public static ChatModel createChatModel(Config config) { - if (config.openai().isConfigured()) { - log.info("Using OpenAI-compatible provider"); - log.debug("Base URL: {}. Model: {}", config.openai().baseUrl(), config.openai().model()); - return createOpenAiModel(config); + return createChatModel(config, selectedProvider(config)); + } + + /** + * Creates a chat model for the named provider. + * + * @param config the application configuration containing provider settings and model parameters + * @param provider the provider name (case-insensitive): openai, anthropic, google, deepseek, ollama + * @return a {@code ChatModel} instance for the requested provider + * @throws IllegalArgumentException when the provider name is unknown + * @throws IllegalStateException when the requested provider is not configured + */ + public static ChatModel createChatModel(Config config, String provider) { + if (provider == null || provider.isBlank()) { + throw new IllegalArgumentException( + "Unknown provider ''. Supported: openai, anthropic, google, deepseek, ollama."); } + String name = provider.trim().toLowerCase(Locale.ROOT); + switch (name) { + case "openai": + requireConfigured(config.openai().isConfigured(), "OpenAI", "OPENAI_API_KEY"); + log.info("Using OpenAI-compatible provider"); + log.debug("Base URL: {}. Model: {}", config.openai().baseUrl(), config.openai().model()); + return createOpenAiModel(config); + case "anthropic": + requireConfigured(config.anthropic().isConfigured(), "Anthropic", "ANTHROPIC_API_KEY"); + log.info("Using Anthropic provider"); + log.debug("Model: {}", config.anthropic().model()); + return createAnthropicModel(config); + case "google": + requireConfigured(config.google().isConfigured(), "Google", "GOOGLE_API_KEY"); + log.info("Using Google Gemini provider"); + log.debug("Model: {}", config.google().model()); + return createGoogleModel(config); + case "deepseek": + requireConfigured(config.deepseek().isConfigured(), "DeepSeek", "DEEPSEEK_API_KEY"); + log.info("Using Deepseek provider"); + return createDeepseekModel(config); + case "ollama": + requireConfigured(config.ollama().isConfigured(), "Ollama", "OLLAMA_MODEL"); + log.info("Ollama is configured, Using Ollama provider."); + log.debug("Base URL: {}. Model: {}", config.ollama().baseUrl(), config.ollama().model()); + return createOllamaModel(config); + default: + throw new IllegalArgumentException( + "Unknown provider '" + provider.trim() + + "'. Supported: openai, anthropic, google, deepseek, ollama."); + } + } + /** + * Returns the first configured provider in priority order. + * + * @param config the application configuration, must not be {@code null} + * @return the provider name: openai, anthropic, google, deepseek, or ollama + * @throws IllegalStateException if no AI provider is configured + */ + public static String selectedProvider(Config config) { + if (config.openai().isConfigured()) { + return "openai"; + } if (config.anthropic().isConfigured()) { - log.info("Using Anthropic provider"); - log.debug("Model: {}", config.anthropic().model()); - return createAnthropicModel(config); + return "anthropic"; } - if (config.google().isConfigured()) { - log.info("Using Google Gemini provider"); - log.debug("Model: {}", config.google().model()); - return createGoogleModel(config); + return "google"; } - if (config.deepseek().isConfigured()) { - log.info("Using Deepseek provider"); - return createDeepseekModel(config); + return "deepseek"; } - if (config.ollama().isConfigured()) { - log.info("Ollama is configured, Using Ollama provider."); - log.debug("Base URL: {}. Model: {}", config.ollama().baseUrl(), config.ollama().model()); - return createOllamaModel(config); + return "ollama"; } + throw new IllegalStateException("No AI provider configured. Set at least one API key (e.g., OPENAI_API_KEY) or OLLAMA_MODEL."); + } - throw new IllegalStateException( - """ - No AI provider configured. Please set one of the following: - - 1. OpenAI / OpenAI-compatible: - export OPENAI_API_KEY="your-api-key" - export OPENAI_MODEL="gpt-4o" # optional - export OPENAI_BASE_URL="https://api.openai.com" # optional - - 2. Anthropic Claude: - export ANTHROPIC_API_KEY="your-api-key" - export ANTHROPIC_MODEL="claude-sonnet-4-0" # optional - - 3. Google Gemini: - export GOOGLE_API_KEY="your-api-key" - export GOOGLE_MODEL="gemini-2.0-flash-exp" # optional - - 4. Deepseek: - export DEEPSEEK_API_KEY="your-api-key" - - 5. Ollama (local): - export OLLAMA_MODEL="llama3" - export OLLAMA_BASE_URL="http://localhost:11434" # optional - - Run 'ai-commit --help' for more information. - """ - ); + /** + * Requires a provider to be configured, raising an actionable error otherwise. + * + * @param configured whether the provider is configured + * @param displayName the human-readable provider name for messages + * @param envVar the environment variable that configures the provider + * @throws IllegalStateException when the provider is not configured + */ + private static void requireConfigured(boolean configured, String displayName, String envVar) { + if (!configured) { + throw new IllegalStateException( + displayName + " provider selected but not configured. Set " + envVar + "."); + } } /** diff --git a/src/main/java/io/github/kxng0109/service/CommitService.java b/src/main/java/io/github/kxng0109/service/CommitService.java index b932bb3..792b199 100644 --- a/src/main/java/io/github/kxng0109/service/CommitService.java +++ b/src/main/java/io/github/kxng0109/service/CommitService.java @@ -179,6 +179,62 @@ public CommitService(GitService gitService, ChatModel chatModel) { * @throws IllegalStateException if no staged changes are found in the repository. */ public void generateAndCommit() { + generateAndCommit(UserPreferences.isAutoCommitEnabled()); + } + + /** + * Generates a commit message and commits staged changes. + * + * @param auto when {@code true} the message is committed without prompting, + * otherwise the interactive flow is used + */ + public void generateAndCommit(boolean auto) { + String diff = resolveDiff(); + boolean autoPush = UserPreferences.isAutoPushEnabled(); + + if (auto) { + log.info("Auto-commit is enabled. Generating commit message."); + handleAutoCommit(diff, autoPush, false); + } else { + handleInteractiveCommit(diff, autoPush, false); + } + } + + /** + * Generates a commit message and amends the previous commit with staged changes. + * + * @param auto when {@code true} the message is amended without prompting, + * otherwise the interactive flow is used + */ + public void generateAndAmend(boolean auto) { + String diff = resolveDiff(); + boolean autoPush = UserPreferences.isAutoPushEnabled(); + + if (auto) { + log.info("Auto-amend is enabled. Generating commit message."); + handleAutoCommit(diff, autoPush, true); + } else { + handleInteractiveCommit(diff, autoPush, true); + } + } + + /** + * Generates a commit message for staged changes without committing. + * + * @return the generated commit message + * @throws IllegalStateException when there are no staged changes or the diff is blocked + */ + public String previewMessage() { + return generateMessage(resolveDiff()); + } + + /** + * Fetches the staged diff and guards it against AI submission policy. + * + * @return the guarded staged diff, never {@code null} or blank + * @throws IllegalStateException when there are no staged changes or the diff is blocked + */ + private String resolveDiff() { log.info("Checking for staged changes..."); String diff; try { @@ -191,15 +247,7 @@ public void generateAndCommit() { } guardDiff(diff); - boolean autoCommit = UserPreferences.isAutoCommitEnabled(); - boolean autoPush = UserPreferences.isAutoPushEnabled(); - - if (autoCommit) { - log.info("Auto-commit is enabled. Generating commit message."); - handleAutoCommit(diff, autoPush); - } else { - handleInteractiveCommit(diff, autoPush); - } + return diff; } /** @@ -239,16 +287,17 @@ private void guardDiff(String diff) { * @param diff a string representation of the changes (diff) used to generate the commit message. * @param autoPush a boolean flag indicating whether changes should be pushed automatically * to the remote repository after committing. If {@code true}, auto-push is enabled. + * @param amend when {@code true} the previous commit is amended instead of creating a new commit. */ - private void handleAutoCommit(String diff, boolean autoPush) { + private void handleAutoCommit(String diff, boolean autoPush, boolean amend) { try { log.info("Generating commit message with AI..."); String commitMessage = generateMessage(diff); displayMessage(commitMessage); - System.out.println("\nAuto-committing..."); + System.out.println(amend ? "\nAuto-amending..." : "\nAuto-committing..."); - String output = gitService.commit(commitMessage); + String output = amend ? gitService.amend(commitMessage) : gitService.commit(commitMessage); displayGitOutput(output); log.info("Committed changes successfully."); @@ -279,8 +328,9 @@ private void handleAutoCommit(String diff, boolean autoPush) { * @param autoPush a boolean value indicating whether to automatically push changes * to the remote repository after a successful commit. If {@code true}, * automatic push is attempted; otherwise, it is skipped. + * @param amend when {@code true} the previous commit is amended instead of creating a new commit. */ - private void handleInteractiveCommit(String diff, boolean autoPush) { + private void handleInteractiveCommit(String diff, boolean autoPush, boolean amend) { String commitMessage = null; boolean isCommitted = false; @@ -296,8 +346,8 @@ private void handleInteractiveCommit(String diff, boolean autoPush) { switch (choice) { case "y", "yes": - log.info("Committing changes..."); - String output = gitService.commit(commitMessage); + log.info(amend ? "Amending changes..." : "Committing changes..."); + String output = amend ? gitService.amend(commitMessage) : gitService.commit(commitMessage); displayGitOutput(output); log.info("Committed changes successfully."); diff --git a/src/main/java/io/github/kxng0109/service/GitService.java b/src/main/java/io/github/kxng0109/service/GitService.java index 4ea9c40..a77628a 100644 --- a/src/main/java/io/github/kxng0109/service/GitService.java +++ b/src/main/java/io/github/kxng0109/service/GitService.java @@ -167,6 +167,20 @@ public String getStagedDiff() { return result; } + /** + * Stages tracked modifications and deletions, like {@code git commit -a}. + *

+ * Only already-tracked files are staged; untracked files are left alone + * and must be added explicitly with {@code git add}. + *

+ * + * @return the output of the git add command + */ + public String stageTracked() { + log.debug("Staging tracked changes..."); + return runCommand("git", "add", "--update"); + } + /** * Commits the currently staged changes in the Git repository with the provided commit message. * This method invokes the Git command to commit changes and adds the specified message. @@ -184,6 +198,21 @@ public String commit(String message) { log.debug("Committing changes..."); return runCommand("git", "commit", "--message", message); } + + /** + * Amends the previous commit with the currently staged changes and a new message. + * + * @param message the replacement commit message, must not be {@code null} or blank + * @return the output of the git amend command + * @throws IllegalArgumentException when the message is {@code null} or blank + */ + public String amend(String message) { + if (message == null || message.isBlank()) { + throw new IllegalArgumentException("commit message must not be blank."); + } + log.debug("Amending previous commit..."); + return runCommand("git", "commit", "--amend", "--message", message); + } /** * Pushes changes to the remote Git repository using the `git push` command. diff --git a/src/main/resources/META-INF/native-image/io.github.kxng0109/ai-commit-cli/reachability-metadata.json b/src/main/resources/META-INF/native-image/io.github.kxng0109/ai-commit-cli/reachability-metadata.json index 14aef5d..c97a772 100644 --- a/src/main/resources/META-INF/native-image/io.github.kxng0109/ai-commit-cli/reachability-metadata.json +++ b/src/main/resources/META-INF/native-image/io.github.kxng0109/ai-commit-cli/reachability-metadata.json @@ -2641,6 +2641,18 @@ { "glob": "version.properties" }, + { + "glob": "completions/ai-commit.bash" + }, + { + "glob": "completions/ai-commit.zsh" + }, + { + "glob": "completions/ai-commit.fish" + }, + { + "glob": "completions/ai-commit.ps1" + }, { "module": "java.base", "glob": "jdk/internal/icu/impl/data/icudt76b/nfkc.nrm" diff --git a/src/main/resources/completions/ai-commit.bash b/src/main/resources/completions/ai-commit.bash new file mode 100644 index 0000000..4f7b288 --- /dev/null +++ b/src/main/resources/completions/ai-commit.bash @@ -0,0 +1,40 @@ +# ai-commit bash completion. Install: ai-commit completion bash >> ~/.bash_completion +# Regenerate this file when flags change: keep in sync with AiCommitCli --help. +_ai_commit() { + local cur prev words cword + _init_completion || return + local flags="--yes -y --dry-run --amend -a --all --model --provider --version -v --help -h" + local commands="config completion" + local config_flags="--show --auto-commit --auto-push --reset --help" + local providers="openai anthropic google deepseek ollama" + local shells="bash zsh fish powershell" + + case "$prev" in + --model) + return 0 + ;; + --provider) + COMPREPLY=($(compgen -W "$providers" -- "$cur")) + return 0 + ;; + completion) + COMPREPLY=($(compgen -W "$shells" -- "$cur")) + return 0 + ;; + config) + COMPREPLY=($(compgen -W "$config_flags" -- "$cur")) + return 0 + ;; + --auto-commit|--auto-push) + COMPREPLY=($(compgen -W "on off" -- "$cur")) + return 0 + ;; + esac + + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "$flags" -- "$cur")) + return 0 + fi + COMPREPLY=($(compgen -W "$commands" -- "$cur")) +} +complete -F _ai_commit ai-commit diff --git a/src/main/resources/completions/ai-commit.fish b/src/main/resources/completions/ai-commit.fish new file mode 100644 index 0000000..3651442 --- /dev/null +++ b/src/main/resources/completions/ai-commit.fish @@ -0,0 +1,15 @@ +# ai-commit fish completion. Install: ai-commit completion fish > ~/.config/fish/completions/ai-commit.fish +# Regenerate this file when flags change: keep in sync with AiCommitCli --help. +complete -c ai-commit -f +complete -c ai-commit -s y -l yes -d 'Commit without prompting' +complete -c ai-commit -l dry-run -d 'Print the message without committing' +complete -c ai-commit -l amend -d 'Amend the previous commit' +complete -c ai-commit -s a -l all -d 'Stage tracked modifications' +complete -c ai-commit -l model -d 'Use this model for the run' -r +complete -c ai-commit -l provider -d 'Use this provider for the run' -r -a 'openai anthropic google deepseek ollama' +complete -c ai-commit -s v -l version -d 'Print version' +complete -c ai-commit -s h -l help -d 'Print help' +complete -c ai-commit -n __fish_use_subcommand -a config -d 'Manage configuration' +complete -c ai-commit -n __fish_use_subcommand -a completion -d 'Print shell completions' +complete -c ai-commit -n '__fish_seen_subcommand_from config' -a '--show --auto-commit --auto-push --reset --help' +complete -c ai-commit -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish powershell' diff --git a/src/main/resources/completions/ai-commit.ps1 b/src/main/resources/completions/ai-commit.ps1 new file mode 100644 index 0000000..e336c54 --- /dev/null +++ b/src/main/resources/completions/ai-commit.ps1 @@ -0,0 +1,26 @@ +# ai-commit PowerShell completion. Install: ai-commit completion powershell >> $PROFILE +# Regenerate this file when flags change: keep in sync with AiCommitCli --help. +Register-ArgumentCompleter -Native -CommandName ai-commit -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) + $flags = @('--yes', '-y', '--dry-run', '--amend', '-a', '--all', '--model', '--provider', '--version', '-v', '--help', '-h') + $commands = @('config', 'completion') + $elements = $commandAst.CommandElements | Select-Object -Skip 1 | ForEach-Object { "$_" } + $prev = if ($elements.Count -ge 1) { $elements[$elements.Count - 1] } else { '' } + $candidates = @() + switch ($prev) { + '--provider' { $candidates = @('openai', 'anthropic', 'google', 'deepseek', 'ollama') } + 'completion' { $candidates = @('bash', 'zsh', 'fish', 'powershell') } + 'config' { $candidates = @('--show', '--auto-commit', '--auto-push', '--reset', '--help') } + '--auto-commit' { $candidates = @('on', 'off') } + '--auto-push' { $candidates = @('on', 'off') } + default { + $candidates = $flags + if ($elements.Count -eq 0 -or ($elements.Count -eq 1 -and $wordToComplete -ne '' -and -not $wordToComplete.StartsWith('-'))) { + $candidates += $commands + } + } + } + $candidates | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterName', $_) + } +} diff --git a/src/main/resources/completions/ai-commit.zsh b/src/main/resources/completions/ai-commit.zsh new file mode 100644 index 0000000..24c872b --- /dev/null +++ b/src/main/resources/completions/ai-commit.zsh @@ -0,0 +1,37 @@ +#compdef ai-commit +# ai-commit zsh completion. Install: ai-commit completion zsh > ~/.zsh/completions/_ai-commit +# Regenerate this file when flags change: keep in sync with AiCommitCli --help. +_ai-commit() { + local context state line + typeset -A opt_args + local flags=( + '(-y --yes)'{-y,--yes}'[commit without prompting]' + '--dry-run[print the message without committing]' + '--amend[amend the previous commit]' + '(-a --all)'{-a,--all}'[stage tracked modifications]' + '--model+[use this model for the run]:model:' + '--provider+[use this provider for the run]:provider:(openai anthropic google deepseek ollama)' + '(-v --version)'{-v,--version}'[print version]' + '(-h --help)'{-h,--help}'[print help]' + ) + _arguments -C $flags \ + '1: :->command' \ + '*:: :->args' && return 0 + case $state in + command) + _describe 'command' '(config completion)' && return 0 + ;; + args) + case $line[1] in + config) + _values 'config option' --show --auto-commit --auto-push --reset --help && return 0 + ;; + completion) + _values 'shell' bash zsh fish powershell && return 0 + ;; + esac + ;; + esac + return 1 +} +compdef _ai-commit ai-commit diff --git a/src/test/java/io/github/kxng0109/AiCommitCliArgsTest.java b/src/test/java/io/github/kxng0109/AiCommitCliArgsTest.java new file mode 100644 index 0000000..13ca33f --- /dev/null +++ b/src/test/java/io/github/kxng0109/AiCommitCliArgsTest.java @@ -0,0 +1,140 @@ +package io.github.kxng0109; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Guards for command-line parsing in {@link AiCommitCli}. + */ +@DisplayName("AiCommitCli args") +public class AiCommitCliArgsTest { + + @Test + @DisplayName("null and empty args yield empty options") + void nullAndEmpty_yieldEmpty() { + AiCommitCli.CliOptions nullOptions = AiCommitCli.parseArgs(null); + AiCommitCli.CliOptions emptyOptions = AiCommitCli.parseArgs(new String[0]); + + assertThat(nullOptions.subcommand()).isNull(); + assertThat(emptyOptions.subcommand()).isNull(); + assertThat(emptyOptions.subArgs()).isEmpty(); + assertThat(emptyOptions.model()).isNull(); + assertThat(emptyOptions.provider()).isNull(); + } + + @Test + @DisplayName("boolean flags parse in long and short forms") + void booleanFlags_parse() { + AiCommitCli.CliOptions options = AiCommitCli.parseArgs( + new String[]{"--yes", "--dry-run", "--amend", "-a", "-v", "-h"}); + + assertThat(options.yes()).isTrue(); + assertThat(options.dryRun()).isTrue(); + assertThat(options.amend()).isTrue(); + assertThat(options.stageAll()).isTrue(); + assertThat(options.version()).isTrue(); + assertThat(options.help()).isTrue(); + } + + @Test + @DisplayName("short aliases parse") + void shortAliases_parse() { + AiCommitCli.CliOptions options = AiCommitCli.parseArgs(new String[]{"-y", "--all"}); + + assertThat(options.yes()).isTrue(); + assertThat(options.stageAll()).isTrue(); + } + + @Test + @DisplayName("model and provider accept space and equals forms") + void modelAndProvider_forms() { + AiCommitCli.CliOptions spaced = AiCommitCli.parseArgs( + new String[]{"--model", "gpt-4o", "--provider", "openai"}); + + assertThat(spaced.model()).isEqualTo("gpt-4o"); + assertThat(spaced.provider()).isEqualTo("openai"); + + AiCommitCli.CliOptions inlined = AiCommitCli.parseArgs( + new String[]{"--model=gpt-4o", "--provider=openai"}); + + assertThat(inlined.model()).isEqualTo("gpt-4o"); + assertThat(inlined.provider()).isEqualTo("openai"); + } + + @Test + @DisplayName("last value wins for repeated options") + void repeatedOptions_lastWins() { + AiCommitCli.CliOptions options = AiCommitCli.parseArgs( + new String[]{"--model", "a", "--model", "b"}); + + assertThat(options.model()).isEqualTo("b"); + } + + @Test + @DisplayName("missing values are rejected") + void missingValues_rejected() { + assertThatThrownBy(() -> AiCommitCli.parseArgs(new String[]{"--model"})) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("--model"); + assertThatThrownBy(() -> AiCommitCli.parseArgs(new String[]{"--provider", "--yes"})) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("--provider"); + assertThatThrownBy(() -> AiCommitCli.parseArgs(new String[]{"--model="})) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> AiCommitCli.parseArgs(new String[]{"--model", " "})) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("unknown options are rejected") + void unknownOptions_rejected() { + assertThatThrownBy(() -> AiCommitCli.parseArgs(new String[]{"--modle", "x"})) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("--modle"); + assertThatThrownBy(() -> AiCommitCli.parseArgs(new String[]{"-z"})) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("subcommands collect trailing args") + void subcommands_collectTrailing() { + AiCommitCli.CliOptions options = AiCommitCli.parseArgs( + new String[]{"--yes", "config", "--auto-commit", "on"}); + + assertThat(options.yes()).isTrue(); + assertThat(options.subcommand()).isEqualTo("config"); + assertThat(options.subArgs()).containsExactly("--auto-commit", "on"); + } + + @Test + @DisplayName("completion subcommand parses") + void completion_parses() { + AiCommitCli.CliOptions options = AiCommitCli.parseArgs(new String[]{"completion", "bash"}); + + assertThat(options.subcommand()).isEqualTo("completion"); + assertThat(options.subArgs()).containsExactly("bash"); + } + + @Test + @DisplayName("unknown subcommands are rejected") + void unknownSubcommands_rejected() { + assertThatThrownBy(() -> AiCommitCli.parseArgs(new String[]{"confgi"})) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("confgi"); + } + + @Test + @DisplayName("sub args list is unmodifiable") + void subArgs_unmodifiable() { + AiCommitCli.CliOptions options = AiCommitCli.parseArgs(new String[]{"config", "x"}); + + assertThat(options.subArgs()).isEqualTo(List.of("x")); + assertThatThrownBy(() -> options.subArgs().add("y")) + .isInstanceOf(UnsupportedOperationException.class); + } +} diff --git a/src/test/java/io/github/kxng0109/AiCommitCliDispatchTest.java b/src/test/java/io/github/kxng0109/AiCommitCliDispatchTest.java new file mode 100644 index 0000000..cc48f5e --- /dev/null +++ b/src/test/java/io/github/kxng0109/AiCommitCliDispatchTest.java @@ -0,0 +1,190 @@ +package io.github.kxng0109; + +import io.github.kxng0109.config.Config; +import io.github.kxng0109.config.UserPreferences; +import io.github.kxng0109.service.GitService; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.ai.anthropic.AnthropicChatModel; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; + +/** + * Guards for flow dispatch in {@link AiCommitCli}. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("AiCommitCli dispatch") +public class AiCommitCliDispatchTest { + + @Mock + private GitService gitService; + + @Mock + private ChatModel chatModel; + + private static Config openaiConfig() { + return new Config( + new Config.OpenAiConfig("key", "https://api.openai.com", "gpt-4o-mini"), + new Config.AnthropicConfig(null, "claude-sonnet-4-0"), + new Config.GoogleConfig(null, "gemini-2.0-flash"), + new Config.DeepseekConfig(null), + new Config.OllamaConfig(null, "http://localhost:11434"), + 0.1, + 30); + } + + private static AiCommitCli.CliOptions options(boolean yes, boolean dryRun, boolean amend, + boolean stageAll, String model, String provider) { + return new AiCommitCli.CliOptions(false, false, yes, dryRun, amend, stageAll, model, + provider, null, List.of()); + } + + @Test + @DisplayName("resolve without overrides uses priority provider") + void resolveWithoutOverrides_usesPriority() { + ChatModel resolved = AiCommitCli.resolveChatModel(openaiConfig(), options(false, false, false, false, null, null)); + + assertThat(resolved).isNotNull(); + } + + @Test + @DisplayName("resolve with forced provider selects it") + void resolveForced_selectsIt() { + Config config = new Config( + openaiConfig().openai(), new Config.AnthropicConfig("key", "claude-sonnet-4-0"), + openaiConfig().google(), openaiConfig().deepseek(), openaiConfig().ollama(), 0.1, 30); + + ChatModel resolved = AiCommitCli.resolveChatModel( + config, options(false, false, false, false, null, "anthropic")); + + assertThat(resolved).isInstanceOf(AnthropicChatModel.class); + } + + @Test + @DisplayName("resolve with model only targets priority provider") + void resolveModelOnly_targetsPriority() { + ChatModel resolved = AiCommitCli.resolveChatModel(openaiConfig(), options(false, false, false, false, "gpt-4o", null)); + + assertThat(resolved.getDefaultOptions().getModel()).isEqualTo("gpt-4o"); + } + + @Test + @DisplayName("resolve with provider and model applies both") + void resolveBoth_appliesBoth() { + Config config = new Config( + openaiConfig().openai(), new Config.AnthropicConfig("key", "old"), + openaiConfig().google(), openaiConfig().deepseek(), openaiConfig().ollama(), 0.1, 30); + + ChatModel resolved = AiCommitCli.resolveChatModel(config, options(false, false, false, false, "new", "anthropic")); + + assertThat(resolved.getDefaultOptions().getModel()).isEqualTo("new"); + } + + @Test + @DisplayName("stage-all stages before dry run") + void stageAll_beforeDryRun() { + when(gitService.getStagedDiff()).thenReturn("diff"); + when(chatModel.call(any(Prompt.class))).thenReturn(response("feat: x")); + + AiCommitCli.executeCommit(options(false, true, false, true, null, null), gitService, chatModel); + + verify(gitService).stageTracked(); + verify(gitService, never()).commit(anyString()); + } + + @Test + @DisplayName("dry run previews without committing") + void dryRun_previews() { + when(gitService.getStagedDiff()).thenReturn("diff"); + when(chatModel.call(any(Prompt.class))).thenReturn(response("feat: x")); + + AiCommitCli.executeCommit(options(false, true, false, false, null, null), gitService, chatModel); + + verify(gitService, never()).commit(anyString()); + verify(gitService, never()).amend(anyString()); + } + + @Test + @DisplayName("amend with yes calls amend") + void amendYes_callsAmend() { + when(gitService.getStagedDiff()).thenReturn("diff"); + when(chatModel.call(any(Prompt.class))).thenReturn(response("feat: x")); + + AiCommitCli.executeCommit(options(true, false, true, false, null, null), gitService, chatModel); + + verify(gitService).amend("feat: x"); + } + + @Test + @DisplayName("yes commits without prompting") + void yes_commits() { + when(gitService.getStagedDiff()).thenReturn("diff"); + when(chatModel.call(any(Prompt.class))).thenReturn(response("feat: x")); + + AiCommitCli.executeCommit(options(true, false, false, false, null, null), gitService, chatModel); + + verify(gitService).commit("feat: x"); + } + + @Test + @DisplayName("plain flags use preference flow") + void plain_usesPreferences() { + UserPreferences.reset(); + UserPreferences.setAutoCommit(true); + try { + when(gitService.getStagedDiff()).thenReturn("diff"); + when(chatModel.call(any(Prompt.class))).thenReturn(response("feat: x")); + + AiCommitCli.executeCommit(options(false, false, false, false, null, null), gitService, chatModel); + + verify(gitService).commit("feat: x"); + } finally { + UserPreferences.reset(); + } + } + + @Test + @DisplayName("run covers remaining shells") + void run_coversShells() { + assertThat(AiCommitCli.run(new String[]{"completion", "zsh"})).isZero(); + assertThat(AiCommitCli.run(new String[]{"completion", "fish"})).isZero(); + assertThat(AiCommitCli.run(new String[]{"completion", "powershell"})).isZero(); + } + + @Test + @DisplayName("run with flags but no provider fails safely") + void runFlags_noProviderFails() { + assumeNoProvider(); + assertThat(AiCommitCli.run(new String[]{"--dry-run"})).isOne(); + assertThat(AiCommitCli.run(new String[]{"--yes"})).isOne(); + assertThat(AiCommitCli.run(new String[]{"--amend"})).isOne(); + } + + private static void assumeNoProvider() { + org.junit.jupiter.api.Assumptions.assumeTrue(System.getenv("OPENAI_API_KEY") == null + && System.getenv("ANTHROPIC_API_KEY") == null + && System.getenv("GOOGLE_API_KEY") == null + && System.getenv("DEEPSEEK_API_KEY") == null + && System.getenv("OLLAMA_MODEL") == null); + } + + private ChatResponse response(String text) { + return new ChatResponse(List.of(new Generation(new AssistantMessage(text)))); + } +} diff --git a/src/test/java/io/github/kxng0109/CompletionTest.java b/src/test/java/io/github/kxng0109/CompletionTest.java new file mode 100644 index 0000000..976c37a --- /dev/null +++ b/src/test/java/io/github/kxng0109/CompletionTest.java @@ -0,0 +1,62 @@ +package io.github.kxng0109; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * Guards for bundled shell completions. + */ +@DisplayName("Completions") +public class CompletionTest { + + @ParameterizedTest(name = "{0} script loads with expected content") + @ValueSource(strings = {"bash", "zsh", "fish", "powershell"}) + @DisplayName("each shell script loads with expected content") + void eachShell_loads(String shell) throws IOException { + String resource = "completions/ai-commit." + extension(shell); + + String script = read(resource); + + assertThat(script).contains("ai-commit"); + assertThat(script).contains("dry-run").contains("provider").contains("amend"); + assertThat(script).contains("config").contains("completion"); + } + + @Test + @DisplayName("run rejects unknown shells") + void run_rejectsUnknownShell() { + assertThat(AiCommitCli.run(new String[]{"completion", "tcsh"})).isOne(); + } + + @Test + @DisplayName("run requires a shell name") + void run_requiresShell() { + assertThat(AiCommitCli.run(new String[]{"completion"})).isOne(); + } + + @Test + @DisplayName("run prints bash completion") + void run_printsBash() throws IOException { + assertThat(AiCommitCli.run(new String[]{"completion", "bash"})).isZero(); + assertThat(read("completions/ai-commit.bash")).isNotBlank(); + } + + private static String extension(String shell) { + return shell.equals("powershell") ? "ps1" : shell; + } + + private static String read(String resource) throws IOException { + try (InputStream input = CompletionTest.class.getClassLoader().getResourceAsStream(resource)) { + assertThat(input).as("resource %s", resource).isNotNull(); + return new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + } +} diff --git a/src/test/java/io/github/kxng0109/config/ConfigWithModelTest.java b/src/test/java/io/github/kxng0109/config/ConfigWithModelTest.java new file mode 100644 index 0000000..c4b7526 --- /dev/null +++ b/src/test/java/io/github/kxng0109/config/ConfigWithModelTest.java @@ -0,0 +1,79 @@ +package io.github.kxng0109.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Guards for per-run model overrides in {@link Config}. + */ +@DisplayName("Config model override") +public class ConfigWithModelTest { + + private static Config base() { + return new Config( + new Config.OpenAiConfig("key", "https://api.openai.com", "gpt-4o-mini"), + new Config.AnthropicConfig(null, "claude-sonnet-4-0"), + new Config.GoogleConfig(null, "gemini-2.0-flash"), + new Config.DeepseekConfig(null), + new Config.OllamaConfig(null, "http://localhost:11434"), + 0.1, + 30); + } + + @Test + @DisplayName("openai model is replaced, rest preserved") + void openai_replaced() { + Config updated = base().withModel("openai", "gpt-4o"); + + assertThat(updated.openai().model()).isEqualTo("gpt-4o"); + assertThat(updated.openai().apiKey()).isEqualTo("key"); + assertThat(updated.anthropic().model()).isEqualTo("claude-sonnet-4-0"); + assertThat(updated.temperature()).isEqualTo(0.1); + } + + @Test + @DisplayName("anthropic, google, and ollama models are replaced") + void others_replaced() { + assertThat(base().withModel("ANTHROPIC", "claude-x").anthropic().model()).isEqualTo("claude-x"); + assertThat(base().withModel(" Google ", "gemini-x").google().model()).isEqualTo("gemini-x"); + assertThat(base().withModel("ollama", "llama3").ollama().model()).isEqualTo("llama3"); + } + + @Test + @DisplayName("model value is trimmed") + void model_trimmed() { + assertThat(base().withModel("openai", " gpt-4o ").openai().model()).isEqualTo("gpt-4o"); + } + + @Test + @DisplayName("deepseek rejects model overrides") + void deepseek_rejected() { + assertThatThrownBy(() -> base().withModel("deepseek", "deepseek-reasoner")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("fixed model"); + } + + @Test + @DisplayName("unknown and blank providers are rejected") + void unknownProviders_rejected() { + assertThatThrownBy(() -> base().withModel("cohere", "x")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cohere"); + assertThatThrownBy(() -> base().withModel(" ", "x")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> base().withModel(null, "x")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("blank models are rejected") + void blankModels_rejected() { + assertThatThrownBy(() -> base().withModel("openai", " ")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> base().withModel("openai", null)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/io/github/kxng0109/service/AiProviderFactorySelectionTest.java b/src/test/java/io/github/kxng0109/service/AiProviderFactorySelectionTest.java new file mode 100644 index 0000000..b662383 --- /dev/null +++ b/src/test/java/io/github/kxng0109/service/AiProviderFactorySelectionTest.java @@ -0,0 +1,113 @@ +package io.github.kxng0109.service; + +import io.github.kxng0109.config.Config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.anthropic.AnthropicChatModel; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.deepseek.DeepSeekChatModel; +import org.springframework.ai.google.genai.GoogleGenAiChatModel; +import org.springframework.ai.ollama.OllamaChatModel; +import org.springframework.ai.openai.OpenAiChatModel; + +/** + * Guards for provider selection in {@link AiProviderFactory}. + */ +@DisplayName("AiProviderFactory selection") +public class AiProviderFactorySelectionTest { + + private static Config empty() { + return new Config( + new Config.OpenAiConfig(null, "https://api.openai.com", "gpt-4o-mini"), + new Config.AnthropicConfig(null, "claude-sonnet-4-0"), + new Config.GoogleConfig(null, "gemini-2.0-flash"), + new Config.DeepseekConfig(null), + new Config.OllamaConfig(null, "http://localhost:11434"), + 0.1, + 30); + } + + private static Config onlyAnthropic() { + Config base = empty(); + return new Config(base.openai(), new Config.AnthropicConfig("key", "claude-sonnet-4-0"), + base.google(), base.deepseek(), base.ollama(), 0.1, 30); + } + + @Test + @DisplayName("selection follows priority order") + void selection_followsPriority() { + Config openai = new Config( + new Config.OpenAiConfig("key", "https://api.openai.com", "gpt-4o-mini"), + new Config.AnthropicConfig("key", "claude-sonnet-4-0"), + empty().google(), empty().deepseek(), empty().ollama(), 0.1, 30); + + assertThat(AiProviderFactory.selectedProvider(openai)).isEqualTo("openai"); + assertThat(AiProviderFactory.selectedProvider(onlyAnthropic())).isEqualTo("anthropic"); + } + + @Test + @DisplayName("selection fails when nothing is configured") + void selection_failsWhenEmpty() { + assertThatThrownBy(() -> AiProviderFactory.selectedProvider(empty())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("No AI provider configured"); + } + + @Test + @DisplayName("forced provider creates matching model") + void forced_createsMatching() { + ChatModel model = AiProviderFactory.createChatModel(onlyAnthropic(), "ANTHROPIC"); + + assertThat(model).isInstanceOf(AnthropicChatModel.class); + } + + @Test + @DisplayName("forced provider requires configuration") + void forced_requiresConfiguration() { + assertThatThrownBy(() -> AiProviderFactory.createChatModel(empty(), "openai")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("OPENAI_API_KEY"); + assertThatThrownBy(() -> AiProviderFactory.createChatModel(empty(), "ollama")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("OLLAMA_MODEL"); + } + + @Test + @DisplayName("unknown and blank forced providers are rejected") + void forced_rejectsUnknown() { + assertThatThrownBy(() -> AiProviderFactory.createChatModel(onlyAnthropic(), "cohere")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cohere"); + assertThatThrownBy(() -> AiProviderFactory.createChatModel(onlyAnthropic(), " ")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> AiProviderFactory.createChatModel(onlyAnthropic(), null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("forced google, deepseek, and ollama create matching models") + void forcedOthers_createMatching() { + Config base = empty(); + Config google = new Config(base.openai(), base.anthropic(), + new Config.GoogleConfig("key", "gemini-2.0-flash"), base.deepseek(), base.ollama(), 0.1, 30); + Config deepseek = new Config(base.openai(), base.anthropic(), base.google(), + new Config.DeepseekConfig("key"), base.ollama(), 0.1, 30); + Config ollama = new Config(base.openai(), base.anthropic(), base.google(), base.deepseek(), + new Config.OllamaConfig("llama3", "http://localhost:11434"), 0.1, 30); + + assertThat(AiProviderFactory.createChatModel(google, "google")) + .isInstanceOf(GoogleGenAiChatModel.class); + assertThat(AiProviderFactory.createChatModel(deepseek, "deepseek")) + .isInstanceOf(DeepSeekChatModel.class); + assertThat(AiProviderFactory.createChatModel(ollama, "ollama")) + .isInstanceOf(OllamaChatModel.class); + assertThat(AiProviderFactory.createChatModel( + new Config(new Config.OpenAiConfig("k", "https://api.openai.com", "m"), + base.anthropic(), base.google(), base.deepseek(), base.ollama(), 0.1, 30), + "openai")).isInstanceOf(OpenAiChatModel.class); + } +} diff --git a/src/test/java/io/github/kxng0109/service/CommitServiceFlowTest.java b/src/test/java/io/github/kxng0109/service/CommitServiceFlowTest.java new file mode 100644 index 0000000..8501c60 --- /dev/null +++ b/src/test/java/io/github/kxng0109/service/CommitServiceFlowTest.java @@ -0,0 +1,113 @@ +package io.github.kxng0109.service; + +import java.io.BufferedReader; +import java.io.StringReader; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; + +/** + * Guards for preview, forced auto, and amend flows in {@link CommitService}. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("CommitService flows") +public class CommitServiceFlowTest { + + @Mock + private GitService gitService; + + @Mock + private ChatModel chatModel; + + @Test + @DisplayName("preview returns the message without committing") + void preview_returnsWithoutCommitting() { + CommitService service = new CommitService(gitService, chatModel); + when(gitService.getStagedDiff()).thenReturn("diff"); + when(chatModel.call(any(Prompt.class))).thenReturn(response("feat: add feature")); + + String message = service.previewMessage(); + + assertThat(message).isEqualTo("feat: add feature"); + verify(gitService, never()).commit(anyString()); + verify(gitService, never()).amend(anyString()); + } + + @Test + @DisplayName("forced auto commits without prompting") + void forcedAuto_commitsWithoutPrompting() { + BufferedReader eofReader = new BufferedReader(new StringReader("")); + CommitService service = new CommitService(gitService, chatModel, eofReader); + when(gitService.getStagedDiff()).thenReturn("diff"); + when(chatModel.call(any(Prompt.class))).thenReturn(response("feat: add feature")); + when(gitService.commit("feat: add feature")).thenReturn("ok"); + + service.generateAndCommit(true); + + verify(gitService).commit("feat: add feature"); + } + + @Test + @DisplayName("forced auto amend calls amend") + void forcedAutoAmend_callsAmend() { + CommitService service = new CommitService(gitService, chatModel); + when(gitService.getStagedDiff()).thenReturn("diff"); + when(chatModel.call(any(Prompt.class))).thenReturn(response("feat: add feature")); + when(gitService.amend("feat: add feature")).thenReturn("ok"); + + service.generateAndAmend(true); + + verify(gitService).amend("feat: add feature"); + verify(gitService, never()).commit(anyString()); + } + + @Test + @DisplayName("interactive amend commits via amend on accept") + void interactiveAmend_amendsOnAccept() { + BufferedReader reader = new BufferedReader(new StringReader("y\n")); + CommitService service = new CommitService(gitService, chatModel, reader); + when(gitService.getStagedDiff()).thenReturn("diff"); + when(chatModel.call(any(Prompt.class))).thenReturn(response("feat: add feature")); + when(gitService.amend("feat: add feature")).thenReturn("ok"); + + service.generateAndAmend(false); + + verify(gitService).amend("feat: add feature"); + verify(gitService, never()).commit(anyString()); + } + + @Test + @DisplayName("explicit commit flag still commits normally") + void explicitCommit_commitsNormally() { + BufferedReader reader = new BufferedReader(new StringReader("yes\n")); + CommitService service = new CommitService(gitService, chatModel, reader); + when(gitService.getStagedDiff()).thenReturn("diff"); + when(chatModel.call(any(Prompt.class))).thenReturn(response("feat: add feature")); + when(gitService.commit("feat: add feature")).thenReturn("ok"); + + service.generateAndCommit(false); + + verify(gitService).commit("feat: add feature"); + verify(gitService, never()).amend(anyString()); + } + + private ChatResponse response(String text) { + return new ChatResponse(List.of(new Generation(new AssistantMessage(text)))); + } +} diff --git a/src/test/java/io/github/kxng0109/service/GitServiceWriteTest.java b/src/test/java/io/github/kxng0109/service/GitServiceWriteTest.java new file mode 100644 index 0000000..29b5f06 --- /dev/null +++ b/src/test/java/io/github/kxng0109/service/GitServiceWriteTest.java @@ -0,0 +1,90 @@ +package io.github.kxng0109.service; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Guards for staging and amending in {@link GitService} using live repositories. + */ +@DisplayName("GitService stage and amend") +public class GitServiceWriteTest { + + @TempDir + Path repoDir; + + @Test + @DisplayName("stageTracked stages modified tracked files only") + void stageTracked_stagesModifiedTracked() throws Exception { + initGitRepo(repoDir); + Path tracked = repoDir.resolve("tracked.txt"); + Files.writeString(tracked, "v1"); + runCommand(repoDir, "git", "add", "tracked.txt"); + runCommand(repoDir, "git", "commit", "-m", "init"); + Files.writeString(tracked, "v2"); + Path untracked = repoDir.resolve("new.txt"); + Files.writeString(untracked, "new"); + + GitService service = new GitService(10, repoDir.toString()); + service.stageTracked(); + + assertThat(service.hasStagedChanges()).isTrue(); + String status = runCommand(repoDir, "git", "status", "--porcelain"); + assertThat(status).contains("M tracked.txt"); + assertThat(status).contains("?? new.txt"); + } + + @Test + @DisplayName("amend replaces the previous message") + void amend_replacesMessage() throws Exception { + initGitRepo(repoDir); + Files.writeString(repoDir.resolve("a.txt"), "a"); + GitService service = new GitService(10, repoDir.toString()); + runCommand(repoDir, "git", "add", "a.txt"); + service.commit("feat: first"); + + Files.writeString(repoDir.resolve("b.txt"), "b"); + runCommand(repoDir, "git", "add", "b.txt"); + String output = service.amend("feat: amended"); + + assertThat(output).contains("feat: amended"); + String message = runCommand(repoDir, "git", "log", "-1", "--format=%s"); + assertThat(message.trim()).isEqualTo("feat: amended"); + } + + @Test + @DisplayName("null and blank amend messages are rejected") + void amend_rejectsBlank() { + GitService service = new GitService(10, repoDir.toString()); + + assertThatThrownBy(() -> service.amend(null)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> service.amend(" ")) + .isInstanceOf(IllegalArgumentException.class); + } + + private void initGitRepo(Path dir) throws Exception { + runCommand(dir, "git", "init"); + runCommand(dir, "git", "config", "user.email", "test@example.com"); + runCommand(dir, "git", "config", "user.name", "Test User"); + } + + private String runCommand(Path dir, String... command) throws Exception { + ProcessBuilder builder = new ProcessBuilder(command) + .directory(dir.toFile()) + .redirectErrorStream(true); + Process process = builder.start(); + int exitCode = process.waitFor(); + String output = new String(process.getInputStream().readAllBytes()); + if (exitCode != 0) { + throw new RuntimeException("Command failed: " + String.join(" ", command) + "\n" + output); + } + return output; + } +}