Python CLI: Local AI Commit Messages
Build a Python CLI that suggests Conventional Commits from staged Git changes.
Introduction
30 Second Summary
Commit messages usually get written at the end of a focused coding session. That is when the details are freshest but your energy is lowest.
In this project, you will build a Python command-line tool that turns staged Git changes into a suggested commit message. Ollama processes the diff on your Mac before returning a Conventional Commit for you to review.
What You'll Build
Picture your terminal showing a clean commit suggestion with a short explanation for the changes you just staged.
By the end of this project, you'll have:
- A working CommitCraft CLI that turns staged changes into a predictable commit header with a short reason.
- A local AI workflow that keeps your staged repository content on your Mac.
- Clear failure messages when Git, staged changes, or Ollama are unavailable.
- Secret Mission: Add a --style option that switches between concise and detailed commit messages.
Are there any prerequisites?
You'll need macOS 14 or newer with Python 3.7 or newer. The local setup also needs VS Code plus enough free storage for the approximately 806MB gemma3:1b model download.
You do not need to confirm Git beforehand or create an API account.
Before We Start
Before any hands-on work, lock in the purpose of CommitCraft. CommitCraft is a suggestion-only local Python CLI that reads staged Git changes to propose a reviewable Conventional Commit while leaving the final decision with you.
Set Up the Local AI Toolchain
CommitCraft needs Python to run the CLI. It also needs Git to expose the changes prepared for the next commit.
Ollama provides the local model that turns those changes into a suggestion. In this step, you'll connect every part of that toolchain before creating any project files.
In this step, get ready to:
- Verify that Python 3.7 or newer is available.
- Verify that Git is available on your Mac.
- Install Ollama and test the gemma3:1b model through its local API.
Verify Python and Git
Python 3.7 introduced the subprocess options that CommitCraft uses later. A version check confirms your interpreter can support the completed CLI.
- Press Cmd+Space to open Spotlight.
- Type Terminal into Spotlight.
- Press Enter to open Terminal.
- Check your installed Python version by running:
python3 --version
What does this check?
The --version flag prints the interpreter version attached to the python3 command. CommitCraft needs Python 3.7 or newer.
✔️ I see version 3.7 or higher
Your Python interpreter supports the standard-library features used by CommitCraft.
ⓧ I see an older version
An older interpreter cannot support every subprocess option used in the finished CLI. The official macOS installer provides a current Python version.
- Visit the Python releases for macOS page.
- Choose Download macOS installer for a current stable release.
- Open the downloaded .pkg file.
- Click Continue through the installer information.
- Click Agree to accept the license.
- Keep the standard Installation Type.
- Click Install to complete the installation.
- Return to Terminal.
- Confirm the newer interpreter is active by running:
python3 --version
What should you confirm?
The output should now show Python 3.7 or newer. That version is ready for CommitCraft.
ⓧ Command not found
Your shell cannot find a Python interpreter yet. The official macOS installer adds a current interpreter to your command path.
- Visit the Python releases for macOS page.
- Choose Download macOS installer for a current stable release.
- Open the downloaded .pkg file.
- Click Continue through the installer information.
- Click Agree to accept the license.
- Keep the standard Installation Type.
- Click Install to complete the installation.
- Return to Terminal.
- Confirm Python is available by running:
python3 --version
What should you confirm?
The output should show Python 3.7 or newer. Your terminal can now find the interpreter.
- Check whether Git is available by running:
git --version
What does this check?
The command prints the installed Git version. CommitCraft later asks Git for the exact changes in its staging area.
✔️ I see a Git version
Git is available for the repository work later in this project.
ⓧ Command not found
Apple provides Git through its Command Line Tools package. The next command opens Apple's installer dialog.
- Start the Command Line Tools installer by running:
xcode-select --install
What does this command do?
This asks macOS to install Apple's Command Line Tools package. That package includes Git.
- Complete Apple's installer dialog.
- Return to Terminal after the installation finishes.
- Confirm Git is now available by running:
git --version
What should you confirm?
The output should now show an installed Git version. That confirms the command-line tools are ready.
Install and start Ollama
Ollama runs the model on your Mac and exposes it through a local server. This project uses Ollama v0.40.15 or newer.
- Check whether Ollama is installed by running:
ollama -v
What does this check?
The -v option prints your installed Ollama version. The result tells you which setup path to follow.
✔️ I see version 0.40.15 or higher
Your Ollama installation is ready for the local setup flow.
ⓧ I see an older version
Installing the current release updates the local runtime used by this project. The official installer script handles the update from Ollama's download source.
- Update Ollama by running:
curl -fsSL https://ollama.com/install.sh | sh
What does this command do?
The command downloads Ollama's official installation script over HTTPS. It passes that script to your shell to install the current release.
- Confirm the updated version by running:
ollama -v
What should you confirm?
The output should show Ollama v0.40.15 or newer. Your installation can run the selected model.
ⓧ Command not found
Installing a local runtime adds Ollama to your Mac and command path. The official installer script handles that setup from Ollama's download source.
- Install Ollama by running:
curl -fsSL https://ollama.com/install.sh | sh
What does this command do?
The command downloads Ollama's official installation script over HTTPS. It passes that script to your shell to install the local runtime.
- Confirm the installation by running:
ollama -v
What should you confirm?
The output should show Ollama v0.40.15 or newer. Your terminal can now find the Ollama command.
The Ollama command starts its terminal setup flow. Local model requests do not require an API key.
- Start the local Ollama setup flow by running:
ollama
What does this command do?
This starts Ollama's terminal setup flow. Completing that flow makes its local server available for model requests.
- Follow each local setup prompt shown in Terminal.
- Keep Ollama available while you continue.
Having trouble starting Ollama?
- Confirm that the installation command finished before running Ollama.
- Close Terminal if it still cannot find Ollama.
- Open Terminal again through Spotlight after closing it.
Still stuck? Help me troubleshoot my Ollama installation on macOS. You can also share the exact terminal output in the NextWork community.
Download the model and test the local API
The gemma3:1b model is approximately 806MB. The first run downloads the model before opening an interactive chat.
This first launch can take several minutes while the model downloads. A moving progress indicator means the download is still working.
- Download and start the selected model by running:
ollama run gemma3:1b
What does this command do?
Ollama downloads gemma3:1b when it is missing locally. It then opens an interactive chat with that model.
- Send a short test prompt by typing:
Reply with five words about local AI.
What does this test prove?
A model reply confirms that Ollama can load gemma3:1b and generate text locally. This checks the model before Python enters the workflow.
- Read the model's response in Terminal.
- Leave the interactive chat by typing /bye.
Your local model has answered its first prompt. The final check proves that the same model is reachable through the interface CommitCraft uses.
An HTTP request lets programs send a prompt to Ollama without opening an interactive chat. The local chat endpoint returns the assistant's text inside a JSON response.
Before you send the request, which part of the response do you expect to hold the assistant's reply?
- Send a non-streaming request to the local chat endpoint by running:
curl http://localhost:11434/api/chat -d '{
"model": "gemma3:1b",
"messages": [
{
"role": "user",
"content": "Reply with one sentence about local AI."
}
],
"stream": false
}'
What does this request do?
- The URL targets Ollama's local chat endpoint on your Mac.
- The model field selects gemma3:1b for the response.
- The messages array carries the user prompt to the model.
- The stream value is false. This returns one complete JSON object.
You'll see a JSON response with a message object. Its content field contains the assistant's reply.
That's the complete local connection proven. Your terminal can send a request to Ollama and receive model-generated text without a hosted API key.
Don't see an assistant reply?
- Confirm that Ollama remains available on your Mac.
- Run the interactive model command again if the model download did not finish.
- Check that the request uses the exact model ID gemma3:1b.
Need help? Help me troubleshoot my local Ollama chat request. You can also share the redacted terminal output in the NextWork community.
Python and Git are ready. Ollama can now answer requests through its local API. Next, you'll create CommitCraft and turn a sample diff into your first AI-generated commit suggestion.
Get the First AI Suggestion
Your local Ollama model is ready. An API response alone does not prove that a Python CLI can turn a Git diff into a useful suggestion.
This step creates the smallest end-to-end version of CommitCraft with a controlled sample diff. You'll inspect the result to discover what this baseline can tell you.
In this step, get ready to:
- Create the commitcraft.py foundation for the sample request.
- Build the local Ollama chat request.
- Run the script to inspect its raw commit suggestion.
Prepare the script foundation
CommitCraft needs one project folder with one Python file. The first code chunk defines the local API address plus the model and sample change used for this test.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code in the search bar.
- Press Enter to open VS Code.
VS Code gives the project folder a dedicated editor window. This keeps the file location clear when you run the script from its integrated terminal.
- Click File in the top menu bar.
- Select Open Folder.
- Select Desktop in the file picker sidebar.
- Click New Folder.
- Enter commitcraft as the folder name.
- Click Create.
- Select the commitcraft folder.
- Click Open.
The Explorer sidebar now points at the empty commitcraft folder on your Desktop.
- Click the New File control in the Explorer sidebar.
- Enter commitcraft.py as the file name.
- Press Enter to create the file.
- Add the imports plus the sample configuration by copying this code into commitcraft.py:
#!/usr/bin/env python3
import json
from urllib.request import Request, urlopen
API_URL = "http://localhost:11434/api/chat"
DEFAULT_MODEL = "gemma3:1b"
SAMPLE_DIFF = """diff --git a/hello.py b/hello.py
+print(\"Hello, CommitCraft!\")
"""
What does this code do?
- The first line lets macOS treat the file as a Python script when it is made executable.
- The json module converts Python data into the request format expected by Ollama.
- Request represents the HTTP request. urlopen sends that request to the local server.
- API_URL points to Ollama's local chat endpoint. DEFAULT_MODEL selects gemma3:1b.
- SAMPLE_DIFF represents one added line in hello.py.
- Save commitcraft.py by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Confirm the Explorer sidebar lists commitcraft.py inside the commitcraft folder.
Can't create the project file?
- Check that the VS Code window shows commitcraft as the open folder.
- Check that the file name ends with .py.
- Make sure the file appears directly inside commitcraft.
Still stuck? Help me create commitcraft.py in the correct VS Code folder.
Build the local chat request
Ollama's chat API accepts a JSON payload containing the selected model plus a list of messages. The user message asks for a Conventional Commit suggestion based on SAMPLE_DIFF.
- Place your cursor after the closing triple quotes in commitcraft.py.
- Add the payload plus the HTTP request by copying this code below the existing content:
def main():
payload = {
"model": DEFAULT_MODEL,
"messages": [
{
"role": "user",
"content": (
"Suggest one Conventional Commit for this Git diff:\n\n"
f"{SAMPLE_DIFF}"
),
}
],
"stream": False,
}
request = Request(
API_URL,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
What does this code do?
- payload groups the model ID plus the conversation into the structure required by the chat endpoint.
- stream is set to False so Ollama returns one complete response.
- json.dumps(payload) serializes the Python dictionary as JSON.
- encode("utf-8") converts that JSON string into bytes for the request body.
- Request prepares a POST request with the JSON content type.
- Save commitcraft.py.
- Check the new section for red syntax underlines in VS Code.
Seeing syntax warnings?
- Check that every opening brace has a matching closing brace.
- Keep every line from payload = { through the closing ) indented inside main().
- Check that the two prompt strings remain inside the content parentheses.
Need another pair of eyes? Help me find the syntax problem in my CommitCraft payload and Request code.
Send the request and inspect the suggestion
The prepared request still needs to be sent to Ollama. CommitCraft then decodes the outer response before selecting the assistant reply inside message.content.
- Place your cursor after the closing parenthesis of the Request block.
- Complete the request flow by copying this code below it:
with urlopen(request) as response:
response_payload = json.loads(response.read().decode("utf-8"))
print(response_payload["message"]["content"])
if __name__ == "__main__":
main()
What's happening here?
- urlopen(request) sends the prepared request to the local Ollama server.
- response.read().decode("utf-8") converts the response bytes into text.
- json.loads parses the outer JSON response into a Python dictionary.
- response_payload["message"]["content"] selects the assistant's raw reply.
- The final condition calls main() when you run commitcraft.py directly.
- Save commitcraft.py.
- Click Terminal in the VS Code top menu.
- Select New Terminal.
Before you run the script, do you think the suggestion will describe your current project or the sample change inside the file?
- Run the first complete CommitCraft version with this command:
python3 commitcraft.py
What does this command do?
python3 runs commitcraft.py from the current folder. The script sends its sample diff to your local model before printing the returned assistant text.
You'll see a raw model-generated commit suggestion in the terminal. Its exact wording can vary between runs.
That's your first end-to-end CommitCraft result. Your Python script can now send a diff to the local model and display its reply.
- Read the suggestion to identify which file and change it describes.
The suggestion describes the added greeting in hello.py because that change is hard-coded inside SAMPLE_DIFF. The model has not inspected your real project changes.
The reply can also contain extra commentary or inconsistent formatting. You have proved the connection works while exposing the limits of this first version.
Didn't get a suggestion?
- Check that Ollama is still running locally.
- Check that gemma3:1b remains available in Ollama.
- Check the terminal path to confirm it ends with the commitcraft folder.
- Compare the indentation inside main() with the code above.
Still blocked? Help me troubleshoot why commitcraft.py is not printing an Ollama response.
✔️ Awesome, I've got everything!
Great. Make sure commitcraft.py is saved and keep Ollama running locally.
ⓧ I'd like to double check the full code
#!/usr/bin/env python3
import json
from urllib.request import Request, urlopen
API_URL = "http://localhost:11434/api/chat"
DEFAULT_MODEL = "gemma3:1b"
SAMPLE_DIFF = """diff --git a/hello.py b/hello.py
+print(\"Hello, CommitCraft!\")
"""
def main():
payload = {
"model": DEFAULT_MODEL,
"messages": [
{
"role": "user",
"content": (
"Suggest one Conventional Commit for this Git diff:\n\n"
f"{SAMPLE_DIFF}"
),
}
],
"stream": False,
}
request = Request(
API_URL,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urlopen(request) as response:
response_payload = json.loads(response.read().decode("utf-8"))
print(response_payload["message"]["content"])
if __name__ == "__main__":
main()
CommitCraft can now turn a controlled diff into a local AI suggestion. Next, you'll replace that hard-coded sample with the changes staged in your real Git repository.
Read Real Staged Changes
The sample diff gave your local model enough context to produce a first suggestion. It cannot describe the work inside your actual project.
A Git staging area holds the exact changes prepared for the next commit. In this step, CommitCraft reads that staged content before asking the model for a suggestion.
In this step, get ready to:
- Initialize the CommitCraft folder as a Git repository.
- Read staged changes through a Python subprocess helper.
- Generate a raw suggestion from the staged Python file.
Connect Python to Git
The subprocess module lets Python run Git with a list of arguments. CommitCraft can then capture Git's output as text for the model prompt.
- Switch back to the terminal panel from earlier.
- Initialize the current CommitCraft folder as a Git repository by running this command:
git init
What does this command do?
This creates the metadata Git needs to track changes inside the current folder. Your existing commitcraft.py file stays in place.
Good start. Your project folder can now hold the staged changes that CommitCraft needs to inspect.
- Return to commitcraft.py in VS Code.
- Place your cursor directly below the import json line.
- Give Python access to subprocesses by adding this import:
import subprocess
Why add this import?
The new import gives CommitCraft access to Python's process-running tools. The script uses those tools to call Git without passing a command through a shell.
- Place your cursor below the closing triple quotes of the existing SAMPLE_DIFF value.
- Add the Git command helper by copying this code:
def run_git(*args):
result = subprocess.run(
["git", *args],
capture_output=True,
text=True,
check=True,
)
return result.stdout
What does this helper do?
- The run_git() helper accepts any Git arguments needed by the script.
- The ["git", *args] list keeps the executable separate from each argument.
- The capture_output=True option collects Git's output for Python.
- The text=True option returns the captured output as text.
- The check=True option raises an error when Git reports a failure.
- Save commitcraft.py.
- Switch back to the terminal panel from earlier.
- Confirm the script still reaches the local model by running:
python3 commitcraft.py
What should you see?
You should still receive a raw model-generated suggestion based on the sample diff. This confirms the new Git helper has not interrupted the existing local request.
Script stops before the suggestion?
- Confirm that import subprocess appears directly below import json.
- Check that run_git() starts at the left edge of the file.
- Confirm the local Ollama server from earlier is still running.
Still stuck? Help me debug the new subprocess helper in CommitCraft.
Read the staged diff
A staged diff represents the saved file content prepared for the next commit. CommitCraft also needs a focused error when the staging area is empty.
- Return to commitcraft.py in the editor.
- Place your cursor below the return result.stdout line.
- Add the staged-diff reader by copying this code:
def get_staged_diff():
inside_work_tree = run_git(
"rev-parse", "--is-inside-work-tree"
).strip()
if inside_work_tree != "true":
raise RuntimeError("Run CommitCraft inside a Git work tree.")
diff = run_git("diff", "--staged", "--")
if not diff.strip():
raise RuntimeError(
"No staged changes found. Stage a file with git add and try again."
)
return diff
How does the staged-diff reader work?
- The first Git call checks whether the current folder is inside a work tree.
- The second Git call returns only the changes stored in the staging area.
- The empty check raises a focused instruction when there is no staged content.
- The final return passes the captured diff to the rest of CommitCraft.
- Save commitcraft.py.
- Switch back to the terminal panel from earlier.
- Stage the saved Python file by running:
git add commitcraft.py
What does staging change?
Git copies the saved content of commitcraft.py into its staging area. That staged copy now represents the content proposed for the next commit.
Before you inspect the staging area, which file do you expect the diff to contain?
- Display the staged content by running:
git diff --staged --
What should you see?
You should see a diff for commitcraft.py containing the saved Python code. This proves Git has real repository content ready for CommitCraft.
Staged diff is empty?
- Confirm the terminal is inside the folder containing commitcraft.py.
- Confirm you saved commitcraft.py before staging it.
- Stage commitcraft.py again from the current project folder.
Need another pair of eyes? Help me find why my staged diff is empty.
Replace the sample with staged content
The staged diff is now available through get_staged_diff(). The request can use that value to describe the work in your repository.
- Return to the main() function in commitcraft.py.
- Select from the def main(): line through the closing brace of payload.
- Replace the selection with this repository-aware request:
def main():
diff = get_staged_diff()
payload = {
"model": DEFAULT_MODEL,
"messages": [
{
"role": "user",
"content": (
"Suggest one Conventional Commit for this staged Git diff:\n\n"
f"{diff}"
),
}
],
"stream": False,
}
What changed in the request?
- The diff variable receives the staged content from get_staged_diff().
- The prompt identifies its input as a staged Git diff.
- The formatted string inserts the repository content into the model request.
- Save commitcraft.py.
Before you test the new request, do you think the suggestion will still focus on the sample greeting?
- Run the repository-aware script by using:
python3 commitcraft.py
What should you see now?
You should see a raw commit suggestion that refers to the staged Python script. The result may include extra commentary because the model output is still unrestricted.
That is the important shift complete. CommitCraft now describes content from your repository.
- Delete the SAMPLE_DIFF assignment through its closing triple quotes.
- Save commitcraft.py.
- Confirm the cleaned script still works by running:
python3 commitcraft.py
Why run it again?
You should still receive a suggestion about the staged Python file. This proves the request no longer depends on SAMPLE_DIFF.
- Update the staged copy with the finished script by running:
git add commitcraft.py
What does this update?
Git replaces the earlier staged copy with the finished version of commitcraft.py. The staged file now contains the repository-aware request without the sample constant.
Before you run the final check, what parts of the staged script do you expect the model to summarize?
- Run the finished repository-aware version by using:
python3 commitcraft.py
What should you see?
You should see a model-generated commit suggestion based on the staged contents of commitcraft.py. The response can still include extra prose because it remains raw model text.
You made CommitCraft useful for real work. Its suggestion now follows the exact changes prepared in Git.
Suggestion still looks unrelated?
- Confirm the prompt inside main() inserts diff.
- Confirm the SAMPLE_DIFF assignment has been removed.
- Stage the saved file again before rerunning CommitCraft.
Still seeing the wrong subject? Help me trace the staged content through CommitCraft.
✔️ Awesome, I've got everything!
Your saved commitcraft.py now reads the staged Git diff. The finished file is also staged for the next step.
ⓧ I'd like to double check the full code
Compare your saved commitcraft.py with this complete version:
#!/usr/bin/env python3
import json
import subprocess
from urllib.request import Request, urlopen
API_URL = "http://localhost:11434/api/chat"
DEFAULT_MODEL = "gemma3:1b"
def run_git(*args):
result = subprocess.run(
["git", *args],
capture_output=True,
text=True,
check=True,
)
return result.stdout
def get_staged_diff():
inside_work_tree = run_git(
"rev-parse", "--is-inside-work-tree"
).strip()
if inside_work_tree != "true":
raise RuntimeError("Run CommitCraft inside a Git work tree.")
diff = run_git("diff", "--staged", "--")
if not diff.strip():
raise RuntimeError(
"No staged changes found. Stage a file with git add and try again."
)
return diff
def main():
diff = get_staged_diff()
payload = {
"model": DEFAULT_MODEL,
"messages": [
{
"role": "user",
"content": (
"Suggest one Conventional Commit for this staged Git diff:\n\n"
f"{diff}"
),
}
],
"stream": False,
}
request = Request(
API_URL,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urlopen(request) as response:
response_payload = json.loads(response.read().decode("utf-8"))
print(response_payload["message"]["content"])
if __name__ == "__main__":
main()
CommitCraft now reads the exact changes prepared for the next commit. Next, you'll turn its raw response into a predictable commit header with a separate explanation.
Enforce a Predictable Commit Format
Your CLI now reads the exact Git changes staged for the next commit. Its raw model response can still include commentary or inconsistent formatting.
This step turns that free-form response into a stable interface with explicit fields. The result is a reviewable Conventional Commit header plus a separate explanation.
In this step, get ready to:
- Inspect the current raw response and identify its formatting limitation.
- Define clear fields for every generated commit suggestion.
- Display a predictable suggestion with a separate reason.
Inspect the raw response
The current script asks for a commit suggestion as plain text. The model controls how that text is presented.
Before you run the current version, do you think every response follows the same structure?
- Run the current CLI from the terminal in your project folder:
python3 commitcraft.py
What does this run reveal?
You should see a repository-specific commit suggestion. Its wording can include extra prose or inconsistent formatting because the response has no enforced structure.
That variation is the intended shortfall. A command-line tool needs fields that Python can parse before it prints a dependable result.
No model response?
- Confirm the local Ollama service from the previous step is still available.
- Confirm your staged copy of `commitcraft.py` still exists in the Git repository.
Still stuck? Help me diagnose why CommitCraft no longer returns its raw model response.
Define the response contract
A JSON schema describes the fields and value types a response must contain. Ollama can use this contract to produce data that your script can parse.
- In `commitcraft.py`, find the `DEFAULT_MODEL` constant.
- Add the diff limit and commit schema directly below that constant:
MAX_DIFF_CHARS = 12000
COMMIT_SCHEMA = {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"feat", "fix", "docs", "style", "refactor", "perf",
"test", "build", "ci", "chore", "revert",
],
},
"scope": {"type": "string"},
"description": {"type": "string"},
"reason": {"type": "string"},
},
"required": ["type", "scope", "description", "reason"],
}
What does this code define?
- `MAX_DIFF_CHARS` limits the staged text sent to the local model to the first 12,000 characters.
- `COMMIT_SCHEMA` requires separate values for the commit type, scope, description, and reason.
- The `enum` limits the commit type to the accepted values listed in the schema.
- The `required` list ensures every response includes all four fields.
- Save `commitcraft.py`.
- Check that the new constants contain valid Python by running the script again:
python3 commitcraft.py
What should you see?
You should still receive a model-generated response without a Python syntax error. That confirms the schema is valid Python data.
Seeing a syntax problem?
- Check that every schema key uses matching quotation marks.
- Check that each opening brace or bracket has a matching closing character.
- Confirm `COMMIT_SCHEMA` starts at the left edge of the file.
Need another pair of eyes? Help me compare my COMMIT_SCHEMA with the required Python structure.
- In `get_staged_diff()`, find `return diff`.
- Replace it with `return diff[:MAX_DIFF_CHARS]`.
The slice applies the project-defined prompt guardrail before the staged diff reaches the model.
- Add `build_prompt(diff)` below `get_staged_diff()` by copying this code:
def build_prompt(diff):
schema_text = json.dumps(COMMIT_SCHEMA)
return (
"Suggest one Conventional Commit for the staged Git diff below. "
"Use an empty scope when no clear scope exists. Keep the description "
"short and imperative. Explain the choice in one short sentence. "
"Return only JSON matching this schema: "
f"{schema_text}\n\nSTAGED DIFF:\n{diff}"
)
How does the prompt guide the model?
- `schema_text` serializes the same response contract into the prompt.
- The instructions establish an empty scope as the fallback when no clear scope exists.
- The prompt requests a short description plus a one-sentence reason.
- The staged diff appears after the instructions so the model can connect its output to the pending commit.
- Save `commitcraft.py`.
- Confirm the new function loads successfully by running the script:
python3 commitcraft.py
What does this check prove?
You should receive another model response without a Python syntax error. Python has successfully loaded `MAX_DIFF_CHARS`, `COMMIT_SCHEMA`, and `build_prompt()`.
Does the script stop before responding?
- Confirm `build_prompt()` starts at the left edge of `commitcraft.py`.
- Check that every line inside the function uses four spaces of indentation.
- Confirm the final closing parenthesis aligns with `return`.
Still seeing a problem? Help me fix the indentation or string construction in build_prompt().
Request and format structured output
The schema now exists inside your script. The next request sends it through Ollama's `format` field with a `temperature` of `0` for more deterministic structured output.
- Add `request_commit(model, diff)` directly below `build_prompt(diff)` by copying this code:
def request_commit(model, diff):
payload = {
"model": model,
"messages": [{"role": "user", "content": build_prompt(diff)}],
"stream": False,
"format": COMMIT_SCHEMA,
"options": {"temperature": 0},
}
request = Request(
API_URL,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urlopen(request, timeout=120) as response:
response_payload = json.loads(response.read().decode("utf-8"))
return json.loads(response_payload["message"]["content"])
What does this request do?
- The `format` field gives Ollama the schema that the response must follow.
- The `options` field sets `temperature` to `0` for more consistent structured completions.
- The request includes a 120-second timeout for local inference.
- The first `json.loads()` parses Ollama's outer response.
- The returned `json.loads()` parses the JSON string inside `message.content` into a Python dictionary.
- Save `commitcraft.py`.
- Check that the request helper loads successfully by running the script:
python3 commitcraft.py
What should happen now?
You should still receive the existing raw response. That result confirms Python can load the structured request helper before you connect it to `main()`.
Does the request helper fail to load?
- Confirm `request_commit()` sits outside `build_prompt()`.
- Check the braces around `payload`.
- Check the parentheses around both `json.loads()` calls.
Need help tracing the structure? Help me find the syntax issue in request_commit().
- Add `format_header(commit_data)` directly below `request_commit(model, diff)` by copying this code:
def format_header(commit_data):
commit_type = commit_data["type"].strip()
scope = commit_data["scope"].strip()
description = commit_data["description"].strip()
if scope:
return f"{commit_type}({scope}): {description}"
return f"{commit_type}: {description}"
How is the header assembled?
- The first three lines remove surrounding whitespace from the structured values.
- A non-empty scope produces the `<type>(<optional scope>): <description>` form.
- An empty scope produces the shorter `type: description` form.
- Save `commitcraft.py`.
- Confirm the formatter loads without breaking the script by running:
python3 commitcraft.py
What does this run confirm?
You should receive the existing model response again. Python has now loaded the formatter that the final `main()` function uses.
- In `commitcraft.py`, select the existing `main()` function from `def main():` through `print(response_payload["message"]["content"])`.
- Replace the selected function with this version:
def main():
commit_data = request_commit(DEFAULT_MODEL, get_staged_diff())
print(f"Suggestion: {format_header(commit_data)}")
print(f"Why: {commit_data['reason'].strip()}")
How does the final flow work?
- `get_staged_diff()` supplies the limited staged Git diff.
- `request_commit()` returns the parsed fields required by `COMMIT_SCHEMA`.
- `format_header()` turns those fields into one Conventional Commit header.
- The two `print()` calls keep the suggestion separate from the model's reason.
- Save `commitcraft.py`.
Before you run the finished version, what two output labels do you expect to see?
- Verify the schema-constrained output by running:
python3 commitcraft.py
What should you see?
You should see a `Suggestion:` line in `<type>(<optional scope>): <description>` form. You should also see a separate `Why:` line with a short explanation.
The exact generated words depend on the staged diff. The two-line structure now remains predictable for your CLI.
Missing the structured lines?
- Confirm `main()` calls `request_commit(DEFAULT_MODEL, get_staged_diff())`.
- Confirm the request payload includes `format` with `COMMIT_SCHEMA` as its value.
- Confirm `message.content` passes through the second `json.loads()` call.
Still seeing raw prose or invalid data? Help me trace CommitCraft's structured response from Ollama to the Suggestion and Why lines.
✔️ Awesome, I've got everything!
Your CLI now turns the staged diff into a schema-constrained commit suggestion. Make sure `commitcraft.py` is saved before continuing.
ⓧ I'd like to double check the full code
- Compare your saved `commitcraft.py` with this complete version:
#!/usr/bin/env python3
import json
import subprocess
from urllib.request import Request, urlopen
API_URL = "http://localhost:11434/api/chat"
DEFAULT_MODEL = "gemma3:1b"
MAX_DIFF_CHARS = 12000
COMMIT_SCHEMA = {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"feat", "fix", "docs", "style", "refactor", "perf",
"test", "build", "ci", "chore", "revert",
],
},
"scope": {"type": "string"},
"description": {"type": "string"},
"reason": {"type": "string"},
},
"required": ["type", "scope", "description", "reason"],
}
def run_git(*args):
result = subprocess.run(
["git", *args],
capture_output=True,
text=True,
check=True,
)
return result.stdout
def get_staged_diff():
inside_work_tree = run_git(
"rev-parse", "--is-inside-work-tree"
).strip()
if inside_work_tree != "true":
raise RuntimeError("Run CommitCraft inside a Git work tree.")
diff = run_git("diff", "--staged", "--")
if not diff.strip():
raise RuntimeError(
"No staged changes found. Stage a file with git add and try again."
)
return diff[:MAX_DIFF_CHARS]
def build_prompt(diff):
schema_text = json.dumps(COMMIT_SCHEMA)
return (
"Suggest one Conventional Commit for the staged Git diff below. "
"Use an empty scope when no clear scope exists. Keep the description "
"short and imperative. Explain the choice in one short sentence. "
"Return only JSON matching this schema: "
f"{schema_text}\n\nSTAGED DIFF:\n{diff}"
)
def request_commit(model, diff):
payload = {
"model": model,
"messages": [{"role": "user", "content": build_prompt(diff)}],
"stream": False,
"format": COMMIT_SCHEMA,
"options": {"temperature": 0},
}
request = Request(
API_URL,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urlopen(request, timeout=120) as response:
response_payload = json.loads(response.read().decode("utf-8"))
return json.loads(response_payload["message"]["content"])
def format_header(commit_data):
commit_type = commit_data["type"].strip()
scope = commit_data["scope"].strip()
description = commit_data["description"].strip()
if scope:
return f"{commit_type}({scope}): {description}"
return f"{commit_type}: {description}"
def main():
commit_data = request_commit(DEFAULT_MODEL, get_staged_diff())
print(f"Suggestion: {format_header(commit_data)}")
print(f"Why: {commit_data['reason'].strip()}")
if __name__ == "__main__":
main()
How should you compare it?
Check the file from top to bottom. Pay close attention to the helper order and the two `json.loads()` calls inside `request_commit()`.
Your CLI now produces a stable commit header with a separate explanation. Next, you'll add command help and friendly failure paths for real-world use.
Finish the CLI Experience
Your Git-aware CLI now turns staged changes into a structured Conventional Commit suggestion. A reusable tool must also guide someone who asks for help or runs it in the wrong environment.
This final step adds command help with selectable model support. It also turns a wrong folder, an empty staging area, or an unavailable Ollama server into concise terminal feedback.
In this step, get ready to:
- Add generated help text with a model option.
- Convert common Git, Ollama, timeout, and JSON failures into concise error messages.
- Verify that CommitCraft suggests a message without creating a commit.
Add command help and model selection
Python's argparse module generates usage instructions from the options your program accepts. The model flag makes CommitCraft configurable without requiring an edit to commitcraft.py.
- Return to commitcraft.py in VS Code.
- Replace the import block at the top of commitcraft.py with this code:
import argparse
import json
import subprocess
import sys
from urllib.error import URLError
from urllib.request import Request, urlopen
What do these imports add?
- The argparse module builds the command help and reads the selected model.
- The sys module gives the error handler access to standard error.
- The URLError exception identifies problems reaching the local Ollama API.
- Reformat the existing COMMIT_SCHEMA enum so each accepted type sits on its own line.
- Save commitcraft.py.
- Switch back to the terminal from earlier.
- Confirm the original suggestion path still works by running:
python3 commitcraft.py
What does this check prove?
This run confirms that the new imports preserve the working Ollama request. It also checks that the existing staged-diff flow still reaches the model.
You should still see separate Suggestion: and Why: lines. You have kept the working path intact while preparing its CLI features.
- Return to commitcraft.py.
- Add parse_args() directly above main() by copying this code:
def parse_args():
parser = argparse.ArgumentParser(
description="Suggest a Conventional Commit from staged Git changes."
)
parser.add_argument(
"--model",
default=DEFAULT_MODEL,
help="Ollama model to use (default: %(default)s)",
)
return parser.parse_args()
How does argument parsing work?
- The parser description explains CommitCraft's purpose on the help screen.
- The --model option accepts a different Ollama model ID when the learner supplies one.
- The DEFAULT_MODEL value keeps gemma3:1b as the normal choice.
- Find def main(): near the bottom of commitcraft.py.
- Replace the entire existing main() function with this version:
def main():
args = parse_args()
try:
diff = get_staged_diff()
commit_data = request_commit(args.model, diff)
header = format_header(commit_data)
reason = commit_data["reason"].strip()
except (
RuntimeError,
subprocess.CalledProcessError,
KeyError,
json.JSONDecodeError,
TimeoutError,
) as error:
print(f"CommitCraft error: {error}", file=sys.stderr)
return 1
print(f"Suggestion: {header}")
print(f"Why: {reason}")
return 0
What changes in main?
- The args.model value sends the default model or the learner's selected model into request_commit().
- The exception group catches expected failures before Python displays a traceback.
- The successful path still prints only a suggestion and its reason.
- Save commitcraft.py.
Before you check the help screen, which option do you expect to appear beside the generated usage information?
- Display CommitCraft's generated help by running:
python3 commitcraft.py --help
What does the help command show?
The argument parser handles the help request before CommitCraft reads Git or contacts Ollama. This gives users immediate guidance even when the rest of the environment is unavailable.
You should see usage information with the --model option. Good work. Your CLI now explains how to run itself.
Missing the model option?
- Confirm that parse_args() appears above main().
- Confirm that main() starts by assigning the result of parse_args().
- Check that the option is spelled --model in parser.add_argument().
Still stuck? Help me debug why CommitCraft's help screen does not show the model option.
Turn failures into clear errors
Expected failures need a boundary where technical exceptions become useful instructions. CommitCraft handles Git checks close to get_staged_diff() and handles API connectivity close to request_commit().
- Find the existing get_staged_diff() function in commitcraft.py.
- Replace that entire function with this version:
def get_staged_diff():
try:
inside_work_tree = run_git(
"rev-parse", "--is-inside-work-tree"
).strip()
except FileNotFoundError as error:
raise RuntimeError("Git is not installed or is not on PATH.") from error
except subprocess.CalledProcessError as error:
raise RuntimeError("Run CommitCraft inside a Git repository.") from error
if inside_work_tree != "true":
raise RuntimeError("Run CommitCraft inside a Git work tree.")
diff = run_git("diff", "--staged", "--")
if not diff.strip():
raise RuntimeError(
"No staged changes found. Stage a file with git add and try again."
)
return diff[:MAX_DIFF_CHARS]
How are Git failures handled?
- The try block checks whether the current location belongs to a Git repository.
- A FileNotFoundError becomes a direct instruction to install Git or fix its path.
- A failed repository check becomes a direct instruction to run CommitCraft inside a repository.
- The existing empty-diff check tells the user to stage a file before trying again.
- Save commitcraft.py.
- Verify that staged-diff reading still works by running:
python3 commitcraft.py
What does this regression check cover?
This run exercises the successful Git work-tree check and the staged-diff lookup. A suggestion confirms that the new exception handling preserves the working path.
You should see the familiar Suggestion: and Why: lines. The Git checks now have concise failure paths as well as a working success path.
Seeing a Git error?
- Confirm that your terminal remains inside the Git repository containing commitcraft.py.
- Confirm that commitcraft.py still has staged changes.
- Check the indentation beneath both except clauses in get_staged_diff().
Need another pair of eyes? Help me identify why CommitCraft cannot read my staged Git diff.
- Find def request_commit(model, diff): in commitcraft.py.
- Select that function through its current return line.
- Replace the selection with the first part of the updated function:
def request_commit(model, diff):
payload = {
"model": model,
"messages": [
{
"role": "user",
"content": build_prompt(diff),
}
],
"stream": False,
"format": COMMIT_SCHEMA,
"options": {"temperature": 0},
}
request = Request(
API_URL,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
What does this part preserve?
- The payload still sends the selected model and staged-diff prompt to Ollama.
- The schema remains in the format field for predictable output.
- The temperature remains at 0 for more deterministic structured responses.
- Paste the error-handling portion directly below the closing parenthesis of Request().
try:
with urlopen(request, timeout=120) as response:
response_payload = json.loads(
response.read().decode("utf-8")
)
except URLError as error:
raise RuntimeError(
"Cannot reach Ollama. Start Ollama and try again."
) from error
return json.loads(response_payload["message"]["content"])
How is the API failure translated?
- The urlopen() call keeps the existing timeout while it waits for the local response.
- A URLError becomes a short instruction to start Ollama.
- The final line still parses the structured JSON stored inside message.content.
- Save commitcraft.py.
- Confirm that the completed request function still reaches Ollama by running:
python3 commitcraft.py
What does this API check prove?
A successful suggestion confirms that the request still reaches the local model. The response also passes through both JSON parsing layers before CommitCraft formats it.
You should see a structured commit suggestion without a traceback. Connection failures, timeouts, invalid JSON, missing response keys, and Git command failures now reach the concise error boundary in main().
Cannot reach the local model?
- Confirm that Ollama is still running locally.
- Check that the try block remains inside request_commit().
- Confirm that return json.loads(response_payload["message"]["content"]) remains after the exception handler.
If the request still fails, help me troubleshoot CommitCraft's connection to the local Ollama chat API.
Verify the suggestion-only workflow
CommitCraft reads the staging area to prepare advice for the developer. The final commit decision always stays with the person reviewing the suggestion.
- Find every run_git( call in commitcraft.py.
- Confirm that those calls use only rev-parse and diff.
CommitCraft never passes commit to Git. Its only output is a reviewable message in your terminal.
- Stage the finished version of commitcraft.py by running:
git add commitcraft.py
What does staging prove?
This updates the Git index with the finished script. CommitCraft now reads the exact CLI code you completed in this step.
Before you run the final check, do you expect CommitCraft to create a commit or print a suggestion for you to review?
- Run the finished CLI against its staged changes:
python3 commitcraft.py
What does the final run verify?
The command reads the staged script and requests a schema-constrained response from the local model. It formats that response without calling git commit.
You should see Suggestion: followed by a Conventional Commit header. The next line should begin with Why:.
That's the reusable CLI complete. It offers help, supports a selectable model, protects users from common tracebacks, and leaves every commit decision in their hands.
✔️ Awesome, I've got everything!
Your finished commitcraft.py is saved and staged. Keep Ollama running whenever you want CommitCraft to generate a suggestion.
ⓧ I'd like to double check the full code
Compare your commitcraft.py with this complete version:
#!/usr/bin/env python3
import argparse
import json
import subprocess
import sys
from urllib.error import URLError
from urllib.request import Request, urlopen
API_URL = "http://localhost:11434/api/chat"
DEFAULT_MODEL = "gemma3:1b"
MAX_DIFF_CHARS = 12000
COMMIT_SCHEMA = {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"feat",
"fix",
"docs",
"style",
"refactor",
"perf",
"test",
"build",
"ci",
"chore",
"revert",
],
},
"scope": {"type": "string"},
"description": {"type": "string"},
"reason": {"type": "string"},
},
"required": ["type", "scope", "description", "reason"],
}
def run_git(*args):
result = subprocess.run(
["git", *args],
capture_output=True,
text=True,
check=True,
)
return result.stdout
def get_staged_diff():
try:
inside_work_tree = run_git(
"rev-parse", "--is-inside-work-tree"
).strip()
except FileNotFoundError as error:
raise RuntimeError("Git is not installed or is not on PATH.") from error
except subprocess.CalledProcessError as error:
raise RuntimeError("Run CommitCraft inside a Git repository.") from error
if inside_work_tree != "true":
raise RuntimeError("Run CommitCraft inside a Git work tree.")
diff = run_git("diff", "--staged", "--")
if not diff.strip():
raise RuntimeError(
"No staged changes found. Stage a file with git add and try again."
)
return diff[:MAX_DIFF_CHARS]
def build_prompt(diff):
schema_text = json.dumps(COMMIT_SCHEMA)
return (
"Suggest one Conventional Commit for the staged Git diff below. "
"Use an empty scope when no clear scope exists. Keep the description "
"short and imperative. Explain the choice in one short sentence. "
"Return only JSON matching this schema: "
f"{schema_text}\n\nSTAGED DIFF:\n{diff}"
)
def request_commit(model, diff):
payload = {
"model": model,
"messages": [
{
"role": "user",
"content": build_prompt(diff),
}
],
"stream": False,
"format": COMMIT_SCHEMA,
"options": {"temperature": 0},
}
request = Request(
API_URL,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urlopen(request, timeout=120) as response:
response_payload = json.loads(
response.read().decode("utf-8")
)
except URLError as error:
raise RuntimeError(
"Cannot reach Ollama. Start Ollama and try again."
) from error
return json.loads(response_payload["message"]["content"])
def format_header(commit_data):
commit_type = commit_data["type"].strip()
scope = commit_data["scope"].strip()
description = commit_data["description"].strip()
if scope:
return f"{commit_type}({scope}): {description}"
return f"{commit_type}: {description}"
def parse_args():
parser = argparse.ArgumentParser(
description="Suggest a Conventional Commit from staged Git changes."
)
parser.add_argument(
"--model",
default=DEFAULT_MODEL,
help="Ollama model to use (default: %(default)s)",
)
return parser.parse_args()
def main():
args = parse_args()
try:
diff = get_staged_diff()
commit_data = request_commit(args.model, diff)
header = format_header(commit_data)
reason = commit_data["reason"].strip()
except (
RuntimeError,
subprocess.CalledProcessError,
KeyError,
json.JSONDecodeError,
TimeoutError,
) as error:
print(f"CommitCraft error: {error}", file=sys.stderr)
return 1
print(f"Suggestion: {header}")
print(f"Why: {reason}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
What should the complete file contain?
The finished file combines argument parsing, staged Git inspection, structured Ollama output, commit formatting, and defensive error handling. Its final entry point returns an exit status through SystemExit.
Secret mission
Add Concise and Detailed Styles
CommitCraft currently uses one fixed presentation for every suggestion. Add a --style option that keeps concise suggestions compact or includes a short explanatory body for detailed suggestions.
Clean Up Your Resources
Clean Up Your Resources
CommitCraft runs locally with no API charges or ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Local Python file commitcraft.py.
- Local Git repository state for CommitCraft.
- Ollama application that provides the local server.
- Local gemma3:1b model stored in ~/.ollama with Ollama configuration.
Keep everything running
No action needed. Choose this if you plan to keep using CommitCraft or other local models.
- Keep commitcraft.py in the local Git repository.
- Leave Ollama running so CommitCraft can reach the local server.
- Keep gemma3:1b available for future suggestions.
Pause - I'll come back to this later
Shut down the local server to free up memory. Your script plus downloaded model remain ready for another session.
- Quit the Ollama application to stop its local server.
- Leave the local Git repository untouched.
- Leave ~/.ollama untouched so gemma3:1b stays downloaded.
- Start Ollama again from Terminal when you return.
Delete - I don't want to use this again
Removing these resources is permanent. Use this option when you want a full reset on your Mac.
- Quit the Ollama application before removing its local files.
- Confirm the terminal prompt points to the local repository that contains commitcraft.py.
- Remove commitcraft.py plus the local Git repository state by running:
rm -rf .git commitcraft.py
What does this remove?
The command deletes commitcraft.py. It also removes the local Git metadata from the same folder.
Any unrelated files in that folder remain in place.
Before You Remove Ollama
Full removal erases every local model. It also clears Ollama configuration.
The first two commands use sudo. macOS may request your administrator password.
- Remove Ollama completely by running the official macOS removal commands:
sudo rm -rf /Applications/Ollama.app
sudo rm /usr/local/bin/ollama
rm -rf "~/Library/Application Support/Ollama"
rm -rf "~/Library/Saved Application State/com.electron.ollama.savedState"
rm -rf ~/Library/Caches/com.electron.ollama/
rm -rf ~/Library/Caches/ollama
rm -rf ~/Library/WebKit/com.electron.ollama
rm -rf ~/.ollama
What Do These Commands Remove?
The first command deletes the Ollama application. The second deletes the CLI binary.
The remaining commands clear local support files plus caches. The final command removes every downloaded model and all Ollama configuration.
That is the full reset done. CommitCraft's local AI runtime is removed from your Mac.
Nice Work!
Nice Work!
You did it! Your Python CLI now turns staged Git changes into reviewable commit suggestions. Its suggestion-only flow calls Ollama over local HTTP while leaving every commit decision with you.
You've learned how to:
- Built a staged-diff workflow that reads only changes prepared for the next commit through safe subprocess argument lists.
- Constrained local model output with a JSON schema. Formatted each result as a Conventional Commit header with a separate reason.
- Added generated help text with a configurable model option. Turned common Git or Ollama failures into friendly error messages. Preserved a suggestion-only workflow that never executes a commit.
- Completed the optional Secret Mission by adding concise and detailed output styles through --style.
Ready to quiz yourself?