🔀
jjstats: Jujutsu リポジトリの履歴を可視化する macOS アプリを作った
jjstats とは
Jujutsu (jj) というバージョン管理システム、だーいぶ前に流れてきたときはよさそうだけどめんどそう〜と思ってスルーしてたけど、また最近なんかよく見る気がしていまなら AI 経由で楽にいけそう〜と思って使い始めました。git ももはやすべて Claude Code や Cursor に指示をだすだけなので Tower は履歴とか diff とか見るためだけに使ってる感じだけどそれなりにリポジトリの内容を把握するのにはわかりやすく便利なので、jj もそういうのないかなーと探してみたものの見つからなかったので Claude Code に作ってもらいました。

コミット一覧、変更ファイル、インライン diff が見れる
機能
- コミット履歴の一覧表示 - サイドバーにコミット一覧を表示
- 変更ファイルの確認 - 各コミットで変更されたファイル一覧
- インライン diff ビュー - GitHub スタイルの行単位 diff 表示
- Git タグ表示 - コミット一覧にタグを表示
- 自動リフレッシュ - FSEvents でリポジトリの変更を監視して自動更新
- 署名ステータス表示 - コミットの署名検証結果を表示(GPG / SSH 両対応)
技術スタック
- Swift 6.0 / SwiftUI
- macOS 14.0+
- XcodeGen でプロジェクト生成
仕組み
基本的には jj コマンドをラップして出力をパースしているだけ。jj はテンプレート機能が充実していて、出力フォーマットをかなり自由にカスタマイズできるので、パースしやすい形式で出力させている。
private static let logTemplate = """
commit_id ++ "\\x00" ++ change_id ++ "\\x00" ++
description.first_line() ++ "\\x00" ++
author.name() ++ "\\x00" ++
author.email() ++ "\\x00" ++
committer.timestamp().utc().format("%Y-%m-%dT%H:%M:%SZ") ++ "\\x00" ++
if(current_working_copy, "true", "false") ++ "\\x00" ++
local_bookmarks ++ " " ++ remote_bookmarks ++ "\\x00" ++
tags ++ "\\x00" ++
if(signature, signature.status(), "") ++ "\\x00" ++
parents.map(|c| c.commit_id()).join(",") ++ "\\x1e"
"""
フィールドの区切りに \x00 (NULL)、レコードの区切りに \x1e (RS: Record Separator) を使っていて、これでパースが楽になる。
FSEvents でファイル監視
リポジトリの変更を検知するために FSEvents を使っている。.jj ディレクトリの変更を監視して、変更があったら自動でリフレッシュする仕組み。
private var stream: FSEventStreamRef?
func startWatching(path: String) {
let pathsToWatch = [path] as CFArray
var context = FSEventStreamContext(
version: 0,
info: Unmanaged.passUnretained(self).toOpaque(),
retain: nil,
release: nil,
copyDescription: nil
)
stream = FSEventStreamCreate(
nil,
{ _, info, numEvents, eventPaths, _, _ in
// ファイル変更時のコールバック
},
&context,
pathsToWatch,
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
0.5,
UInt32(kFSEventStreamCreateFlagUseCFTypes | kFSEventStreamCreateFlagFileEvents)
)
}
Git diff フォーマットのパース
diff 表示には jj diff --git の出力をパースしている。Git diff フォーマットは標準的なので、正規表現でハンク情報を取得して行単位の変更を抽出。
let hunkHeaderPattern = try! NSRegularExpression(
pattern: #"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@"#
)
おわりに
思いついてから実用レベルにもってくまで Claude Code で1日。
ディレクションさえ適切にできれば欲しいアプリがちょっぱやで手に入るすごい時代…
Discussion