Mac 下用 Git 提交自动生成 Obsidian 工作日报

21

手头要维护的 Git 仓库一多,写日报就变成一件「想起来才补、补了也不全」的事。我之前的做法是:在每个仓库里放 .githooks/post-commit,提交后往 ~/git-work-log/raw.jsonl 里追加一行 JSON,再手动跑脚本生成 Markdown。

能用,但麻烦——新项目要记得配 core.hooksPath,嵌套子仓库容易漏,commit message 里带引号时还可能写坏日志,Obsidian 里也只有 commit 标题、没有正文。

后来改成 「只扫固定工作区 + 白名单 + 中文项目名 + 定时写入 Obsidian」,才算稳定下来。这篇文章把整套流程与关键代码记下来,方便以后换机器或同事复用。


要解决什么问题

旧方案的问题新方案的做法
每个仓库都要装 hook定时扫描 ~/Projects/work 下所有 Git 仓库
大量历史仓库全记,噪音大白名单只纳入当前迭代涉及的项目
日报里显示 catalog-apirepo_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

数据流分三层:

  1. ~/git-work-log/raw.jsonl:全量 commit 日志(JSON Lines,按 hash 去重)
  2. ~/git-work-log/state.json:每个仓库上次同步到的 HEAD,增量拉取
  3. Obsidiandaily/日常工作/每日工作项/{年}/{月}/{日期}.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-log

launchd 定时任务

~/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 synccollect + 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 out

4. 幂等更新 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: 30

repo_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 synccollect + 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,迁移昨日待办。

分工建议:

若只需标题、不要正文:report.include_commit_body: false


从 per-repo githooks 迁移

  1. 部署 git_work_log.py、配置文件与 git-work-log 命令
  2. git-work-log collect --since 2026-05-01report 验证 Obsidian 区块
  3. 加载 launchd
  4. 各仓库逐步移除 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_pathwork_daily.dir 改为自己的路径即可复用全文流程。



下一篇
为了使用微信读书,我把Kindle(kpw3)刷成了安卓