Last active
October 15, 2025 13:46
-
-
Save brackendev/705e754e599e451343226e9a37ccf4c6 to your computer and use it in GitHub Desktop.
Git LLM - LLM-powered commit messages and PR descriptions
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bb | |
| ;; ============================================================================ | |
| ;; Git LLM - LLM-powered commit messages and PR descriptions | |
| ;; ============================================================================ | |
| ;; This is a Babashka script - a fast-starting scripting environment for Clojure | |
| ;; Learn more at https://babashka.org/ | |
| ;; | |
| ;; This script uses LLMs (Claude or Codex) to generate git commit messages and | |
| ;; PR descriptions based on code changes. It can either preview the generated | |
| ;; text (--dry-run) or automatically commit/push or create the PR. | |
| ;; | |
| ;; Source: https://gist.github.com/brackendev/705e754e599e451343226e9a37ccf4c6 | |
| ;; | |
| ;; Dependencies: | |
| ;; brew install gum # Required for interactive mode (-i flag) | |
| ;; brew install gh # Required for PR mode (--pr flag) | |
| ;; | |
| ;; Usage: git_llm.bb [options] | |
| ;; -m opus|sonnet|gpt-5-codex Use Claude Opus, Sonnet, or GPT-5-Codex (default: gpt-5-codex) | |
| ;; -v Show verbose output | |
| ;; -i Interactive mode - edit messages and confirm actions | |
| ;; --commit Generate commit message, commit staged changes, and push | |
| ;; --pr Generate PR description and create PR | |
| ;; --dry-run Show generated text without committing/creating PR | |
| ;; -h Show help | |
| ;; | |
| ;; Examples: | |
| ;; git_llm.bb --commit --dry-run # Preview commit message | |
| ;; git_llm.bb --commit -i # Edit message before committing | |
| ;; git_llm.bb --pr --dry-run # Preview PR description | |
| ;; git_llm.bb --pr -i # Edit description before creating PR | |
| ;; Load dependencies at runtime | |
| (require '[babashka.deps :as deps]) | |
| (deps/add-deps '{:deps {progrock/progrock {:mvn/version "1.0.0"} | |
| clojure-term-colors/clojure-term-colors {:mvn/version "0.1.0"}}}) | |
| ;; Import required libraries: | |
| ;; - babashka.process for running shell commands | |
| ;; - babashka.cli for parsing command-line arguments | |
| ;; - clojure.string for string manipulation | |
| ;; - progrock.core for progress indicators | |
| ;; - clojure.term.colors for colored terminal output | |
| (require '[babashka.process :refer [shell]] | |
| '[babashka.cli :as cli] | |
| '[clojure.string :as str] | |
| '[progrock.core :as pr] | |
| '[clojure.term.colors :as c]) | |
| ;; Configuration - Edit these values to customize the script | |
| ;; def creates an immutable global variable | |
| (def config | |
| {:default-model "gpt-5-codex" ; Default LLM model (sonnet, opus, or gpt-5-codex) | |
| :max-diff-chars 8000 ; Max characters from diff to send to LLM | |
| :valid-models #{"sonnet" "opus" "gpt-5-codex"} ; #{} creates a set - fast membership testing | |
| ;; Map models to their CLI providers | |
| ;; This determines which command-line tool to use for each model | |
| :model-providers {"gpt-5-codex" "codex" ; Codex models use the codex CLI | |
| "sonnet" "claude" ; Claude Sonnet uses claude CLI | |
| "opus" "claude"} ; Claude Opus uses claude CLI | |
| ;; Commit message generation prompt | |
| :commit-prompt-template | |
| (str "Based on this git diff of staged changes, " | |
| "write a single line commit message using imperative mood " | |
| "(e.g., 'Add feature' not 'Added feature' or 'I added feature'). " | |
| "No quotes, no explanation, just the message:") | |
| ;; PR template file path - project-specific customization | |
| ;; If the file doesn't exist, falls back to a default template | |
| :pr-template-file "docs/PULL_REQUEST_TEMPLATE.md"}) | |
| ;; Define the command-line options the script accepts | |
| ;; babashka.cli uses this to parse arguments like -m, --model, -v, etc. | |
| (def options | |
| {:model {:default (:default-model config) :alias :m} ; :m is short for --model | |
| :verbose {:alias :v} ; :v is short for --verbose | |
| :commit {} ; --commit for generating and committing | |
| :pr {} ; --pr for PR creation | |
| :dry-run {} ; --dry-run to preview without executing | |
| :interactive {:alias :i} ; :i is short for --interactive | |
| :help {:alias :h}}) ; :h is short for --help | |
| (defn print-help | |
| "Print usage instructions and exit" | |
| [] | |
| (println "Generate commit messages, commit and push changes, and create PRs using Claude or Codex\n") | |
| (println "Usage: git_llm.bb [options]\n") | |
| (println " -m MODEL Use opus, sonnet, or gpt-5-codex (default: gpt-5-codex)") | |
| (println " -v Show verbose output") | |
| (println " -i Interactive mode - edit messages and confirm actions") | |
| (println " --commit Generate commit message, commit staged changes, and push") | |
| (println " --pr Generate PR description and create PR") | |
| (println " --dry-run Show generated text without committing/creating PR") | |
| (println " -h Show this help") | |
| (println "\nExamples:") | |
| (println " git_llm.bb --commit --dry-run # Show commit message") | |
| (println " git_llm.bb --commit -i # Edit message before committing") | |
| (println " git_llm.bb --pr --dry-run # Show PR description") | |
| (println " git_llm.bb --pr -i # Edit description before creating PR")) | |
| (defn run-git | |
| "Run a git command and return its output as a string | |
| Example: (run-git \"diff\" \"--cached\") returns the staged diff | |
| The & args means this function accepts any number of arguments" | |
| [& args] | |
| ;; -> is the 'thread-first' macro - passes result of each form as first arg to next | |
| ;; This is equivalent to: (:out (apply shell {:out :string :continue true} "git" args)) | |
| (-> (apply shell {:out :string :continue true} "git" args) | |
| :out)) | |
| (defn with-spinner | |
| "Execute a task while showing a spinner with a status message | |
| Parameters: | |
| - message: The text to display next to the spinner | |
| - f: A function to execute while the spinner runs" | |
| [message f] | |
| ;; let creates local variables - these exist only within this function | |
| (let [done (atom false) ; atom = mutable reference, starts as false | |
| spinner-chars ["⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏"] ; Braille spinner animation frames | |
| ;; future runs code in a background thread (like setTimeout in JavaScript) | |
| ;; This allows the spinner to animate while the main task runs | |
| spinner-thread (future | |
| ;; loop/recur creates a loop (like while in other languages) | |
| ;; recur jumps back to loop with new values | |
| (loop [i 0] | |
| ;; when-not = if not (checks if done is still false) | |
| ;; @ reads the current value of the atom | |
| (when-not @done | |
| ;; Print spinner to stderr so it doesn't mix with normal output | |
| (binding [*out* *err*] | |
| ;; \r = carriage return (moves cursor to start of line, overwrites previous spinner) | |
| ;; nth gets item at index i, mod wraps around when i > array length | |
| ;; c/yellow wraps the spinner char in yellow ANSI color codes | |
| (print (str "\r" (c/yellow (nth spinner-chars (mod i (count spinner-chars)))) " " message)) | |
| (flush)) ; flush ensures print shows immediately | |
| (Thread/sleep 100) ; Wait 100ms between frames | |
| (recur (inc i)))))] ; Loop with i+1 (next frame) | |
| ;; While spinner runs in background, execute the actual task | |
| (let [result (f)] | |
| ;; Task is done - stop the spinner | |
| (reset! done true) ; reset! changes atom value to true | |
| @spinner-thread ; Wait for spinner thread to finish (@ = deref) | |
| ;; Clear the spinner line by overwriting with spaces | |
| (binding [*out* *err*] | |
| (print "\r") ; Move to start of line | |
| ;; Create string of spaces as long as the spinner message | |
| ;; repeat creates N copies, apply str joins them into one string | |
| (print (apply str (repeat (+ 2 (count message)) " "))) | |
| (print "\r") ; Move cursor back to start | |
| (flush)) | |
| result))) | |
| (defn gum-write | |
| "Open gum editor for multi-line text editing | |
| Parameters: | |
| - value: Initial text to show in the editor | |
| - header: Header text shown above the editor | |
| Returns the edited text as a string" | |
| [value header] | |
| ;; -> is the thread-first macro - pipes result through functions | |
| ;; This reads as: shell() -> get :out -> trim whitespace | |
| (-> (shell {:out :string :in value} ; :out :string = capture output, :in value = pipe value to stdin | |
| "gum" "write" "--header" header "--char-limit" "0") | |
| :out ; Extract :out key from result map | |
| str/trim)) ; Remove leading/trailing whitespace | |
| (defn gum-confirm | |
| "Show a yes/no confirmation prompt | |
| Parameters: | |
| - prompt: The question to ask the user | |
| Returns true if user confirms (yes), false otherwise (no)" | |
| [prompt] | |
| ;; gum confirm exits with 0 for yes, 1 for no | |
| ;; zero? checks if exit code is 0 (success) | |
| ;; So: exit 0 -> zero? returns true, exit 1 -> zero? returns false | |
| (zero? (:exit (shell {:continue true} ; :continue true = don't throw error on non-zero exit | |
| "gum" "confirm" prompt)))) | |
| (defn call-llm | |
| "Call the appropriate LLM CLI based on model and return the response | |
| Parameters: | |
| - model: Which LLM model to use (sonnet, opus, or gpt-5-codex) | |
| - prompt: The prompt text to send to the LLM | |
| Returns the LLM response as a string" | |
| [model prompt] | |
| ;; Look up which CLI provider to use for this model | |
| ;; Example: "gpt-5-codex" -> "codex", "sonnet" -> "claude" | |
| (let [provider ((:model-providers config) model)] | |
| (if (= provider "codex") | |
| ;; Codex CLI: uses tmpfile to capture output and redirects noise to /dev/null | |
| ;; Codex is verbose and writes to /dev/tty, so this approach silences it | |
| (let [tmpfile (str "/tmp/git_llm_" (System/currentTimeMillis) ".txt")] | |
| (shell {:out :string :err :string :continue true} | |
| "sh" "-c" | |
| ;; Build command: codex --model MODEL exec --output-last-message TMPFILE "PROMPT" | |
| ;; Redirect all output to /dev/null to suppress verbose progress messages | |
| (str "codex --model " model " exec --output-last-message " tmpfile " \"$1\" </dev/null >/dev/null 2>&1") | |
| "sh" prompt) | |
| ;; Read result from tmpfile, clean up, and return | |
| (let [result (slurp tmpfile)] | |
| (shell "rm" tmpfile) | |
| (str/trim result))) | |
| ;; Claude CLI: simpler because claude is quiet by default | |
| ;; Just run claude and capture stdout directly | |
| (-> (shell {:out :string :err :string :continue true} | |
| "claude" "--model" model "-p" prompt) | |
| :out | |
| str/trim)))) | |
| (defn get-commit-message | |
| "Ask Claude or Codex to generate a commit message based on the staged changes | |
| Parameters: | |
| - files: List of changed filenames (from git diff --cached --name-only) | |
| - diff: The actual code changes (from git diff --cached) | |
| - model: Which LLM to use (sonnet, opus, or gpt-5-codex) | |
| - verbose?: Boolean flag for extra debug output | |
| Returns the generated commit message as a string" | |
| [files diff model verbose?] | |
| ;; let creates local variables that only exist within this function's scope | |
| (let [prompt (str (:commit-prompt-template config) "\n\n" ; :keyword gets value from map | |
| "Files changed:\n" files "\n\n" | |
| "Diff:\n" | |
| ;; Truncate diff to max length to avoid exceeding LLM token limits | |
| ;; subs = substring, min returns smaller of two numbers | |
| ;; Example: if diff is 10000 chars and max is 8000, takes first 8000 chars | |
| (subs diff 0 (min (:max-diff-chars config) (count diff))))] | |
| ;; when is like 'if' but only for when condition is true (no else branch) | |
| (when verbose? | |
| ;; binding temporarily changes where println outputs go | |
| ;; *out* is stdout, *err* is stderr - redirects to stderr for debug output | |
| (binding [*out* *err*] | |
| (println "Model:" model) | |
| (println "Prompt length:" (count prompt) "chars"))) | |
| ;; Call LLM with spinner animation | |
| ;; with-spinner shows animated spinner while LLM generates response | |
| (with-spinner "Generating commit message..." | |
| ;; fn [] creates an anonymous function with no arguments | |
| ;; This is passed to with-spinner which will call it | |
| ;; call-llm handles provider-specific logic (codex vs claude CLI) | |
| (fn [] | |
| (call-llm model prompt))))) | |
| (defn get-pr-description | |
| "Generate a PR description based on commits in the current branch | |
| Parameters: | |
| - model: Which LLM to use (sonnet, opus, or gpt-5-codex) | |
| - verbose?: Boolean flag for extra debug output | |
| Compares current branch against the default branch and generates a full PR template | |
| Returns the generated PR description as a string" | |
| [model verbose?] | |
| ;; Get the current branch name and determine the base branch to compare against | |
| (let [current-branch (str/trim (run-git "branch" "--show-current")) | |
| ;; Try to auto-detect the default branch from origin/HEAD, fallback to origin/main | |
| ;; or returns first truthy value: if first fails, uses second | |
| base-branch (str/trim (or (run-git "symbolic-ref" "refs/remotes/origin/HEAD") | |
| "origin/main")) | |
| ;; Strip the "refs/remotes/origin/" prefix to get just the branch name | |
| ;; #"..." creates a regex pattern (# prefix means regex literal) | |
| ;; Example: "refs/remotes/origin/main" becomes "main" | |
| base-branch (str/replace base-branch #"refs/remotes/origin/" "") | |
| ;; Get all commits between base and current branch | |
| ;; .. syntax shows commits in current that aren't in base | |
| ;; Example: if base is main and current is feature-branch: | |
| ;; shows all commits added to feature-branch since branching from main | |
| commits (run-git "log" (str base-branch ".." current-branch) "--oneline") | |
| ;; Get the full diff (using ... for merge-base comparison) | |
| ;; ... syntax compares against common ancestor (merge-base), not tip of base | |
| ;; This shows only changes made on the current branch | |
| ;; Example: if main has moved forward, ... ignores those new main commits | |
| diff (run-git "diff" (str base-branch "..." current-branch)) | |
| ;; Try to load PR template from file, fall back to default if not found | |
| ;; try/catch handles cases where template file doesn't exist or can't be read | |
| ;; Catches any Exception (file not found, permission denied, etc.) and uses minimal default | |
| pr-template (try | |
| (slurp (:pr-template-file config)) | |
| (catch Exception _ | |
| (str "## Description\n\n" | |
| "## Related Issue\n\n" | |
| "## Motivation and Context\n\n" | |
| "## How Has This Been Tested?\n\n" | |
| "## Screenshots (if appropriate):\n\n"))) | |
| ;; Build the prompt with commits, diff, and the PR template to fill in | |
| prompt (str "Based on these git commits and diff, generate a PR description.\n\n" | |
| "Commits:\n" commits "\n\n" | |
| "Diff:\n" (subs diff 0 (min (:max-diff-chars config) (count diff))) "\n\n" | |
| "Fill in this template with appropriate content:\n\n" | |
| pr-template | |
| "\n\nReturn the filled-in template with actual content (not the HTML comments)." | |
| "\nDo not include any # heading above the first ## section." | |
| "\nStart directly with ## Description.")] | |
| (when verbose? | |
| (binding [*out* *err*] | |
| (println "Current branch:" current-branch) | |
| (println "Base branch:" base-branch) | |
| (println "Commits:\n" commits))) | |
| ;; Generate PR description using the selected model | |
| ;; call-llm handles provider-specific logic (codex vs claude CLI) | |
| (with-spinner "Generating PR description..." | |
| (fn [] | |
| (call-llm model prompt))))) | |
| (defn get-pr-labels | |
| "Select appropriate labels for the PR based on the changes | |
| Parameters: | |
| - commits: List of commit messages (from git log) | |
| - diff: The actual code changes (from git diff) | |
| - model: Which LLM to use (sonnet, opus, or gpt-5-codex) | |
| - verbose?: Boolean flag for extra debug output | |
| Flow: | |
| 1. Fetch all labels from repo with 'gh label list' | |
| 2. Send labels + changes to LLM asking it to select appropriate ones | |
| 3. Parse comma-separated response into vector of label names | |
| Returns vector of label names, or empty vector if no labels selected" | |
| [commits diff model verbose?] | |
| ;; Get available labels from the repo using gh CLI | |
| (let [available-labels (-> (shell {:out :string :continue true} "gh" "label" "list" "--limit" "100") | |
| :out ; Extract stdout from shell result | |
| str/split-lines ; Split into array of lines | |
| ;; Each line is "name\tdescription\tcolor" - extract just the name | |
| ;; ->> is thread-last macro: passes result as LAST argument | |
| ;; #(...) is shorthand for anonymous function: #(+ % 1) = (fn [x] (+ x 1)) | |
| ;; % is the argument (in this case, each line) | |
| ;; #"\t" is a regex that matches tab character | |
| ;; So this: splits each line on tabs, takes first element (the label name) | |
| (->> (map #(first (str/split % #"\t"))))) | |
| ;; Build prompt asking LLM to select labels | |
| prompt (str "Based on these git commits and diff, select appropriate labels from the available labels.\n\n" | |
| "Commits:\n" commits "\n\n" | |
| "Diff:\n" (subs diff 0 (min (:max-diff-chars config) (count diff))) "\n\n" | |
| "Available labels:\n" (str/join ", " available-labels) "\n\n" | |
| "Return ONLY the selected label names as a comma-separated list (e.g., bug, enhancement).\n" | |
| "If no labels apply, return 'none'.\n" | |
| "Do not include explanations or additional text.")] | |
| (when verbose? | |
| (binding [*out* *err*] | |
| (println "Available labels:" (str/join ", " available-labels)))) | |
| ;; Call LLM to select labels | |
| ;; call-llm handles provider-specific logic (codex vs claude CLI) | |
| (let [response (with-spinner "Selecting labels..." | |
| (fn [] | |
| (call-llm model prompt))) | |
| ;; Parse response into vector of labels | |
| ;; If response is empty or "none", return empty vector | |
| ;; Otherwise split on commas, trim whitespace, and convert to vector | |
| labels (if (or (empty? response) (= "none" (str/lower-case response))) | |
| [] | |
| (vec (map str/trim (str/split response #","))))] | |
| (when verbose? | |
| (binding [*out* *err*] | |
| (println "LLM response:" response) | |
| (println "Parsed labels:" labels))) | |
| labels))) | |
| ;; ============================================================================ | |
| ;; Main execution starts here | |
| ;; ============================================================================ | |
| ;; The script has two main modes: --commit and --pr | |
| ;; Both support --dry-run to preview without taking action | |
| ;; Parse command-line arguments and validate git repo state before executing | |
| ;; let creates local variables for the entire main execution block | |
| (let [;; Parse command-line arguments into a map | |
| ;; {:keys [a b]} is destructuring - extracts specific keys from a map | |
| ;; Example: {:model "sonnet" :verbose true} -> model = "sonnet", verbose = true | |
| ;; *command-line-args* is a special Babashka variable containing script arguments | |
| {:keys [model verbose help pr dry-run commit interactive]} (cli/parse-opts *command-line-args* | |
| {:spec options}) | |
| ;; delay creates a "lazy" value - the code inside won't run until @ is used | |
| ;; This is efficient: if help is requested, git checks are skipped entirely | |
| ;; @ (deref) is used later to actually run the delayed code and get the result | |
| ;; These checks are delayed because they're expensive and might not be needed | |
| git-check (delay (shell {:out :string :err :string :continue true} | |
| "git" "rev-parse" "--git-dir")) ; Check if in git repo | |
| staged-files (delay (run-git "diff" "--cached" "--name-only"))] ; Get list of staged files | |
| ;; cond is like a switch/case statement - evaluates conditions in order | |
| ;; First truthy condition wins, its expression is evaluated, rest are skipped | |
| ;; This structure validates arguments and environment before running any commands | |
| (cond | |
| ;; Show help if no args or help flag | |
| ;; or returns true if ANY condition is true | |
| (or help (empty? *command-line-args*)) | |
| (print-help) | |
| ;; Invalid model specified - check if model is in the valid set | |
| ;; Sets are functions! (#{:a :b} :a) returns :a (truthy), (#{:a :b} :c) returns nil (falsy) | |
| ;; So (:valid-models config) acts as a function that checks membership | |
| ;; not inverts: if model not in set, this condition is true | |
| (not ((:valid-models config) model)) | |
| ;; do groups multiple expressions (executes all, returns last) | |
| (do (binding [*out* *err*] ; Print to stderr | |
| (println (c/red (str "Error: Model must be one of: " | |
| (str/join ", " (:valid-models config)))))) | |
| (System/exit 1)) ; Exit with error code | |
| ;; Not in a git repository - validate environment | |
| ;; @ dereferences the delay - NOW the git check actually runs | |
| ;; :exit extracts the exit code from the shell result map | |
| ;; zero? checks if exit code is 0 (success), not inverts it | |
| ;; So: if exit code is not 0 (command failed), this condition is true | |
| (not (zero? (:exit @git-check))) | |
| (do (binding [*out* *err*] | |
| (println (c/red "Error: Not in a git repository"))) | |
| (println "This script must be run from within a git repository.") | |
| (System/exit 1)) | |
| ;; Interactive mode requires gum - validate dependencies | |
| ;; and checks both conditions: interactive flag AND gum not installed | |
| ;; command -v returns 0 if command exists, non-zero if not | |
| (and interactive | |
| (not (zero? (:exit (shell {:out :string :err :string :continue true} | |
| "command" "-v" "gum"))))) | |
| (do (binding [*out* *err*] | |
| (println (c/red "Error: Interactive mode requires gum"))) | |
| (println (c/cyan "Install with: brew install gum")) | |
| (System/exit 1)) | |
| ;; PR mode requires gh (GitHub CLI) | |
| ;; Check if gh is installed when --pr flag is used | |
| (and pr | |
| (not (zero? (:exit (shell {:out :string :err :string :continue true} | |
| "command" "-v" "gh"))))) | |
| (do (binding [*out* *err*] | |
| (println (c/red "Error: PR mode requires gh (GitHub CLI)"))) | |
| (println (c/cyan "Install with: brew install gh")) | |
| (System/exit 1)) | |
| ;; PR mode: preview description (--pr --dry-run) | |
| ;; Generate and display PR description and selected labels without creating the PR | |
| ;; In interactive mode, allows editing before displaying | |
| ;; Note: Duplicates git logic here and in PR create mode because get-pr-description | |
| ;; does its own git calls internally, but get-pr-labels needs the commits/diff | |
| (and pr dry-run) | |
| (let [current-branch (str/trim (run-git "branch" "--show-current")) | |
| base-branch (str/trim (or (run-git "symbolic-ref" "refs/remotes/origin/HEAD") | |
| "origin/main")) | |
| base-branch (str/replace base-branch #"refs/remotes/origin/" "") | |
| ;; Get commits (.. = all commits in current not in base) | |
| commits (run-git "log" (str base-branch ".." current-branch) "--oneline") | |
| ;; Get diff (... = compare against merge-base, shows only branch changes) | |
| diff (run-git "diff" (str base-branch "..." current-branch)) | |
| generated-description (get-pr-description model verbose) | |
| ;; In interactive mode, allows editing before displaying | |
| description (if interactive | |
| (gum-write generated-description "Edit PR description:") | |
| generated-description) | |
| selected-labels (get-pr-labels commits diff model verbose)] | |
| (println description) | |
| (println "\n---") | |
| (println "Labels:" (str/join ", " selected-labels))) | |
| ;; PR mode: create PR (--pr) | |
| ;; Generate PR description and create the PR using gh CLI | |
| ;; --fill automatically uses branch name as PR title | |
| ;; Labels are selected by LLM based on the changes | |
| pr | |
| (let [current-branch (str/trim (run-git "branch" "--show-current")) | |
| base-branch (str/trim (or (run-git "symbolic-ref" "refs/remotes/origin/HEAD") | |
| "origin/main")) | |
| base-branch (str/replace base-branch #"refs/remotes/origin/" "") | |
| ;; Get commits (.. = all commits in current not in base) | |
| commits (run-git "log" (str base-branch ".." current-branch) "--oneline") | |
| ;; Get diff (... = compare against merge-base, shows only branch changes) | |
| diff (run-git "diff" (str base-branch "..." current-branch)) | |
| generated-description (get-pr-description model verbose) | |
| ;; In interactive mode, allows editing the description | |
| description (if interactive | |
| (gum-write generated-description "Edit PR description:") | |
| generated-description) | |
| ;; Ask LLM to select appropriate labels based on changes | |
| selected-labels (get-pr-labels commits diff model verbose) | |
| ;; Build label arguments for gh CLI dynamically | |
| ;; gh pr create expects: -l label1 -l label2 -l label3 | |
| ;; mapcat = map + concat: transforms each item and flattens results | |
| ;; Example: ["bug" "enhancement"] -> [["-l" "bug"] ["-l" "enhancement"]] -> ["-l" "bug" "-l" "enhancement"] | |
| ;; fn [label] ["-l" label] creates a function that wraps each label with "-l" | |
| label-args (mapcat (fn [label] ["-l" label]) selected-labels) | |
| ;; Combine base command with dynamic label arguments | |
| ;; concat joins multiple sequences into one | |
| ;; Example result: ["gh" "pr" "create" "--body" "description" "--fill" "-l" "bug" "-l" "enhancement"] | |
| pr-args (concat ["gh" "pr" "create" "--body" description "--fill"] label-args)] | |
| ;; In interactive mode, confirm before creating PR | |
| ;; when executes body only if condition is true | |
| ;; (or (not interactive) ...) = if not interactive OR user confirms | |
| (when (or (not interactive) | |
| (gum-confirm "Create PR?")) | |
| ;; Execute gh command with all arguments | |
| ;; apply calls a function with arguments from a sequence | |
| ;; cons adds {:continue true} as first argument to the shell function | |
| ;; This expands to: (shell {:continue true} "gh" "pr" "create" "--body" "..." "--fill" "-l" "bug" ...) | |
| (apply shell (cons {:continue true} pr-args)) | |
| (when verbose | |
| (binding [*out* *err*] | |
| (println "PR Description:\n" description) | |
| (println "Labels:" selected-labels))))) | |
| ;; Commit mode: check for staged files first | |
| ;; Can't commit if nothing is staged - fail early with clear error | |
| ;; and checks both: commit mode AND no staged files | |
| ;; @staged-files derefs the delay - NOW the git diff command actually runs | |
| ;; empty? returns true if the string is empty (no files listed) | |
| (and commit (empty? @staged-files)) | |
| (do (binding [*out* *err*] | |
| (println (c/red "Error: No staged files"))) | |
| (println (c/cyan "Use 'git add' to stage changes first.")) | |
| (System/exit 1)) | |
| ;; Commit mode: preview commit message (--commit --dry-run) | |
| ;; Generate and display commit message without committing | |
| ;; In interactive mode, allows editing before displaying | |
| (and commit dry-run) | |
| (let [diff (run-git "diff" "--cached") ; Get staged changes | |
| ;; @staged-files derefs the delay to get the file list | |
| generated-message (get-commit-message @staged-files diff model verbose) | |
| ;; In interactive mode, allows editing before displaying | |
| message (if interactive | |
| (gum-write generated-message "Edit commit message:") | |
| generated-message)] | |
| (println message)) ; Output the message (dry-run = preview only) | |
| ;; Commit mode: commit and push (--commit) | |
| ;; Generate commit message, commit staged changes, and push to remote | |
| ;; This is the actual commit flow (not dry-run) | |
| commit | |
| (let [diff (run-git "diff" "--cached") | |
| generated-message (get-commit-message @staged-files diff model verbose) | |
| ;; In interactive mode, allows editing the message | |
| message (if interactive | |
| (gum-write generated-message "Edit commit message:") | |
| generated-message)] | |
| (when verbose | |
| (binding [*out* *err*] | |
| (println "Commit message:" message))) | |
| ;; In interactive mode, confirm before committing | |
| ;; when = execute body only if condition is true | |
| (when (or (not interactive) ; Not interactive OR user confirmed | |
| (gum-confirm "Commit and push?")) | |
| (shell "git" "commit" "-m" message) ; Create the commit | |
| ;; Push to remote with upstream tracking | |
| ;; -u sets upstream tracking (needed for new branches) | |
| ;; origin is the remote name (typically the GitHub repo) | |
| ;; HEAD resolves to current branch name automatically (works for any branch) | |
| (shell "git" "push" "-u" "origin" "HEAD"))) | |
| ;; No mode specified - catch-all for invalid usage | |
| ;; :else is the default case in cond (like 'default:' in switch statements) | |
| ;; If none of the above conditions matched, show error | |
| :else | |
| (do (binding [*out* *err*] | |
| (println (c/red "Error: Must specify --commit or --pr"))) | |
| (println (c/cyan "Use -h for help")) | |
| (System/exit 1)))) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment