Skip to content

Instantly share code, notes, and snippets.

@piousdeer
Last active March 29, 2025 23:02
Show Gist options
  • Save piousdeer/b29c272eaeba398b864da6abf6cb5daa to your computer and use it in GitHub Desktop.
Save piousdeer/b29c272eaeba398b864da6abf6cb5daa to your computer and use it in GitHub Desktop.
Create mutable files with home-manager and Nix
{
home.file."test-file" = {
text = "Hello world";
force = true;
mutable = true;
};
}
# This module extends home.file, xdg.configFile and xdg.dataFile with the `mutable` option.
{ config, lib, ... }:
let
fileOptionAttrPaths =
[ [ "home" "file" ] [ "xdg" "configFile" ] [ "xdg" "dataFile" ] ];
in {
options = let
mergeAttrsList = builtins.foldl' (lib.mergeAttrs) { };
fileAttrsType = lib.types.attrsOf (lib.types.submodule ({ config, ... }: {
options.mutable = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to copy the file without the read-only attribute instead of
symlinking. If you set this to `true`, you must also set `force` to
`true`. Mutable files are not removed when you remove them from your
configuration.
This option is useful for programs that don't have a very good
support for read-only configurations.
'';
};
}));
in mergeAttrsList (map (attrPath:
lib.setAttrByPath attrPath (lib.mkOption { type = fileAttrsType; }))
fileOptionAttrPaths);
config = {
home.activation.mutableFileGeneration = let
allFiles = (builtins.concatLists (map
(attrPath: builtins.attrValues (lib.getAttrFromPath attrPath config))
fileOptionAttrPaths));
filterMutableFiles = builtins.filter (file:
(file.mutable or false) && lib.assertMsg file.force
"if you specify `mutable` to `true` on a file, you must also set `force` to `true`");
mutableFiles = filterMutableFiles allFiles;
toCommand = (file:
let
source = lib.escapeShellArg file.source;
target = lib.escapeShellArg file.target;
in ''
$VERBOSE_ECHO "${source} -> ${target}"
$DRY_RUN_CMD cp --remove-destination --no-preserve=mode ${source} ${target}
'');
command = ''
echo "Copying mutable home files for $HOME"
'' + lib.concatLines (map toCommand mutableFiles);
in (lib.hm.dag.entryAfter [ "linkGeneration" ] command);
};
}
{ config, pkgs, lib, ... }:
let
# Path logic from:
# https://github.com/nix-community/home-manager/blob/3876cc613ac3983078964ffb5a0c01d00028139e/modules/programs/vscode.nix
cfg = config.programs.vscode;
vscodePname = cfg.package.pname;
configDir = {
"vscode" = "Code";
"vscode-insiders" = "Code - Insiders";
"vscodium" = "VSCodium";
}.${vscodePname};
userDir = if pkgs.stdenv.hostPlatform.isDarwin then
"Library/Application Support/${configDir}/User"
else
"${config.xdg.configHome}/${configDir}/User";
configFilePath = "${userDir}/settings.json";
tasksFilePath = "${userDir}/tasks.json";
keybindingsFilePath = "${userDir}/keybindings.json";
snippetDir = "${userDir}/snippets";
pathsToMakeWritable = lib.flatten [
(lib.optional (cfg.userTasks != { }) tasksFilePath)
(lib.optional (cfg.userSettings != { }) configFilePath)
(lib.optional (cfg.keybindings != [ ]) keybindingsFilePath)
(lib.optional (cfg.globalSnippets != { })
"${snippetDir}/global.code-snippets")
(lib.mapAttrsToList (language: _: "${snippetDir}/${language}.json")
cfg.languageSnippets)
];
in {
home.file = lib.genAttrs pathsToMakeWritable (_: {
force = true;
mutable = true;
});
}
@danielo515
Copy link

Can someone explain the implications of this? What happens when "something" writes to the mutable file? On the next apply of your config it will be overwritten again? What is the advantage of this over synlinking ?

@piousdeer
Copy link
Author

piousdeer commented Mar 3, 2025

@danielo515

What happens when "something" writes to the mutable file?

The file will be modified

On the next apply of your config it will be overwritten again?

Yes. This will also happen after a reboot (at least if you're using home-manager as a NixOS module)

What is the advantage of this over synlinking ?

Normally, config files managed by home-manager are symlinks pointing to /nix/store. This makes them read-only.

Not being read-only allows for quick iteration on the config before integrating your changes with home-manager. Additionally, some programs, like vscode, just don't work well with a read-only config: nix-community/home-manager#1800

Alternatively, you can use mkOutOfStoreSymlink to symlink the config to a writable file. Then, however:

  • you can't use Nix to generate the config contents
  • you have to hardcode the writable file's location, which deducts from reproducibility

@danielo515
Copy link

Thank you for your response, very much appreciated.

Alternatively, you can use mkOutOfStoreSymlink to symlink the config to a writable file. Then, however:

This is what I was referring to as symlink.

  • you can't use Nix to generate the config contents

That is a drawback, but I can live with it by using some other method to generate it (a script or something like that)

  • you have to hardcode the writable file's location, which deduces from reproducibility

That is definitely the main problem, and what I have been doing up until now. I was wondering it I was just doing it wrong and nix provides a mechanism for referencing the paths it owns (like a subfolder within my nix dotfiles folder), but if an advanced user like you tell me that is a literal problem, I trust you.

The cherry on top of this would be to have a mechanism to detect conflicts, or even better, to re-incorporate the modified file into the nix repository

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment