管理点文件的尝试

我有一件事从一九年初纠结至今。那是个不留意就不会有问题,但留意之后就会一直纠结的话题。我虽然没有找到满意的解决方法,但还是试了几个工具去尝试解决。不吐不快——纠结这么久也没解决的问题,只有写出来能让我舒服些。

我所纠结的是管理点文件dotfiles的方式。

目录

为什么要管理点文件

很多软件很强大,但不配置一下的话根本没法用(往好了说是可玩性高Z Shell默认情况下连Delete键都不给你绑定Vim找不到配置文件就模拟Vi,等等。那些软件的配置文件以点开头(.zshrc、.vimrc,所以又称点文件

客观来讲,抄/写两行配置确实能让工具变得更应手。但倾注在点文件上的时间越多,就越怕失去它;而且很少有人只用一台电脑。所以需要有一种手段追踪、备份、同步这些点文件。

失败的尝试

dotbot

最初我使用的点文件管理器是dotbot。那个软件很不错,但有一个致命的缺陷:它使用软链接把仓库里的文件链接到其他软件期望的路径。

为什么软链接是个问题?因为两台电脑上的同一软件可能需要不同的配置。只用软链接的话,就需要把配置文件拆成三份:A、B电脑共通的部分和A、B独有的部分。然后在共通部分中再判断当前电脑是A还是B,读取各自独有的部分。如果软件使用功能完备的语言来配置的话,这样做没什么问题;但有些软件的配置语言不含任何逻辑判断、分支功能,就没法用这种方法。

chezmoi

在二〇年末我发现了带有模板功能的chezmoi,就抛弃dotbot了。模板是很有用的功能:不管配置语言是否完备,我都可以用chezmoi对不同计算机生成不同的点文件。不过chezmoi也不完美:它号称跨平台,但对Windows的支持几乎可以说是没有。另外它选择复制哪些点文件的方式很反人类:我需要告诉chezmoi哪些点文件不被使用/不要把某文件安装到哪。正常的思路应该是告诉工具哪些点文件需要被使用/要把某文件安装到哪(dotbot就是这么做的。总之,chezmoi除了拥有模板功能外,一点不合我胃口。

另外我不懂法语,不会念chez moi

Scala 3 + GraalVM

管理点文件的核心功能,说白了只是个复制而已。既然如此,为什么不自己写一个呢?我厌倦chezmoi时恰逢Scala 3发布1.0版——那就用Scala 3来写吧。Scala的问题是它需要JVM,而点文件管理器很重要的特性是依赖少:毕竟它发挥最大作用的时刻是你拿到一台崭新但空无一物的电脑之时。GraalVM可以给Scala 3生成“Native Image,完美解决问题——才怪:我依赖的库在Native Image里面有问题。所以这次尝试仅仅几天就结束了。

目前的方法

Bash + ESH

我所追求的点文件管理器有模板功能、可以同时运行于WindowsLinux、需要安装最少的依赖。理所当然的,我把目光投向了无处不在的Bash、AWK模板方面,交给三百多行Shell实现的ESH;安装软件、复制文件等方面,交给Bash

软链接让跟踪修改更方便,模板可以用参数生成不同的文件。之前用的dotbot只有软链接没有模板,chezmoi只有模板但没有软链接——但其实它俩不冲突:我现在把模板生成的文件也保存在Git仓库里,再链接它。我认为我兼得了鱼和熊掌。

这是个土法子,但解决了我的需求:我已经使用这种方法一年了,并且没打算换掉它。当然,这个方法并不完美:不满之一是Bash的语法太扭曲了;不满之二是手写模板还是太麻烦了——最近ChatGPT的效果十分惊人,也许以后我会让AI来生成点文件。

示例

下面是我使用这套管理器的配置节选:

#!/usr/bin/env bash

# shellcheck disable=SC2211,SC2215

. prelude.sh

? zsh
  - ~/.zshenv zsh/zshenv
  + ~/.config/zsh/.zshrc zsh/zshrc
  - ~/.config/zsh/.p10k.zsh zsh/p10k.zsh

? nvim
  - ~/.config/nvim vim

其中prelude.sh定义了三个函数:

  1. ?:用于条件执行-+
  2. -:用于在Linux/Windows上创建软链接;
  3. +:用于执行ESH模板、链接生成后的文件。
prelude.sh有些长,且比较枯燥。点我展开源码。
PATH="$PATH:$PWD/esh"
export ESH_SHELL=bash

PROGRAM_EXISTS=false
PROGRAM_NAME=

# TODO loop over $@
? () {
    PROGRAM_NAME=$1
    if command -v "$1" &>/dev/null; then
        PROGRAM_EXISTS=true
        echo -e "\n$1 exists, installing its configs:"
    else
        PROGRAM_EXISTS=false
        echo -e "\n$1 doesn't exist, its configs has been skipped."
    fi
}

- () {
    if $PROGRAM_EXISTS; then
        echo "  - Linking $1 from $2."
        link "$1" "$2"
    fi
}

+ () {
    if $PROGRAM_EXISTS; then
        local generated="gen/${HOSTNAME:-HOST}${1////-}"
        echo -n "  + Creating $generated from $2"
        if (( $# > 2 )); then
            echo " with ${*:3}"
            esh -o "$generated" -- "$2" "${@:3}"
        else
            echo
            esh -o "$generated" -- "$2"
        fi
        echo "    Linking $1 from $generated."
        link "$1" "$generated"
    fi
}

win-path () {
  local path="$1"
  local rest="${path:2}"
  # Quote for echo: https://unix.stackexchange.com/a/443524
  echo -n "${path:1:1}:${rest//\//\\}"
}

link () {
    local to from
    to=$1
    from=$(realpath "$2")
    mkdir -p "$(dirname "$to")"
    if [[ "$OSTYPE" == linux-gnu ]]; then
        ln -sfn "$from" "$to"
    else # msys
        [[ -e $to ]] && rm -rf "$to"
        to=$(win-path "$to")
        from=$(win-path "$from")
        # /J v. /D: https://superuser.com/a/1291446
        # "/c xxx" or //c xxx: https://stackoverflow.com/a/11878147
        # TODO quote path for cmd?
        [[ -d $from ]] && cmd "/c mklink /J $to $from" >/dev/null
        [[ -f $from ]] && cmd "/c mklink $to $from" >/dev/null
    fi
}

方便裁剪的注释

我有一个无关管理器的管理思路:让点文件变得方便裁剪。有时在用临时电脑时没必要把所有的点文件复制过来——只要把核心部分抄过来就好了。我使用TOML式的注释来标记配置的类别和层级,方便抄写时快速找到必需的部分

比如我的Z Shell配置里有下面这几行:

# [completion]
必要的内容
# [completion.completer]
必要的内容
# [completion.menu]
锦上添花的内容
# [completion.file]
锦上添花的内容

只要把completioncompletion.completer下的内容抄过来,补全就基本可用了。

一些点文件片段

GitHub上分享点文件的行为蔚然成风。我最开始也在那里公开自己的点文件,但点文件难免会混杂一些私人内容——dotbot的作者用一个公开仓库和一个私人仓库解决私密点文件的问题;我嫌麻烦,就统统放到自己的VPS里了。可是,毕竟我的点文件有很大一部分是四处搜刮来的片段。如果我不分享我的点文件,就像用BT下载而不做种。正好趁此机会分享一些谁都用得上的片段吧。

fish

fish不需要任何点文件就非常好用。我不知道为什么其他程序不学学fish。

PowerShell

使用接近Bash(Emacs)的键位以及列表式的补全。

Set-PSReadLineOption -EditMode Emacs
Set-PSReadLineKeyHandler -Key Tab -Function MenuComplete
Set-PSReadlineOption -BellStyle None
Set-PSReadLineOption -PredictionSource History -PredictionViewStyle ListView

Bash

详见《友好的交互式Bash

Z Shell

似乎大家都喜欢用别人写的配置框架。但Z Shell毕竟还没复杂到Emacs的程度,所以我还是喜欢写一个简短的配置文件。下面的补全方式来自StackOverflow的答案,可以模仿fish那样不分大小写并且从字符串中间匹配的行为。

zstyle ':completion:*' matcher-list 'm:{[:lower:]}={[:upper:]}' '+r:|[._-]=* r:|=*' '+l:|=*'

Git

这下再也不怕git psuh了!

[help]
    autocorrect = 1

Beets

详见《再见,所有的音乐订阅我如何停止担忧并爱上甜菜

(Neo)Vim

注:Neovim详见《Fennel宏配置Neovim

有些人喜欢把Vim配置成类似IDE的样子,但我个人倾向于把它当成nano++来看待。专业的事还是得交给专业的工具。尽管如此,下面这几行还是值得一用的。

" 打开文件时跳到上次编辑的位置,:h last-position-jump
autocmd BufRead * autocmd FileType <buffer> ++once
  \ let s:line = line("'\"")
  \ | if s:line >= 1 && s:line <= line("$") && &filetype !~# 'commit'
  \      && index(['xxd', 'gitrebase'], &filetype) == -1
  \ |   execute "normal! g`\""
  \ | endif

" 记录所有更改
let &undodir = expand('~/.vim/undofiles/') | set undofile

" 在命令栏里输入%%会展开为当前编辑文件所在的文件夹
cnoremap <expr> %% getcmdtype() == ':' ? expand('%:h').'/' : '%%'

Emacs

别自己写了,哥。

XMonad

趁早别用了,哥。

fontconfig

把英文字体放在中文字体前面可以解决看英文网页时撇号后面间距太大的问题。不过代价是中文的单引号间距会太小。

<alias>
   <family>serif</family>
   <prefer>
     <family>Noto Serif</family>
     <family>Noto Serif CJK SC</family>
   </prefer>
</alias>

复制以下链接,并粘贴到你的Mastodon、MisskeyGoToSocial等应用的搜索栏中,即可搜到对应本文的嘟文。对嘟文进行的点赞、转发、评论,都会出现在本文底部。快去试试吧!

链接:https://emptystack.top/note/dotfiles