How I Sync and Manage My Dev Environment Across Two Macs

Tuesday, April 14, 2026 / , , , ,
Simplified ChineseEnglish
Summary:
After adding a Mac mini, a giant nix config and manually copied dotfiles became too much hassle. So I split the system config into composable modules and wrote envctl, a small Go tool that backs up and restores files from a manifest.

For years, my development environment lived on a single MacBook Pro, so managing it was fairly casual. The system layer was nix-darwin, and everything was written in one file, /etc/nix-darwin/flake.nix, which kept getting longer. The various config files under the home directory—such as the zsh, git, terminal, and editor configs—used to live in a ~/dot repository, and a small script would cp them into the repository and then commit manually.

Shell
# duplicate configuration file
cp ~/.config/starship.toml ./config # starship
cp -r ~/.config/alacritty ./config # alacritty
cp -r ~/.config/helix ./config # helix
cp ~/.zshrc ./home/.zshrc

With only one computer, I could get by like this. But recently I bought a new Mac mini M4, and the problems suddenly surfaced.

Most things on the two computers are the same—command-line tools, shell, terminal, and git configs—and I want them to be completely identical. But there are also quite a few differences: the MacBook has the full JetBrains suite, virtual machines, and various reverse-engineering and hardware tools, while the Mac mini mainly runs services all the time and doesn't need those. The languages installed via mise are different, the Zed settings are different, and each machine has its own environment variables.

A giant flake.nix obviously can’t accommodate two machines, while the cp script can only copy configuration from one computer into the repository in one direction, and has no idea that the other computer exists. So I spent some time reorganizing the whole environment.

One Repository, Three Layers

First, I gathered everything into a single repository, ~/env, and then divided it into several layers by responsibility.

Layer What it handles Tool
System System settings, command-line tools, Homebrew casks, launchd services, and fish itself nix-darwin
home $HOME config files envctl
Runtime Programming languages and development tools mise
Shell plugins Fish plugins fisher
TEXT
~/env
├── flake.nix
├── nix/
│   ├── hosts/              # 每台机器一个文件
│   └── modules/            # 可以组合的模块
├── home/
│   ├── shared/             # 所有机器共用的配置文件
│   └── hosts/<host>/       # 某台机器独有的配置文件
├── sync/manifest.toml      # 哪些文件要同步、归谁
├── cmd/envctl/             # envctl 的入口
└── internal/envctl/        # envctl 的实现

This repository is private because it contains some things that belong only to me, such as each machine's environment variables.

Splitting the nix Config Apart

The old flake.nix had system settings, command-line tools, and dozens of casks all mixed together. This time I split it into small modules, with each module handling just one category.

TEXT
nix/modules/
├── common/
│   ├── base.nix               # nixpkgs、flakes、stateVersion
│   ├── cli.nix                # 日常命令行工具
│   ├── devtools.nix           # 编译器和开发工具
│   └── package-overrides.nix  # overlay
├── darwin/
│   ├── defaults.nix           # 系统设置、Touch ID sudo
│   ├── fish.nix
│   ├── homebrew.nix           # Homebrew 的公共设置
│   ├── launchd.nix
│   └── shell-env.nix
├── apps/                      # 按类别分组的 cask
│   ├── editors.nix
│   ├── fonts.nix
│   ├── jetbrains.nix
│   ├── terminals.nix
│   └── ...
├── profiles/                  # 某个方向的一整套工具
│   ├── network-tools.nix
│   ├── ai-tools.nix
│   └── media.nix
└── private/local.nix

The modules are all short; the terminal module, for example, contains only two casks.

NIX
{ ... }:
{
  homebrew.casks = [
    "alacritty-full"
    "ghostty"
  ];
}

nix-darwin's module system merges the values of the same option across different modules, so homebrew.casks can be scattered across many files and will ultimately be combined into one complete list. That makes splitting things up feel very natural.

Each machine then has its own host file, which selects the modules to use and adds anything unique to that machine. The MacBook's looks roughly like this.

NIX
{ ... }:
{
  imports = [
    ../modules/common/base.nix
    ../modules/common/cli.nix
    ../modules/common/devtools.nix
    ../modules/darwin/defaults.nix
    ../modules/darwin/fish.nix
    ../modules/darwin/homebrew.nix
    ../modules/apps/editors.nix
    ../modules/apps/fonts.nix
    ../modules/apps/jetbrains.nix
    ../modules/apps/terminals.nix
    ../modules/profiles/network-tools.nix
    ../modules/profiles/ai-tools.nix
    ../modules/profiles/media.nix
    # ...
  ];

  networking.hostName = "brian-mbp-m1max";
  system.primaryUser = "brian";

  homebrew.casks = [
    # Virtualization and remote access
    "parallels"
    "utm"
    "vmware-fusion"

    # Reverse engineering, hardware, and inspection
    "imhex"
    "qflipper"
    # ...
  ];
}

The Mac mini, on the other hand, lacks the devtools, jetbrains, and media modules, and adds some services that only run on it, such as beszel-agent for monitoring.

NIX
{ pkgs, ... }:
{
  imports = [
    ../modules/common/base.nix
    ../modules/common/cli.nix
    ../modules/darwin/defaults.nix
    ../modules/darwin/fish.nix
    ../modules/darwin/homebrew.nix
    ../modules/apps/terminals.nix
    ../modules/profiles/network-tools.nix
    # ...
  ];

  networking.hostName = "brian-macmini-m4";
  system.primaryUser = "brian";

  homebrew = {
    taps = [ "henrygd/beszel" ];
    brews = [
      {
        name = "beszel-agent";
        restart_service = "changed";
        start_service = true;
      }
    ];
  };
}

Finally, flake.nix has only a few lines left; a small function generates a config for each machine.

NIX
outputs = inputs@{ self, nix-darwin, nixpkgs, ... }:
  let
    mkDarwin = host: system:
      nix-darwin.lib.darwinSystem {
        inherit system;
        specialArgs = { inherit inputs self; };
        modules = [ ./nix/hosts/${host}.nix ];
      };
  in
  {
    darwinConfigurations."brian-mbp-m1max" = mkDarwin "brian-mbp-m1max" "aarch64-darwin";
    darwinConfigurations."brian-macmini-m4" = mkDarwin "brian-macmini-m4" "aarch64-darwin";
  };

This way, if I want to add a tool to both computers, I change the common module; if I only want it on one, I change its host file. If I add another machine later, I only need to write a new host file and pick a few modules to combine.

Config in the Home Directory

The system layer has nix-darwin—so what about the config files in the home directory?

At first I used chezmoi, wrapped in a shell script that packaged common operations into backup-home, apply-home, merge-home. But after using it for more than two months, I found that the way it works didn't quite match my habits.

chezmoi's philosophy is that the source files in the repository are the single source of truth; you should edit the source files and then apply them into the home directory. But my usual habit is exactly the opposite: most of the time I edit files in the home directory directly, and many configs aren't edited by me at all—for example, Zed's settings are written back by Zed itself after I click a few things in its UI. So almost every time I had to go the other way and re-add the changes back into the repository, and when both sides had changed, I also had to do a three-way merge.

Also, chezmoi renames source files: ~/.config/fzf/fzf-preview.sh is called dot_config/fzf/executable_fzf-preview.sh, so whenever I browse the repository I have to translate it in my head. As for differences between the two machines, chezmoi's approach is to use templates and write conditional logic inside the files, which is too heavy for me.

After thinking about it, what I actually needed was very simple—similar to the old cp script, except it had to distinguish which files are shared and which belong to a particular machine, and be able to go in both directions. So I just wrote a dedicated little tool for this repository in Go, called envctl.

envctl

envctl's only job is to copy files between the home directory and the repository according to a manifest.

  • The direction is determined by the command. home backup always copies from the home directory to the repository, and home restore always copies from the repository to the home directory; the program itself never decides which way to sync.
  • No merging. There is no hidden merge base and no local sync database. If both sides have changed, first run diff to see clearly, then decide which side to use; if necessary, edit it manually before syncing.
  • No templates. Files that differ between machines are simply stored separately for each.
  • Back up before changing. Before every backup or restore overwrites a file, it first copies whatever is about to be overwritten to /tmp/envctl/ so that if something goes wrong, I can get it back at any time.

The Manifest

Which files to sync is all specified in sync/manifest.toml. Each rule has an id, a path, whether its type is a single file or an entire directory, and most importantly its ownership: shared or host.

TOML
version = 1

ignore = [
  "**/.DS_Store",
]

[[rules]]
id = "fish"
path = "~/.config/fish"
type = "tree"
ownership = "shared"
ignore = [
  "fish_variables",
  "completions/**",
  "themes/**",
]

[[rules]]
id = "fish-env"
path = "~/.config/fish/conf.d/05-env.fish"
type = "file"
ownership = "host"

[[rules]]
id = "mise-config"
path = "~/.config/mise/config.toml"
type = "file"
ownership = "host"

[[rules]]
id = "zed"
path = "~/.config/zed"
type = "tree"
ownership = "host"
ignore = [
  "conversations/**",
  "embeddings/**",
  "prompts/**",
]

[[rules]]
id = "zed-themes"
path = "~/.config/zed/themes"
type = "tree"
ownership = "shared"

shared files are stored only once in the repository, under home/shared, shared by all machines. host files are stored separately for each machine, under home/hosts/<host>. For example, the fish configuration is shared as a whole, but 05-env.fish contains each machine's own environment variables, so it is pulled out separately and set to host. Zed is the same: the settings differ per machine, but the themes are shared.

Rules can be nested, and more specific sub-rules override outer rules. ignore is used to exclude files generated by plugins or an app's own state files, such as plugin files installed by fisher or Zed's conversation history; these don't need to be synced.

To determine which machine it is currently on, envctl uses scutil --get LocalHostName to detect it automatically; this is the same as nix-darwin's host name.

Day-to-Day Use

When I change config on a computer, I first look at what changed, then back it up into the repository.

Shell
go run ./cmd/envctl home diff
go run ./cmd/envctl home backup --all --yes
git commit -am "update fish config"
git push

home diff will output a unified-format diff just like git diff, showing exactly which lines changed for text files, while binary files show changes in size and hash. You can also view or back up only a certain part, for example, --id zed, or --path ~/.config/fish.

On the other computer, pull the latest repository, then restore the shared part.

Shell
git pull
go run ./cmd/envctl home restore --ownership shared --yes

restore must specify which part to restore; without arguments it will error out directly, because I want this kind of operation—dumping everything from the repository over the home directory—to be done only after I've thought it through each time.

Managing the System Layer as Well

Since I had a tool like this, I brought system-layer operations in too, so the whole repository has only one entry point.

Shell
go run ./cmd/envctl system dry-run   # 只构建,不切换
go run ./cmd/envctl system apply     # sudo darwin-rebuild switch --flake ~/env#<host>
go run ./cmd/envctl fisher apply     # 按 fish_plugins 安装缺少的插件
go run ./cmd/envctl doctor           # 打印系统、shell、Homebrew、mise 的状态

system apply also checks the login shell after darwin-rebuild switch succeeds. It reads system.primaryUser from the nix config, confirms that fish is installed and present in /etc/shells, then checks what this user's current login shell is, and if it isn't fish, changes it with dscl. So after a new computer runs system apply, its default shell is already fish.

A New Computer

All these steps chained together make up bootstrap. On a brand-new Mac, you only need to install Nix and Homebrew first, then clone the repository and temporarily use Nix to get a Go to run it.

Shell
git clone <私有仓库> ~/env
cd ~/env
nix shell nixpkgs#go
go run ./cmd/envctl bootstrap
Mermaid diagram

bootstrap does the following in order. First it applies nix-darwin, installing command-line tools, casks, system settings, and fish; then it restores the shared config files and this machine's own config files; then it installs fish plugins; finally mise install installs the language environments. The only thing left to do manually is sign in to the accounts for each app.

The Mac mini was set up exactly this way. After it finished, I opened the terminal and it was almost no different from the MacBook, just without the software it doesn't need.

Implementation

envctl is about three thousand lines of Go in total, and quite a few of those are tests. It uses only one dependency, a TOML parsing library; everything else is the standard library.

To avoid breaking things, my implementation is very cautious. For example, the manifest is checked before use: rule ids and paths cannot be duplicated, paths cannot escape $HOME, home/shared files cannot actually be owned by host, and the repository cannot contain any files that no rule covers. Also, before writing, it rechecks the target file; if the file has changed between planning and execution, it aborts immediately and makes me run it again. Symlinks are never followed; only the symlink itself is copied, never its target.

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...