Skip to content

Instantly share code, notes, and snippets.

@piousdeer
Last active January 2, 2026 19:25
Show Gist options
  • Select an option

  • Save piousdeer/b29c272eaeba398b864da6abf6cb5daa to your computer and use it in GitHub Desktop.

Select an option

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;
});
}
@thunze
Copy link

thunze commented Sep 3, 2025

@piousdeer I just did some digging to find out why xdg.configFile isn't working.

As I understand it, the intended purpose of mergeAttrsList is to merge the attribute sets containing the updates for the options home.file, xdg.configFile, and xdg.dataFile. The problem here is that mergeAttrsList—or more specifically, lib.mergeAttrs—merges attribute sets shallowly (equivalent to //). Therefore, when lib.mergeAttrs is applied from left to right via foldl', the value of the attribute xdg set for xdg.configFile gets overridden by the value of the attribute xdg set for xdg.dataFile.

The solution would be to merge these attribute sets recursively, which can e.g. be achieved by using lib.recursiveUpdate instead of lib.mergeAttrs in mergeAttrsList. So a quick fix would be to replace

mergeAttrsList = builtins.foldl' (lib.mergeAttrs) { };

with

mergeAttrsList = builtins.foldl' (lib.recursiveUpdate) { };

@GatlenCulp
Copy link

@piousdeer I just did some digging to find out why xdg.configFile isn't working.

As I understand it, the intended purpose of mergeAttrsList is to merge the attribute sets containing the updates for the options home.file, xdg.configFile, and xdg.dataFile. The problem here is that mergeAttrsList—or more specifically, lib.mergeAttrs—merges attribute sets shallowly (equivalent to //). Therefore, when lib.mergeAttrs is applied from left to right via foldl', the value of the attribute xdg set for xdg.configFile gets overridden by the value of the attribute xdg set for xdg.dataFile.

The solution would be to merge these attribute sets recursively, which can e.g. be achieved by using lib.recursiveUpdate instead of lib.mergeAttrs in mergeAttrsList. So a quick fix would be to replace

mergeAttrsList = builtins.foldl' (lib.mergeAttrs) { };

with

mergeAttrsList = builtins.foldl' (lib.recursiveUpdate) { };

MWAH I love you, this worked for me.

@GatlenCulp
Copy link

I was having some issues with the VSCode module conflicting with home.file, I think because I configure VSCode through home-manager program options rather than home.file. Not very familiar with nix but I / Claude generated this alternative which works (at least on nix-darwin):

# vscode-mutability.nix
# This module makes VS Code config files mutable (editable after generation)
# by replacing the nix-store symlinks with writable copies after home-manager
# creates them.
#
# Unlike using the `mutable` home.file option, this approach doesn't conflict
# with the VS Code module's file definitions since we just run an activation
# script after linkGeneration.
{ config, pkgs, lib, ... }:
let
  cfg = config.programs.vscode.profiles.default;
  configDir = "Code";
  userDir = if pkgs.stdenv.hostPlatform.isDarwin then
    "Library/Application Support/${configDir}/User"
  else
    "${config.xdg.configHome}/${configDir}/User";

  # List of files to make mutable
  filesToMakeMutable = lib.flatten [
    (lib.optional (cfg.userSettings != { }) "${userDir}/settings.json")
    (lib.optional (cfg.keybindings != [ ]) "${userDir}/keybindings.json")
    (lib.optional (cfg.userTasks != { }) "${userDir}/tasks.json")
  ];

  # Generate the shell commands to replace symlinks with copies
  makeFileMutable = file: ''
    target="$HOME/${file}"
    if [ -L "$target" ]; then
      real_source=$(readlink "$target")
      echo "Making mutable: $target"
      rm "$target"
      cp "$real_source" "$target"
      chmod u+w "$target"
    elif [ -f "$target" ]; then
      echo "Already mutable: $target"
    fi
  '';
in
{
  home.activation.vscodeFileMutability = lib.hm.dag.entryAfter [ "linkGeneration" ] ''
    echo "Making VS Code config files mutable..."
    ${lib.concatMapStrings makeFileMutable filesToMakeMutable}
  '';
}

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