手头要维护的 Git 仓库一多,写日报就变成一件「想起来才补、补了也不全」的事。我之前的做法是:在每个仓库里放 .githooks/post-commit,提交后往 ~/git-work-log/raw.jsonl 里追加一行 JSON,再手动跑脚本生成 Markdown。
能用,但麻烦——新项目要记得配 core.hooksPath,嵌套子仓库容易漏,commit message 里带引号时还可能写坏日志,Obsidian 里也只有 commit 标题、没有正文。
后来改成 「只扫固定工作区 + 白名单 + 中文项目名 + 定时写入 Obsidian」,才算稳定下来。这篇文章把整套流程与关键代码记下来,方便以后换机器或同事复用。
要解决什么问题
| 旧方案的问题 | 新方案的做法 |
|---|---|
| 每个仓库都要装 hook | 定时扫描 ~/Projects/work 下所有 Git 仓库 |
| 大量历史仓库全记,噪音大 | 白名单只纳入当前迭代涉及的项目 |
日报里显示 catalog-api | repo_aliases.yaml 映射成「商品目录 API」等中文名 |
| Obsidian 只有 commit 第一行 | 输出 分支 + 正文条目(Conventional Commits 的 body) |
| 与 husky 等 hook 冲突 | 不改动各仓库 hook,全局脚本独立运行 |
目标很明确:Git 只负责真实提交记录,Obsidian 的 daily/日常工作/每日工作项/ 负责给人看的日报底稿。
整体架构
flowchart LR
subgraph scan [采集 collect]
R[工作区下 Git 仓库]
F[白名单 repo_filter]
A[raw.jsonl + state.json]
end
subgraph report [汇总 report]
M[按日期过滤]
C[中文别名]
O[Obsidian 工作项 md]
end
R --> F --> A --> M --> C --> O数据流分三层:
~/git-work-log/raw.jsonl:全量 commit 日志(JSON Lines,按 hash 去重)~/git-work-log/state.json:每个仓库上次同步到的HEAD,增量拉取- Obsidian:
daily/日常工作/每日工作项/{年}/{月}/{日期}.md里的## Git 提交(自动)区块
手写的「今日完成」 checklist 仍在 ## 今日完成 里;自动区块用 HTML 注释包起来,重复 report 时只替换注释中间的内容,不会冲掉手写内容。
目录与安装
工具放在用户目录,不进业务仓库:
~/.config/git-work-log/
config.yaml
repo_aliases.yaml
git_work_log.py
~/.local/bin/git-work-log
~/git-work-log/
raw.jsonl
state.json
launchd.log
~/Library/LaunchAgents/com.example.git-work-log.plist命令入口
~/.local/bin/git-work-log:
#!/usr/bin/env bash
set -euo pipefail
exec python3 "$HOME/.config/git-work-log/git_work_log.py" "$@"安装后赋予执行权限:
chmod +x ~/.local/bin/git-work-loglaunchd 定时任务
~/Library/LaunchAgents/com.example.git-work-log.plist(将 YOUR_USER 换成本机用户名,或改用 $HOME 展开后的绝对路径):
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.example.git-work-log</string>
<key>ProgramArguments</key>
<array>
<string>/Users/YOUR_USER/.local/bin/git-work-log</string>
<string>sync</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>StartInterval</key>
<integer>1800</integer>
<key>StartCalendarInterval</key>
<array>
<dict>
<key>Hour</key>
<integer>18</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
</array>
<key>StandardOutPath</key>
<string>/Users/YOUR_USER/git-work-log/launchd.log</string>
<key>StandardErrorPath</key>
<string>/Users/YOUR_USER/git-work-log/launchd.err.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
</dict>
</dict>
</plist>加载:
launchctl load ~/Library/LaunchAgents/com.example.git-work-log.plist会在 登录、每 30 分钟、每天 18:00 执行 git-work-log sync(collect + report)。
核心代码
主程序为单文件 Python 3:~/.config/git-work-log/git_work_log.py。下面摘录与日报质量直接相关的几段(为阅读方便略有删减)。
1. 用 git log 拉取完整 commit(含正文)
旧 hook 把 message 塞进 shell 字符串再拼 Python,引号、换行容易破坏 JSON。新方案用分隔符一次读出 subject 与 body:
def parse_git_log(repo: Path, rev_range: str) -> list[dict[str, str]]:
fmt = "%H%x1f%h%x1f%ai%x1f%s%x1f%b%x1e"
out = run_git(repo, "log", rev_range, f"--format={fmt}", "--reverse")
if not out:
return []
entries = []
for record in out.split("\x1e"):
record = record.strip("\n")
if not record:
continue
parts = record.split("\x1f", 4)
if len(parts) < 4:
continue
full_hash, short_hash, author_date, subject = parts[:4]
body = parts[4].rstrip("\n") if len(parts) > 4 else ""
message = subject
if body.strip():
message = f"{subject}\n\n{body.strip()}"
branch = run_git(
repo, "branch", "--contains", full_hash,
"--format=%(refname:short)",
)
entries.append({
"time": format_author_time(author_date),
"repo": repo.name,
"branch": branch.splitlines()[0] if branch else "HEAD",
"commit": short_hash,
"commit_full": full_hash,
"message": message,
})
return entries写入 raw.jsonl 时使用标准库序列化,避免手工转义:
def append_raw(entries: list[dict], known: set[str]) -> int:
added = 0
with RAW_LOG.open("a", encoding="utf-8") as f:
for e in entries:
key = e.get("commit_full") or e["commit"]
if key in known:
continue
row = {
"time": e["time"],
"repo": e["repo"],
"repo_rel": e.get("repo_rel"),
"branch": e["branch"],
"commit": e["commit"],
"message": e["message"],
}
f.write(json.dumps(row, ensure_ascii=False) + "\n")
known.add(key)
added += 1
return added单条 raw.jsonl 示例(对应下文「正经」提交说明):
{
"time": "2026-05-27 14:32:18",
"repo": "catalog-api",
"repo_rel": "acme-stack/catalog-api",
"branch": "release/2.4",
"commit": "a1b2c3d",
"message": "feat(api): 为商品列表接口增加游标分页\n\n- 新增 CursorPageRequest 与 nextCursor 响应字段\n- CatalogService 查询改为 keyset 分页,避免深分页性能问题\n- 补充集成测试覆盖空结果与末页边界"
}2. 增量同步:state.json
每个仓库记录上次同步到的 HEAD,下次只拉 last_hash..HEAD:
if since:
rev_range = f"--since={since}"
elif last_hash and run_git(repo, "cat-file", "-e", f"{last_hash}^{{commit}}"):
rev_range = f"{last_hash}..HEAD"
else:
rev_range = f"--since={cfg.get('initial_since_days', 30)}.days.ago"
entries = parse_git_log(repo, rev_range)
for e in entries:
e["repo_rel"] = repo_relative_path(repo, cfg["scan_roots"])
append_raw(entries, known)
state["repos"][repo_key] = {
"last_hash": run_git(repo, "rev-parse", "HEAD"),
"last_sync": datetime.now().isoformat(timespec="seconds"),
}3. 渲染 Obsidian:标题 + 分支 + 正文条目
def format_commit_lines(item: dict, cfg: dict) -> list[str]:
opts = cfg.get("report", {})
t = item["time"].split()[1]
msg_lines = item.get("message", "").splitlines()
subject = msg_lines[0] if msg_lines else "(no message)"
out = [f"- {t} `{item['commit']}` {subject}"]
if opts.get("include_branch", True):
out.append(f" - 分支: `{item.get('branch', '')}`")
if opts.get("include_commit_body", True) and len(msg_lines) > 1:
for line in "\n".join(msg_lines[1:]).strip().splitlines():
line = line.strip()
if not line:
continue
if opts.get("strip_co_authored") and line.lower().startswith("co-authored-by:"):
continue
out.append(f" {line}" if line.startswith("- ") else f" - {line}")
return out4. 幂等更新 Obsidian 区块
用 HTML 注释标记自动区块,report 多次执行只替换注释内内容:
SECTION_TITLE = "## Git 提交(自动)"
def upsert_git_section(content: str, section_block: str, markers: dict) -> str:
start, end = markers["start"], markers["end"]
section = f"{SECTION_TITLE}\n\n{section_block}"
pattern = re.compile(
rf"{re.escape(SECTION_TITLE)}\s*\n+{re.escape(start)}.*?{re.escape(end)}",
re.DOTALL,
)
if pattern.search(content):
return pattern.sub(section, content, count=1)
if "## 备注" in content:
idx = content.index("## 备注")
return content[:idx].rstrip() + "\n\n" + section + "\n\n" + content[idx:]
return content.rstrip() + "\n\n" + section + "\n"旧方案为何容易坏:post-commit 反例
每个仓库维护一份 hook 时,常见写法类似:
#!/usr/bin/env bash
COMMIT_MSG=$($GIT log -1 --pretty=%B)
python3 <<EOF >> "$RAW_LOG"
import json
data = {
"message": """$COMMIT_MSG"""
}
print(json.dumps(data, ensure_ascii=False))
EOF当 commit body 含 "、\ 或 Here-Doc 结束符时,JSON 会损坏。全局采集方案不再依赖 per-repo hook,从根上规避这类问题。
配置文件
config.yaml(完整示例)
下列项目名为示例,请按实际仓库替换:
vault_path: "/Users/YOUR_USER/Library/Mobile Documents/iCloud~md~obsidian/Documents/Obsidian Vault"
work_daily:
dir: "daily/日常工作/每日工作项"
scan_roots:
- ~/Projects/work
ignore_dir_names:
- node_modules
- target
- build
- dist
max_depth: 6
repo_filter:
mode: allowlist
include_repos:
- user-portal # 一级:用户门户
- billing-service # 一级:计费服务
include_paths:
- acme-stack/catalog-api # monorepo 子仓库
- acme-stack/notify-worker
repo_aliases_file: repo_aliases.yaml
markers:
start: "<!-- git-work-log:start -->"
end: "<!-- git-work-log:end -->"
report:
include_branch: true
include_commit_body: true
strip_co_authored: true
initial_since_days: 30repo_aliases.yaml(英文名 → 日报中文名)
user-portal: 用户门户
billing-service: 计费服务
acme-stack/catalog-api: 商品目录 API
acme-stack/notify-worker: 消息投递 Worker
catalog-api: 商品目录 API
notify-worker: 消息投递 Worker子仓库建议同时写 相对路径 与 目录名,查找时优先匹配路径。
生成效果示例(正式提交信息)
假设当日在「商品目录 API」有若干符合 Conventional Commits 的提交,Obsidian 中自动区块类似:
## Git 提交(自动)
<!-- git-work-log:start -->
**商品目录 API**
- 14:32:18 `a1b2c3d` feat(api): 为商品列表接口增加游标分页
- 分支: `release/2.4`
- 新增 CursorPageRequest 与 nextCursor 响应字段
- CatalogService 查询改为 keyset 分页
- 补充集成测试覆盖空结果与末页边界
**用户门户**
- 11:05:42 `e4f5a6b` fix(cache): 修正会话缓存 TTL 与击穿保护
- 分支: `develop`
- 将未配置 Redis 时的默认过期时间改为 30 分钟
- 为热点 key 增加 singleflight 防击穿
**消息投递 Worker**
- 09:18:03 `c7d8e9f` refactor(queue): 统一重试退避与死信队列命名
- 分支: `main`
- 抽取 RetryPolicy,消除各消费者重复实现
- 增加超过最大重试次数时的死信归档测试
<!-- git-work-log:end -->建议在团队中约定 commit 规范(如 Conventional Commits),正文用 - 列表写变更点,日报几乎可以直接复用。
常用命令
| 命令 | 说明 |
|---|---|
git-work-log collect | 扫描白名单仓库,增量写入 raw.jsonl |
git-work-log report | 更新今日 Obsidian 工作项 |
git-work-log report 2026-05-27 | 更新指定日期 |
git-work-log sync | collect + report |
git-work-log doctor | 检查 vault、纳入仓库数、launchd |
git-work-log repos | 白名单与中文名对照 |
git-work-log aliases --check | 检查未配置中文名的仓库 |
补历史:
git-work-log collect --since 2026-05-01
git-work-log report 2026-05-01与 Obsidian 每日工作流配合
vault 中可配合「每日工作项」模板或技能:早上创建 daily/日常工作/每日工作项/{年}/{月}/{日期}.md,迁移昨日待办。
分工建议:
- 早上:建模板,填写
## 今日待办 - 白天:按规范提交 Git,无需配置 hook
- 定时 / 下班前:
sync刷新## Git 提交(自动) - 写周报:按日期打开工作项,将 Git 区块整理进
## 今日完成
若只需标题、不要正文:report.include_commit_body: false。
从 per-repo githooks 迁移
- 部署
git_work_log.py、配置文件与git-work-log命令 git-work-log collect --since 2026-05-01与report验证 Obsidian 区块- 加载 launchd
- 各仓库逐步移除
git config core.hooksPath .githooks
故障排查
| 现象 | 处理 |
|---|---|
| doctor 显示「纳入日报: 0」 | 检查 include_repos / include_paths 是否包含该仓库 |
| Obsidian 显示英文目录名 | 在 repo_aliases.yaml 补中文映射 |
| 今日区块为「无 Git 提交」 | 当日无提交,或提交所在仓库不在白名单 |
| 同 commit 出现两次 | 历史 hook 与 collect 重复写入;report 已按短 hash 去重 |
| launchd 未执行 | launchctl list | grep git-work-log,查 ~/git-work-log/launchd.log |
小结
把「记录提交」收拢到一台 Mac 上的定时任务:白名单控制记哪些项目,别名表控制日报可读性,Obsidian 工作项承接最终文稿。
新增迭代项目时,通常只需在 config.yaml 加一行路径、在 repo_aliases.yaml 加一行中文名。写日报与周报时,打开当天笔记即可,不必再逐个仓库翻 git log。
若使用 Obsidian 管理日常工作项,将 vault_path 与 work_daily.dir 改为自己的路径即可复用全文流程。