yui 結 — target-as-truth dotfiles manager

結 — ライブ設定を直接編集すれば、ソースリポジトリが自動で更新される

自作の dotfiles 管理ツール yui (結) を Rust で作りました。長年愛用してきた chezmoi から移行し、現在は自分の dotfiles リポジトリをこの yui で管理しています。

なぜ作ったのか

dotfiles を長年 chezmoi などのツールで管理していて、どうしても拭いきれなかった 3 つの「気になる点」がありました。

  1. 「編集して適用する」という 2 ステップの儀式 (edit-source-then-apply tax)
    • 設定ファイル(例えば target 側の ~/.config/nvim/init.lua など)をちょっといじりたいだけなのに、「リポジトリ内のソースファイルを編集 → chezmoi apply を実行して target 側に適用する」という 2 ステップの儀式が毎回発生し、テンポが悪かった。
  2. ソースとターゲットのドリフト (Source ↔ Target drift)
    • アプリケーションが target 側の設定ファイルを直接上書き・編集した際、ソースリポジトリとの間に乖離(ドリフト)が発生し、次の chezmoi diff で初めて気づく。
  3. 未追跡の新規ファイル (Untracked new files)
    • Neovim やその他のツールが管理対象ディレクトリ内に新しくファイルを作成しても、手動で chezmoi add をしない限り、リポジトリ側では未追跡のまま放置されてしまう。

これらを解決するために、「対象 (target/ホームディレクトリ) を Truth (真実) にする」という逆転の発想で設計したのが yui です。

yui のアプローチ: Target as Truth

yui は、chezmoi の流れを反転させるだけではありません。 yui apply を一度実行して、ソースリポジトリ(source)と target 側のファイルを物理的なリンクで接続してしまえば、それ以降は設定を変更するたびに yui apply を走らせる必要がなくなります。

物理リンクによって実体(inode)を共有しているため、エディタ等による target 側への書き込みが、そのまま自動的かつ即時に source 側の書き込みとして反映されます。

プラットフォーム ファイル ディレクトリ
Linux / macOS シンボリックリンク (symlink) シンボリックリンク (symlink)
Windows (デフォルト) ハードリンク (hardlink) ジャンクション (junction)
Windows (オプトイン) シンボリックリンク (symlink) シンボリックリンク (symlink) (開発者モード/管理者権限が必要)

Windows でのデフォルト設定は意図的なものです。ハードリンクとジャンクションはどちらも管理者権限なしで作成可能で、多くのエディタの「アトミック保存」時にも壊れにくいためです。

このアプローチにより、エディタやアプリから target 側の設定ファイルへ書き込むだけで、リポジトリ側も自動で更新されます。もう「編集した後に apply を実行する」という手間はありません。

リンク接続のイメージ(ファイルツリー)

基本の配置ルールはリポジトリ直下の config.toml で定義します(例:home/ 配下を ~ にマウント)。yui apply を実行すると、その配下のファイルやフォルダが自動的にリンクされます。

OS別に異なるリンク先を設定したい場合や、デフォルトの配置ルールとは異なる特別な場所にリンクしたい場合に、対象ディレクトリ内に .yuilink マーカーを使用します(直接 config.toml に定義することも可能です)。

text
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
[Source リポジトリ側] (~/.dotfiles/)
├── config.toml                     # マウント定義 (例: home/ -> ~/) とフック設定
├── home/                           # 基本的にこの配下が自動でリンクされる
│   ├── .gitconfig.tera             # テンプレートファイル
│   ├── .gitconfig                  # レンダリング結果 (gitignored) ━ [デフォルト: Hardlink/Symlink で自動リンク]
│   └── .config/
│       └── nvim/                   # 基本はそのまま ~/.config/nvim/ に自動マッピングされるが...
│           ├── init.lua
│           └── .yuilink            # 🌟Windows用に別の特別なパスを追加で指定するマーカー
└── .yui/
    ├── backup/                     # 自動バックアップ先
    └── state.json                  # マシンごとの状態管理

            │
            ▼ yui apply 実行 (Linux / macOS の場合)
            │

[Dest システム側 (Unix)] (~)
├── .gitconfig                      # (自動接続) home/.gitconfig とリンク共有
└── .config/
    └── nvim/                       # (自動接続) home/.config/nvim/ とリンク共有
        └── init.lua

            │
            ▼ yui apply 実行 (Windows の場合)
            │

[Dest システム側 (Windows)] (~)
├── .gitconfig                      # (自動接続) home/.gitconfig と物理リンクを共有
├── .config/
│   └── nvim/                       # (自動接続) home/.config/nvim/ と物理リンクを共有
│       └── init.lua
└── AppData/Local/nvim/             # 🌟(.yuilink の指定により、こちらにも追加でリンク共有)
    └── init.lua

アトミック保存対策と AutoAbsorb (吸い込み)

エディタによっては、ファイルを保存する際に「一時ファイルに書き出してから元のファイル名にリネームする」というアトミック保存を行います。これを行うと、せっかく張ったハードリンクやシンボリックリンクが切れてしまいます。

これを解決するために、yui は applystatus を実行した際、ファイルID (inode や file index) の一致をチェックします。リンクが切れてしまっている場合、yui の Absorb Classifier が以下のように状況を判定します。

状態判定 発生条件 処理内容
InSync target の file-id == source の file-id 同期中 (何もしない)
RelinkOnly 内容は同一だが、file-id が異なる 再リンクのみ行う
AutoAbsorb target の方が更新日時が新しく、内容が異なる target(実ファイル)の内容を source(リポジトリ)にコピーし、再リンクする
NeedsConfirm source の方が更新日時が新しく、内容が異なる 競合アノマリーとして確認を求める
Restore target が見つからない リポジトリから復元する

エディタのアトミック保存でハードリンクが切れて内容が変更された場合、それは AutoAbsorb と判定されます。yui は自動的にリポジトリ側のファイルをバックアップした上で、実ファイル(target)の変更内容をリポジトリ(source)に吸い込み、再度リンクを張り直します。

これにより、リンク切れを気にする必要すらなくなります。

特別な配置やOSごとの振り分けを行う .yuilink マーカー

基本的には config.tomlhome/~(ホーム)へマウントする定義を 1 つ書くだけで、配下のディレクトリ(例:home/.config/nvim)も自然にターゲット(~/.config/nvim)へとマッピングされます。

しかし、「OS別にマッピング先を変えたい」、あるいは「デフォルトの配置ルールから外れる特定の場所へ個別リンクしたい」といった場合には、該当するディレクトリに .yuilink というファイルを配置することで、追加のリンクルールを重ねることができます。

1つのソースから複数のターゲットへのリンク振り分け

例えば、Neovim の設定を Unix ではデフォルトの ~/.config/nvim に配置しつつ、Windows では LOCALAPPDATA/nvim にもマッピングしたいとします。その場合、home/.config/nvim/.yuilink に以下のように記述します。

toml
1
2
3
4
# $DOTFILES/home/.config/nvim/.yuilink
[[link]]
dst = "{{ env(name='LOCALAPPDATA') }}/nvim"
when = "yui.os == 'windows'"

これによって、yui apply は環境(OS)の判定を評価し、Windows の場合であれば通常の ~/.config/nvim へのマッピングに加え、追加で AppData/Local/nvim にも物理リンクをマッピングしてくれます。

その他の特徴

  • テンプレート (*.tera) と teravars による柔軟な出し分け

    • Tera テンプレートエンジンをサポートしています。.tera サフィックスの付いたテンプレートファイルはレンダリングされ、出力される実ファイル(.tera サフィックスを落とした実設定ファイル)は自動的に .gitignore の専用セクション(# >>> yui rendered<<< の間)へ追記され、コミット対象から除外されます。 手動で .gitignore を編集する必要がなく、レンダリング後の中間ファイルを誤ってコミットしてしまう心配がありません。
    • yui は内部で teravars ライブラリを採用しており、環境変数やシステムコンテキスト(OS、アーキテクチャ等)に加え、config.toml[vars] テーブルで定義した任意の変数にアクセスできます。
    • さらに、Git管理から除外される config.local.toml を各マシンに配置し、その中で [vars] の上書き定義(オーバーライド)を行うことで、マシンごとの微調整をシームレスに行えます。

    具体例:AutoHotkey の設定切り分け

    自宅の個人PCと、会社の仕事用PCで AutoHotkey (.ahk) のキー割り当てや起動アプリを切り分けたい場合の実例です。

    リポジトリでコミットする config.toml には、共通のデフォルト定義を書いておきます。

    toml
    1
    2
    3
    4
    5
    6
    # $DOTFILES/config.toml (共通設定)
    [vars.autohotkey]
    purpose = "home"
    use_new_outlook = true
    use_excel = false
    use_neovide = true

    仕事用PCには、Git管理外の config.local.toml を作成して仕事用の変数で上書きします。

    toml
    1
    2
    3
    4
    5
    # $DOTFILES/config.local.toml (仕事用PCのローカルのみ、gitignored)
    [vars.autohotkey]
    purpose = "work"
    use_excel = true         # 仕事用PCでは Excel ショートカットを有効にする
    use_new_outlook = false  # Classic Outlook を使う

    そして、設定ファイル AutoHotkey.ahk.tera では、これらの変数を使って条件分岐を記述します。

    autohotkey
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    ; Outlook (New か Classic か)
    ^F9::
    {
    {%- if vars.autohotkey.use_new_outlook %}
      Activate(EnvGet("LOCALAPPDATA") . "\Microsoft\WindowsApps\olk.exe")
    {%- elif vars.autohotkey.purpose == "home" %}
      Activate(EnvGet("LOCALAPPDATA") . "\Microsoft\WindowsApps\olk.exe")
    {%- else %}
      Activate("C:\Program Files\Microsoft Office\root\Office16\OUTLOOK.EXE")
    {%- endif %}
    }
    
    ; Excel (必要な場合のみキー割り当てを有効化)
    {%- if vars.autohotkey.use_excel %}
    F9::
    {
      Activate("C:\Program Files\Microsoft Office\root\Office16\EXCEL.EXE")
    }
    {%- endif %}

    このように記述しておくことで、yui apply を叩いた時にそれぞれのマシン固有の設定(自宅PCなら New Outlook の起動、仕事用PCなら Classic Outlook 起動や Excel ホットキー有効化)が施された AutoHotkey.ahk が自動生成されます。

  • 秘密情報の暗号化 (*.age)

    • age による暗号化をサポートしています。Bitwarden や 1Password といった Vault ツールと連携し、新しいマシンで yui secret unlock を実行するだけで秘密鍵を復元できるセキュアな仕組みも用意されています(※セットアップ方法や Vault 連携などの詳細な手順は、yui の README (Secrets) をご参照ください)。
  • フック (hooks)

    • apply の前後に走らせるフックを設定可能。スクリプトファイルの SHA-256 が変化したときだけ走る when_run = "onchange" など、きめ細かい制御ができます。
    • Deno で書いたスクリプトを走らせることも簡単です。

--git-hooks による安全策

yui init --git-hooks を実行すると、リポジトリの .git/hooks/pre-commitpre-push のフックがインストールされます。

これらのフックの中身は、いずれも yui render --check を実行するシンプルなものです。

  • pre-commit: コミット時に、もしテンプレート(*.tera)からレンダリングされた結果と、実際にステージされているファイルに乖離(ドリフト)がある場合、コミットを拒否します。これにより「適用(yui applyyui render)を忘れたままコミットしてしまった」というイージーミスを防げます。
  • pre-push: プッシュ時にも同様のチェックを行います。--no-verify などでコミット時のチェックをバイパスした場合のセーフティネットとして機能し、乖離した状態のままリモートにプッシュされるのを防ぎます。

インストールとクイックスタート

インストール

sh
1
cargo install yui-cli

クイックスタート

sh
1
2
3
4
# リポジトリの初期化と git hooks の設定
yui init --git-hooks

# $DOTFILES/config.toml を編集してマウントを定義

config.toml の最小構成例:

toml
1
2
3
4
5
6
7
8
[[mount.entry]]
src = "home"
dst = "~"

[[mount.entry]]
src  = "appdata"
dst  = "{{ env(name='APPDATA') }}"
when = "yui.os == 'windows'"

あとは以下のコマンドで操作します:

sh
1
2
3
4
5
yui apply   # テンプレート描画 + リンク作成 + ドリフトの自動吸い込み
yui status  # ドリフト状態のチェック
yui list    # マッピング一覧の表示
yui diff    # 乖離しているファイルの差分表示
yui doctor  # 環境チェック

おわりに

yui を導入したことで、dotfiles 編集の心理的摩擦がほぼゼロになりました。 「あ、Neovim の設定ファイルちょっと書き換えよう」と思った瞬間にいつものエディタで直接開き、保存するだけで、自動的にリポジトリ側のソースが更新されます。あとは必要に応じてリポジトリでコミットするだけです。

「chezmoi の 2ステップの儀式がめんどくさい」「実機設定とリポジトリの同期をシームレスに行いたい」という方は、ぜひ yui を試してみてください。