本日核心價值 (Core Focus): 用
git diff --staged產生 Conventional Commits 訊息、用git log vX..HEAD彙整 Release Notes,全程經 subprocess 呼叫 Git;預設只印出結果,沒有--yes絕不git commit,也絕不自動push。
概念說明與實戰情境 (Overview)
RAG 解決的是文件怎麼找;Git 解決的是變更怎麼被以後的人讀懂。Commit message 寫得亂,Day 17 的 CI 摘要、Changelog、on-call 追溯都會一起爛。讓模型讀 staged diff、輸出 feat / fix / docs 這類 Conventional Commits,比請它「寫得專業一點」可靠。Release Notes 則不該再讀一次整份 diff,而該讀已經審查過的 commit 標題,依類型分組。這兩件事都是生成工作流,不是把憑證交給 bot 去推遠端。預設行為必須是 dry-run:印出訊息,提交與推送留給人類。
關鍵操作與範例 (Implementation & Example)
1. Prompt:只看 staged diff,輸出單一標題
未 staged 的工作區雜訊不要進模型。訊息格式鎖定 Conventional Commits,避免模型寫成散文。主旨用祈使句、不超過 72 字元、句尾不加句點;scope 只取 diff 裡真實出現的模組名。需要內文時另開第二段,說明「為什麼改」而不是重述 diff。範例(人寫或模型寫都應長這樣):
feat(rag): refuse answers below similarity threshold
fix(api): retry OpenAI timeouts with jitter
docs(ironman): add day 23 chroma retrieval workflow
chore(ci): cache pip in github actions
SYSTEM:
You write a single Conventional Commit header from a staged git diff.
Format: type(scope): subject
Types: feat, fix, docs, refactor, test, chore, perf, ci.
Subject: imperative, <= 72 chars, no trailing period, Traditional Chinese or English matching the diff comments.
Do not invent files that are not in the diff.
If the diff is empty, reply EMPTY.
Return only the header line, no markdown.
USER:
Staged diff:
2. 腳本:subprocess 包 Git,--yes 才 commit,永不 push
from __future__ import annotations
import argparse
import os
import subprocess
from collections import defaultdict
from pathlib import Path
from openai import OpenAI
COMMIT_TYPES = ("feat", "fix", "docs", "refactor", "test", "chore", "perf", "ci")
def git(*args: str, cwd: Path) -> str:
proc = subprocess.run(
["git", *args],
cwd=cwd,
check=True,
capture_output=True,
text=True,
encoding="utf-8",
)
return proc.stdout
def staged_diff(repo: Path) -> str:
return git("diff", "--staged", cwd=repo)
def propose_commit_message(diff: str) -> str:
if not diff.strip():
raise SystemExit("No staged changes. git add first; refusing to guess.")
client = OpenAI()
resp = client.chat.completions.create(
model=os.environ.get("CHAT_MODEL", "gpt-4.1-mini"),
temperature=0,
messages=[
{
"role": "system",
"content": (
"You write one Conventional Commit header from a staged git diff. "
"Format: type(scope): subject. Types: feat, fix, docs, refactor, test, chore, perf, ci. "
"Return only the header line."
),
},
{"role": "user", "content": f"Staged diff:\n{diff[:20000]}"},
],
)
line = (resp.choices[0].message.content or "").strip().splitlines()[0]
if not any(line.startswith(f"{t}") or line.startswith(f"{t}(") for t in COMMIT_TYPES):
raise SystemExit(f"Model output is not a conventional commit: {line!r}")
return line
def maybe_commit(repo: Path, message: str, yes: bool) -> None:
print(message)
if not yes:
print("Dry-run: pass --yes to run git commit. Push is never performed.")
return
subprocess.run(
["git", "commit", "-m", message],
cwd=repo,
check=True,
)
def release_notes(repo: Path, since_ref: str) -> str:
log = git("log", f"{since_ref}..HEAD", "--pretty=format:%s", cwd=repo)
grouped: dict[str, list[str]] = defaultdict(list)
other: list[str] = []
for raw in log.splitlines():
line = raw.strip()
if not line:
continue
kind = next((t for t in COMMIT_TYPES if line.startswith(t)), None)
if kind in {"feat", "fix"}:
grouped[kind].append(line)
else:
other.append(line)
parts = [f"## Changes since {since_ref}", ""]
for title, key in (("Features", "feat"), ("Fixes", "fix")):
parts.append(f"### {title}")
items = grouped.get(key)
if items:
parts.extend(f"- {x}" for x in items)
else:
parts.append("- (none)")
parts.append("")
if other:
parts.append("### Other")
parts.extend(f"- {x}" for x in other)
return "\n".join(parts).strip() + "\n"
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Propose commit message or release notes.")
parser.add_argument("--repo", type=Path, default=Path.cwd())
sub = parser.add_subparsers(dest="cmd", required=True)
c = sub.add_parser("commit-msg")
c.add_argument("--yes", action="store_true", help="Actually run git commit. Never pushes.")
r = sub.add_parser("release-notes")
r.add_argument("--since", required=True, help="Git ref, e.g. v1.4.0")
args = parser.parse_args(argv)
if args.cmd == "commit-msg":
diff = staged_diff(args.repo)
msg = propose_commit_message(diff)
maybe_commit(args.repo, msg, yes=args.yes)
return 0
notes = release_notes(args.repo, args.since)
print(notes)
return 0
if __name__ == "__main__":
raise SystemExit(main())
使用方式:
python git_ai.py commit-msg # 只印訊息
python git_ai.py commit-msg --yes # 才 git commit
python git_ai.py release-notes --since v1.4.0
第三個指令只讀 git log,不 bump tag、不打包、不 push。Release Notes 的輸入是已存在歷史,模型若要潤飾,也應只改分組與條列,不得新增 log 裡沒有的功能。把建議訊息交給模型之後,人仍要看一眼:scope 是否真的出現在 diff、type 是否誇大(把重構寫成 feat)、是否誤把 lockfile 或產生檔當主旨。通過才加 --yes。送進模型的 diff 可先過濾 package-lock.json、*.min.js 這類噪音,否則 Token 成本會被鎖定檔吃掉(Day 22),訊息也會變成「update lockfile」。空的 staged diff 直接結束,不要請模型「猜今天可能改了什麼」。
3. Release Notes 分組後的輸出形狀
假設 git log v1.4.0..HEAD --pretty=format:%s 得到上列範例訊息,腳本應產出類似:
## Changes since v1.4.0
### Features
- feat(rag): refuse answers below similarity threshold
### Fixes
- fix(api): retry OpenAI timeouts with jitter
### Other
- docs(ironman): add day 23 chroma retrieval workflow
- chore(ci): cache pip in github actions
產品說明可再請模型把 feat / fix 翻成給使用者看的句子,但原始 header 要保留在附錄,方便對回 SHA。可選的 Git hook(prepare-commit-msg)只負責「把建議訊息印到編輯器」,仍不要在 hook 裡呼叫 git commit 或 git push。Hook 拿不到 --yes 這種人類確認時,預設應失敗或只填註解,避免 CI runner、GUI client 在背景自動提交。
範圍也要限縮:一次 staged 變更若同時包含 feat 與 fix,腳本應拒絕並要求拆 commit,而不是讓模型硬塞一個含糊的 chore。這比事後改寫歷史便宜,也讓 Release Notes 的分組統計有意義。團隊若已用 squash merge,Release Notes 應讀 PR 標題或 squash 後的單筆訊息,而不是 feature branch 上數十筆 WIP;來源選錯,分組再漂亮也只是把雜訊分類。訊息品質會直接餵給明天的排程摘要:n8n / Make 若每日彙整 feat / fix,標題含糊就會讓營運 digest 無法掃描。
這條工作流的價值不在「少打幾個字」,而在讓之後的自動化有穩定輸入。標題寫清楚,Day 17 的 CI 摘要、本篇的 Release Notes、明天的排程 digest 才能用同一套類型去過濾,不必再請模型猜「這次到底修了什麼」。相對地,把產生器接到 git add -A 或遠端推送,等於把不可信的模型輸出直接寫進歷史;因此確認鍵只有 --yes,而且確認的是「這行字可以進 repo」,不是「模型說可以就全部做完」。本機審查的最低清單是:沒有秘密檔、type 沒有誇大、scope 對得回 diff、遠端仍由人推送。若 diff 同時改測試與產品碼,主旨仍應寫行為變化(例如修逾時),測試檔不必寫進 scope,以免 Release Notes 變成「更新測試」而讀不出使用者影響。
注意事項與常見失敗 (Pitfalls)
git commit 甚至 git push。修法:預設 dry-run;--yes 只開放 commit;push 不出現在程式裡。git diff(含未 staged)整份丟進模型。修法:只用 git diff --staged;過長截斷並在訊息中聲明「僅依據前 N 字元」。.env。修法:commit 前用既有 hook / git status 人工確認;模型不得「幫忙 add 所有檔案」。vX..HEAD。修法:只讀已提交歷史;未發布變更不算進該版說明。本日總結 (Takeaways)
feat(scope): … / fix(scope): …。主旨對齊 diff,不要寫成散文。--yes 執行 git commit。推送永遠留在人工指令,腳本裡不要出現 git push。git log vX..HEAD 既有標題,按 feat / fix 分組;沒出現在 log 的功能不准編造。明日預告 (Next)
Commit 與 Release Notes 仍是「有人執行腳本才跑」。要把摘要、巡檢、通知變成每日營運,下一步接排程:Day 25 將做自動化排程工作流:結合 Make / n8n 與 OpenAI API 實現日常營運自動化。