diff --git a/README.md b/README.md index 6b7cb0936..8f648006c 100644 --- a/README.md +++ b/README.md @@ -53,11 +53,6 @@ Home Manager targets [NixOS][] unstable and NixOS version 25.05 (the current stable version), it may or may not work on other Linux distributions and NixOS versions. -Also, the `home-manager` tool does not explicitly support rollbacks at the -moment so if your home directory gets messed up you'll have to fix it yourself. -See the [rollbacks][] section for instructions on how to manually perform a -rollback. - Now when your expectations have been built up and you are eager to try all this out you can go ahead and read the rest of this text. diff --git a/docs/home-manager.1 b/docs/home-manager.1 index 66faeab4f..068a41aad 100644 --- a/docs/home-manager.1 +++ b/docs/home-manager.1 @@ -28,6 +28,8 @@ .Cm | packages .Cm | remove-generations Ar ID \&... .Cm | switch +.Op Fl -rollback +.Op Bro Fl c | Fl -specialisation Brc Ar NAME .Cm | uninstall .Brc .Op Fl A Ar attrPath @@ -157,9 +159,18 @@ sub-command to find suitable generation numbers. .RE .Pp -.It Cm switch +.It Cm switch Oo Fl -rollback Oc Oo Bro Fl c | Fl -specialisation Brc Ar NAME Oc .RS 4 Build and activate the configuration\&. +.sp +If the +.Fl -rollback +option is given, then the build is not done, instead roll back to and +activate the configuration prior to the current configuration\&. +.sp +If the +.Fl -specialisation +option is given, then the named specialisation is activated\&. .RE .Pp diff --git a/docs/manual/internals.md b/docs/manual/internals.md new file mode 100644 index 000000000..799d38f1b --- /dev/null +++ b/docs/manual/internals.md @@ -0,0 +1,11 @@ +# Home Manager Internals {#ch-internals} + +This chapter collects some documentation about the internal workings +of Home Manager. The information here is mostly aimed to developers of +Home Manager and those who do non-trivial integration with Home +Manager. + + +```{=include=} sections +internals/activation.md +``` diff --git a/docs/manual/internals/activation.md b/docs/manual/internals/activation.md new file mode 100644 index 000000000..e47e3d9ca --- /dev/null +++ b/docs/manual/internals/activation.md @@ -0,0 +1,40 @@ +# Activation {#sec-internals-activation} + +Activating a Home Manager configuration ensures that the built +configuration is introduced into the user's environment. The +activation is performed by a suitably named script +{command}`activate`. This script is generated as part of the +configuration build and is placed in the root of the build output. + +The activation script is implemented in the Bash language and consists +of initialization code followed by a number of _activation script +blocks_. These blocks are specified using the +[home.activation](#opt-home.activation) option. The blocks may have +dependencies among themselves and the generated activation script will +contain the blocks serialized such that the dependencies are +satisfied. A dependency cycle causes a failure when the configuration +is built. + +Historically, the activation script has been responsible for creating +a new generation of the `home-manager` Nix profile. The more modern +way, however, is to let the _activation driver_ – that is, the +software calling the activation script – manage the profile. Indeed, +in some cases we may not have a `home-manager` profile at all! This is +the case when Home Manager is used as a NixOS or nix-darwin module, in +these cases the system profile will contain references to the +corresponding Home Manager configurations. + +Note, to maintain backwards compatibility, the old activation script +behavior is still the default. To choose the new mode of operation you +have to call the activation script with the command line option +`--driver-version 1`. The old behavior is available using +`--driver-version 0`, or simply omit it entirely. + +Unfortunately, driver software need to support both modes of operation +for the time being since a user may wish to activate an old generation +that contains an activation script that does not support +`--driver-version`. To determine whether support is available, check +the {file}`gen-version` file in the configuration build output root. +If the file is missing then the activation script does not support +`--driver-version`. If the file exists and contains the integer 1 or +higher, then `--driver-version 1` is supported. diff --git a/docs/manual/manual.md b/docs/manual/manual.md index d42d85f04..ce22d8aae 100644 --- a/docs/manual/manual.md +++ b/docs/manual/manual.md @@ -14,6 +14,7 @@ usage.md nix-flakes.md writing-modules.md contributing.md +internals.md 3rd-party.md faq.md ``` diff --git a/docs/manual/usage/rollbacks.md b/docs/manual/usage/rollbacks.md index 5b5180f99..d379de9f9 100644 --- a/docs/manual/usage/rollbacks.md +++ b/docs/manual/usage/rollbacks.md @@ -1,32 +1,45 @@ # Rollbacks {#sec-usage-rollbacks} -While the `home-manager` tool does not explicitly support rollbacks at -the moment it is relatively easy to perform one manually. The steps to -do so are +When you perform a `home-manager switch` and discover a problem then +it is possible to _roll back_ to the previous version of your +configuration using `home-manager switch --rollback`. This will turn +the previous configuration into the current configuration. -1. Run `home-manager generations` to determine which generation you - wish to rollback to: +::: {.example #ex-rollback-scenario} +### Home Manager Rollback - ``` shell - $ home-manager generations - 2018-01-04 11:56 : id 765 -> /nix/store/kahm1rxk77mnvd2l8pfvd4jkkffk5ijk-home-manager-generation - 2018-01-03 10:29 : id 764 -> /nix/store/2wsmsliqr5yynqkdyjzb1y57pr5q2lsj-home-manager-generation - 2018-01-01 12:21 : id 763 -> /nix/store/mv960kl9chn2lal5q8lnqdp1ygxngcd1-home-manager-generation - 2017-12-29 21:03 : id 762 -> /nix/store/6c0k1r03fxckql4vgqcn9ccb616ynb94-home-manager-generation - 2017-12-25 18:51 : id 761 -> /nix/store/czc5y6vi1rvnkfv83cs3rn84jarcgsgh-home-manager-generation - … - ``` +Imagine you have just updated Nixpkgs and switched to a new Home +Manager configuration. You discover that a package update included in +your new configuration has a bug that was not present in the previous +configuration. -2. Copy the Nix store path of the generation you chose, e.g., +You can then run `home-manager switch --rollback` to recover your +previous configuration, which includes the working version of the +package. - /nix/store/mv960kl9chn2lal5q8lnqdp1ygxngcd1-home-manager-generation +To see what happened above we can observe the list of Home Manager +generations before and after the rollback: - for generation 763. +``` shell +$ home-manager generations +2024-01-04 11:56 : id 765 -> /nix/store/kahm1rxk77mnvd2l8pfvd4jkkffk5ijk-home-manager-generation (current) +2024-01-03 10:29 : id 764 -> /nix/store/2wsmsliqr5yynqkdyjzb1y57pr5q2lsj-home-manager-generation +2024-01-01 12:21 : id 763 -> /nix/store/mv960kl9chn2lal5q8lnqdp1ygxngcd1-home-manager-generation +2023-12-29 21:03 : id 762 -> /nix/store/6c0k1r03fxckql4vgqcn9ccb616ynb94-home-manager-generation +2023-12-25 18:51 : id 761 -> /nix/store/czc5y6vi1rvnkfv83cs3rn84jarcgsgh-home-manager-generation +… -3. Run the `activate` script inside the copied store path: +$ home-manager switch --rollback +Starting home manager activation +… - ``` shell - $ /nix/store/mv960kl9chn2lal5q8lnqdp1ygxngcd1-home-manager-generation/activate - Starting home manager activation - … - ``` +$ home-manager generations +2024-01-04 11:56 : id 765 -> /nix/store/kahm1rxk77mnvd2l8pfvd4jkkffk5ijk-home-manager-generation +2024-01-03 10:29 : id 764 -> /nix/store/2wsmsliqr5yynqkdyjzb1y57pr5q2lsj-home-manager-generation (current) +2024-01-01 12:21 : id 763 -> /nix/store/mv960kl9chn2lal5q8lnqdp1ygxngcd1-home-manager-generation +2023-12-29 21:03 : id 762 -> /nix/store/6c0k1r03fxckql4vgqcn9ccb616ynb94-home-manager-generation +2023-12-25 18:51 : id 761 -> /nix/store/czc5y6vi1rvnkfv83cs3rn84jarcgsgh-home-manager-generation +… +``` + +::: diff --git a/docs/release-notes/rl-2511.md b/docs/release-notes/rl-2511.md index 4060bfe4a..0b2ce0f46 100644 --- a/docs/release-notes/rl-2511.md +++ b/docs/release-notes/rl-2511.md @@ -7,7 +7,46 @@ section is therefore not final. This release has the following notable changes: -- No changes. +- Updating the `home-manager` Nix profile inside the activation script + now deprecated. The profile update is instead the responsibility of + the software calling the activation script, such as the + `home-manager` tool. + + The legacy behavior remains the default for backwards compatibility + but may emit a deprecation warning in the future and in the longer + term removed all together. If you have developed tooling that + directly call the generated activation script, then you are + encouraged to adapt to the new behavior. See the + [Activation](#sec-internals-activation) section in the manual for + details on how to call the activation script. + +- The `home-manager switch` command now offers a `--rollback` option. + When given, the switch performs a rollback to the Home Manager + generation prior to the current before activating. While it was + previously possible to accomplish this by manually activating an old + generation, it always created a new profile generation. The new + behavior mirrors the behavior of `nixos-rebuild switch --rollback`. + See the [Rollbacks](#sec-usage-rollbacks) section for more. + +- The `home-manager switch` command now offers a + `--specialisation NAME` option. When given, the switch activates the + named specialisation. While it was previously possible to accomplish + this by manually running the specialisation `activate` script it was + quite cumbersome and always created a new profile generation. The + new behavior mirrors the behavior of `nixos-rebuild switch + --specialisation`. + +- When using Home Manager as a NixOS or nix-darwin module we + previously created an unnecessary `home-manager` per-user "shadow + profile" for the user. This no longer happens. You can restore the + old behavior by adding + + ``` nix + home-manager.enableLegacyProfileManagement = true; + ``` + + to your configuration. This option is likely to be deprecated in the + future. ## State Version Changes {#sec-release-25.11-state-version-changes} diff --git a/home-manager/home-manager b/home-manager/home-manager index 3edc43dd1..bb52ec72f 100644 --- a/home-manager/home-manager +++ b/home-manager/home-manager @@ -17,6 +17,12 @@ function errMissingOptArg() { exit 1 } +function errTopLevelSubcommandOpt() { + # translators: For example: "home-manager: --rollback can only be used after switch" + _iError '%s: %s can only be used after %s' "$0" "$1" "$2" >&2 + exit 1 +} + function setNixProfileCommands() { if [[ -e $HOME/.nix-profile/manifest.json \ || -e ${XDG_STATE_HOME:-$HOME/.local/state}/nix/profile/manifest.json ]] ; then @@ -481,7 +487,7 @@ EOF _i "Creating initial Home Manager generation..." echo - if doSwitch; then + if doSwitch --switch; then # translators: The "%s" specifier will be replaced by a file path. _i $'All done! The home-manager tool should now be installed and you can edit\n\n %s\n\nto configure Home Manager. Run \'man home-configuration.nix\' to\nsee all available options.' \ "$confFile" @@ -699,31 +705,104 @@ function doRepl() { } function doSwitch() { + setHomeManagerPathVariables + setVerboseArg setWorkDir + local action + local specialisation + + while (( $# > 0 )); do + local opt="$1" + shift + + case $opt in + --switch) + action='switch' + ;; + --test) + action='test' + ;; + --rollback) + action='rollback' + ;; + --specialisation) + specialisation="$1" + shift + ;; + *) + _iError "%s: unknown option '%s'" "home-manager switch" "$opt" >&2 + return 1 + ;; + esac + done + + if [[ ! -v action ]]; then + errorEcho "home-manager switch: missing required option" >&2 + return 1 + fi + local generation - # Build the generation and run the activate script. Note, we - # specify an output link so that it is treated as a GC root. This - # prevents an unfortunately timed GC from removing the generation - # before activation completes. - generation="$WORK_DIR/generation" + case $action in + switch|test) + # Build the generation and run the activate script. Note, we + # specify an output link so that it is treated as a GC root. This + # prevents an unfortunately timed GC from removing the generation + # before activation completes. + generation="$WORK_DIR/generation" - setFlakeAttribute - if [[ -v FLAKE_CONFIG_URI ]]; then - doBuildFlake \ - "$FLAKE_CONFIG_URI.activationPackage" \ - --out-link "$generation" \ - ${PRINT_BUILD_LOGS+--print-build-logs} \ - && "$generation/activate" || return - else - doBuildAttr \ - --out-link "$generation" \ - --attr activationPackage \ - && "$generation/activate" || return + setFlakeAttribute + if [[ -v FLAKE_CONFIG_URI ]]; then + doBuildFlake \ + "$FLAKE_CONFIG_URI.activationPackage" \ + --out-link "$generation" \ + ${PRINT_BUILD_LOGS+--print-build-logs} + else + doBuildAttr \ + --out-link "$generation" \ + --attr activationPackage + fi + ;; + rollback) + generation="$HM_PROFILE_DIR/home-manager" + ;; + esac + + # If we are doing a switch but built a legacy configuration, where the + # activation script manages the profile, then we instead perform a test + # action. + # + # The migration away from legacy activation scripts happened when + # introducing the gen-version file, hence the existence check. + if [[ $action == 'switch' && ! -e "$generation/gen-version" ]]; then + action='test' fi - presentNews + # Choose the activate script to run. + local activateScript="$generation/activate" + if [[ -v specialisation ]]; then + activateScript="$generation/specialisation/$specialisation/activate" + if [[ ! -x $activateScript ]]; then + _iError 'The configuration did not contain the specialisation "%s"' "$specialisation" + exit 1 + fi + fi + + case $action in + switch) + run nix-env $VERBOSE_ARG --profile "$HM_PROFILE_DIR/home-manager" --set "$generation" + ;; + rollback) + run nix-env $VERBOSE_ARG --profile "$HM_PROFILE_DIR/home-manager" --rollback + ;; + esac + + "$activateScript" --driver-version 1 || return + + if [[ $action == 'switch' || $action == 'test' ]]; then + presentNews + fi } function doListGens() { @@ -736,10 +815,14 @@ function doListGens() { fi pushd "$HM_PROFILE_DIR" > /dev/null + local curProfile + curProfile=$(readlink home-manager) + # shellcheck disable=2012 ls --color=$color -gG --time-style=long-iso --sort time home-manager-*-link \ | cut -d' ' -f 4- \ - | sed -E 's/home-manager-([[:digit:]]*)-link/: id \1/' + | sed -E -e "/$curProfile/ { s/\$/ \(current\)/ }" \ + -e 's/home-manager-([[:digit:]]*)-link/: id \1/' popd > /dev/null } @@ -994,7 +1077,15 @@ function doHelp() { echo echo " instantiate Instantiate the configuration and print the resulting derivation" echo - echo " switch Build and activate configuration" + echo " switch [OPTION]" + echo " Build and activate configuration" + echo + echo " --rollback Do not build a new configuration, instead roll back to" + echo " the configuration prior to the current configuration." + echo + echo " -c, --specialisation NAME" + echo " Activates the named specialisation; when not specified," + echo " switching will activate the unspecialised configuration." echo echo " generations List all home environment generations" echo @@ -1028,7 +1119,7 @@ while [[ $# -gt 0 ]]; do opt="$1" shift case $opt in - build|init|instantiate|option|edit|expire-generations|generations|help|news|packages|remove-generations|repl|switch|uninstall) + build|init|instantiate|option|edit|expire-generations|generations|help|news|packages|remove-generations|repl|rollback|switch|test|uninstall) COMMAND="$opt" ;; -A) @@ -1093,6 +1184,28 @@ while [[ $# -gt 0 ]]; do -n|--dry-run) export DRY_RUN=1 ;; + --rollback) + case $COMMAND in + switch) + COMMAND_ARGS+=("$opt") + ;; + *) + errTopLevelSubcommandOpt "--rollback" "switch" + ;; + esac + ;; + -c|--specialisation) + case $COMMAND in + switch) + [[ -v 1 && $1 != -* ]] || errMissingOptArg "$opt" + COMMAND_ARGS+=("--specialisation" "$1") + shift + ;; + *) + errTopLevelSubcommandOpt "--specialisation" "switch" + ;; + esac + ;; --option|--arg|--argstr) [[ -v 1 && $1 != -* ]] || errMissingOptArg "$opt" [[ -v 2 ]] || errMissingOptArg "$opt $1" @@ -1151,14 +1264,22 @@ case $COMMAND in doInstantiate ;; switch) - doSwitch + doSwitch --switch "${COMMAND_ARGS[@]}" ;; + # TODO: The test functionality is not really sensible until we perform + # activation through some form of systemd unit. + # test) + # doSwitch --test + # ;; generations) doListGens ;; remove-generations) doRmGenerations "${COMMAND_ARGS[@]}" ;; + rollback) + doRollback + ;; expire-generations) if [[ ${#COMMAND_ARGS[@]} != 1 ]]; then _i 'expire-generations expects one argument, got %d.' "${#COMMAND_ARGS[@]}" >&2 diff --git a/home-manager/po/home-manager.pot b/home-manager/po/home-manager.pot index e28edec4e..662ea0e8c 100644 --- a/home-manager/po/home-manager.pot +++ b/home-manager/po/home-manager.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Home Manager\n" "Report-Msgid-Bugs-To: https://github.com/nix-community/home-manager/issues\n" -"POT-Creation-Date: 2025-05-30 15:05+0200\n" +"POT-Creation-Date: 2025-07-22 10:59+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -23,36 +23,41 @@ msgstr "" msgid "%s: missing argument for %s" msgstr "" -#: home-manager/home-manager:65 +#. translators: For example: "home-manager: --rollback can only be used after switch" +#: home-manager/home-manager:22 +msgid "%s: %s can only be used after %s" +msgstr "" + +#: home-manager/home-manager:71 msgid "No configuration file found at %s" msgstr "" #. translators: The first '%s' specifier will be replaced by either #. 'home.nix' or 'flake.nix'. -#: home-manager/home-manager:82 home-manager/home-manager:86 -#: home-manager/home-manager:185 +#: home-manager/home-manager:88 home-manager/home-manager:92 +#: home-manager/home-manager:191 msgid "" "Keeping your Home Manager %s in %s is deprecated,\n" "please move it to %s" msgstr "" -#: home-manager/home-manager:93 +#: home-manager/home-manager:99 msgid "No configuration file found. Please create one at %s" msgstr "" -#: home-manager/home-manager:108 +#: home-manager/home-manager:114 msgid "Home Manager not found at %s." msgstr "" #. translators: This message will be seen by very few users that likely are familiar with English. So feel free to leave this untranslated. -#: home-manager/home-manager:116 +#: home-manager/home-manager:122 msgid "" "The fallback Home Manager path %s has been deprecated and a file/directory " "was found there." msgstr "" #. translators: This message will be seen by very few users that likely are familiar with English. So feel free to leave this untranslated. -#: home-manager/home-manager:119 +#: home-manager/home-manager:125 msgid "" "To remove this warning, do one of the following.\n" "\n" @@ -73,42 +78,42 @@ msgid "" " $ rm -r \"%s\"" msgstr "" -#: home-manager/home-manager:147 +#: home-manager/home-manager:153 msgid "Sanity checking Nix" msgstr "" -#: home-manager/home-manager:167 +#: home-manager/home-manager:173 msgid "Could not find suitable profile directory, tried %s and %s" msgstr "" #. translators: Here "flake" is a noun that refers to the Nix Flakes feature. -#: home-manager/home-manager:222 +#: home-manager/home-manager:230 msgid "Can't inspect options of a flake configuration" msgstr "" -#: home-manager/home-manager:297 home-manager/home-manager:320 -#: home-manager/home-manager:1060 +#: home-manager/home-manager:305 home-manager/home-manager:328 +#: home-manager/home-manager:734 home-manager/home-manager:1237 msgid "%s: unknown option '%s'" msgstr "" -#: home-manager/home-manager:302 home-manager/home-manager:1061 +#: home-manager/home-manager:310 home-manager/home-manager:1238 msgid "Run '%s --help' for usage help" msgstr "" -#: home-manager/home-manager:328 home-manager/home-manager:433 +#: home-manager/home-manager:336 home-manager/home-manager:441 msgid "The file %s already exists, leaving it unchanged..." msgstr "" -#: home-manager/home-manager:330 home-manager/home-manager:435 +#: home-manager/home-manager:338 home-manager/home-manager:443 msgid "Creating %s..." msgstr "" -#: home-manager/home-manager:479 +#: home-manager/home-manager:487 msgid "Creating initial Home Manager generation..." msgstr "" #. translators: The "%s" specifier will be replaced by a file path. -#: home-manager/home-manager:484 +#: home-manager/home-manager:492 msgid "" "All done! The home-manager tool should now be installed and you can edit\n" "\n" @@ -119,7 +124,7 @@ msgid "" msgstr "" #. translators: The "%s" specifier will be replaced by a URL. -#: home-manager/home-manager:489 +#: home-manager/home-manager:497 msgid "" "Uh oh, the installation failed! Please create an issue at\n" "\n" @@ -129,11 +134,11 @@ msgid "" msgstr "" #. translators: Here "flake" is a noun that refers to the Nix Flakes feature. -#: home-manager/home-manager:500 +#: home-manager/home-manager:508 msgid "Can't instantiate a flake configuration" msgstr "" -#: home-manager/home-manager:576 +#: home-manager/home-manager:584 msgid "" "There is %d unread and relevant news item.\n" "Read it by running the command \"%s news\"." @@ -143,72 +148,76 @@ msgid_plural "" msgstr[0] "" msgstr[1] "" -#: home-manager/home-manager:590 +#: home-manager/home-manager:598 msgid "Unknown \"news.display\" setting \"%s\"." msgstr "" -#: home-manager/home-manager:598 +#: home-manager/home-manager:606 #, sh-format msgid "Please set the $EDITOR or $VISUAL environment variable" msgstr "" -#: home-manager/home-manager:616 +#: home-manager/home-manager:624 msgid "Cannot run build in read-only directory" msgstr "" -#: home-manager/home-manager:697 +#: home-manager/home-manager:787 +msgid "The configuration did not contain the specialisation \"%s\"" +msgstr "" + +#: home-manager/home-manager:841 msgid "No generation with ID %s" msgstr "" -#: home-manager/home-manager:699 +#: home-manager/home-manager:843 msgid "Cannot remove the current generation %s" msgstr "" -#: home-manager/home-manager:701 +#: home-manager/home-manager:845 msgid "Removing generation %s" msgstr "" -#: home-manager/home-manager:722 +#: home-manager/home-manager:866 msgid "No generations to expire" msgstr "" -#: home-manager/home-manager:733 +#: home-manager/home-manager:877 msgid "No home-manager packages seem to be installed." msgstr "" -#: home-manager/home-manager:818 +#: home-manager/home-manager:962 msgid "Unknown argument %s" msgstr "" -#: home-manager/home-manager:843 +#: home-manager/home-manager:987 msgid "This will remove Home Manager from your system." msgstr "" -#: home-manager/home-manager:846 +#: home-manager/home-manager:990 msgid "This is a dry run, nothing will actually be uninstalled." msgstr "" -#: home-manager/home-manager:850 +#: home-manager/home-manager:994 msgid "Really uninstall Home Manager?" msgstr "" -#: home-manager/home-manager:856 +#: home-manager/home-manager:1000 msgid "Switching to empty Home Manager configuration..." msgstr "" -#: home-manager/home-manager:871 +#: home-manager/home-manager:1015 msgid "Yay!" msgstr "" -#: home-manager/home-manager:876 +#: home-manager/home-manager:1020 msgid "Home Manager is uninstalled but your home.nix is left untouched." msgstr "" -#: home-manager/home-manager:1100 +#: home-manager/home-manager:1285 msgid "expire-generations expects one argument, got %d." msgstr "" -#: home-manager/home-manager:1122 +#: home-manager/home-manager:1310 msgid "Unknown command: %s" msgstr "" diff --git a/modules/home-environment.nix b/modules/home-environment.nix index 252e53e1b..08488f3e9 100644 --- a/modules/home-environment.nix +++ b/modules/home-environment.nix @@ -477,6 +477,18 @@ in description = "The package containing the complete activation script."; }; + home.activationGenerateGcRoot = mkOption { + internal = true; + type = types.bool; + default = true; + description = '' + Whether the activation script should create a GC root to avoid being + garbage collected. Typically you want this but if you know for certain + that the Home Manager generation is referenced from some other GC root, + then it may be appropriate to not create our own root. + ''; + }; + home.extraActivationPath = mkOption { internal = true; type = types.listOf types.package; @@ -627,12 +639,16 @@ in # The entry acting as a boundary between the activation script's "check" and # the "write" phases. This is where we commit to attempting to actually # activate the configuration. + # + # Note, if we are run by a version 0 driver then we update the profile here. home.activation.writeBoundary = lib.hm.dag.entryAnywhere '' - if [[ ! -v oldGenPath || "$oldGenPath" != "$newGenPath" ]] ; then - _i "Creating new profile generation" - run nix-env $VERBOSE_ARG --profile "$genProfilePath" --set "$newGenPath" - else - _i "No change so reusing latest profile generation" + if (( $hmDriverVersion < 1 )); then + if [[ ! -v oldGenPath || "$oldGenPath" != "$newGenPath" ]] ; then + _i "Creating new profile generation" + run nix-env $VERBOSE_ARG --profile "$genProfilePath" --set "$newGenPath" + else + _i "No change so reusing latest profile generation" + fi fi ''; @@ -763,6 +779,38 @@ in export PATH="${activationBinPaths}" ${config.lib.bash.initHomeManagerLib} + # The driver version indicates the behavior expected by the caller of + # this script. + # + # - 0 : legacy behavior + # - 1 : the script will not attempt to update the Home Manager Nix profile. + hmDriverVersion=0 + + while (( $# > 0 )); do + opt="$1" + shift + + case $opt in + --driver-version) + if (( $# == 0 )); then + errorEcho "$0: no driver version specified" >&2 + exit 1 + elif (( 0 <= $1 && $1 <= 1 )); then + hmDriverVersion=$1 + else + errorEcho "$0: unexpected driver version $1" >&2 + exit 1 + fi + shift + ;; + *) + _iError "%s: unknown option '%s'" "$0" "$opt" >&2 + exit 1 + ;; + esac + done + unset opt + ${builtins.readFile ./lib-bash/activation-init.sh} if [[ ! -v SKIP_SANITY_CHECKS ]]; then @@ -770,13 +818,15 @@ in checkHomeDirectory ${lib.escapeShellArg config.home.homeDirectory} fi - # Create a temporary GC root to prevent collection during activation. - trap 'run rm -f $VERBOSE_ARG "$newGenGcPath"' EXIT - run --silence nix-store --realise "$newGenPath" --add-root "$newGenGcPath" + ${lib.optionalString config.home.activationGenerateGcRoot '' + # Create a temporary GC root to prevent collection during activation. + trap 'run rm -f $VERBOSE_ARG "$newGenGcPath"' EXIT + run --silence nix-store --realise "$newGenPath" --add-root "$newGenGcPath" + ''} ${activationCmds} - ${lib.optionalString (!config.uninstall) '' + ${lib.optionalString (config.home.activationGenerateGcRoot && !config.uninstall) '' # Create the "current generation" GC root. run --silence nix-store --realise "$newGenPath" --add-root "$currentGenGcPath" @@ -797,6 +847,11 @@ in echo "${config.home.version.full}" > $out/hm-version + # The gen-version indicates the format of the generation package + # itself. It allows us to make backwards incompatible changes in the + # package output and have surrounding tooling adapt. + echo 1 > $out/gen-version + cp ${activationScript} $out/activate mkdir $out/bin diff --git a/modules/po/hm-modules.pot b/modules/po/hm-modules.pot index f698bd9be..6d5bbe5b4 100644 --- a/modules/po/hm-modules.pot +++ b/modules/po/hm-modules.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Home Manager Modules\n" "Report-Msgid-Bugs-To: https://github.com/nix-community/home-manager/issues\n" -"POT-Creation-Date: 2025-05-30 15:05+0200\n" +"POT-Creation-Date: 2025-07-22 10:59+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -25,15 +25,15 @@ msgstr "" msgid "Cleaning up orphan links from %s" msgstr "" -#: modules/home-environment.nix:632 +#: modules/home-environment.nix:647 msgid "Creating new profile generation" msgstr "" -#: modules/home-environment.nix:635 +#: modules/home-environment.nix:650 msgid "No change so reusing latest profile generation" msgstr "" -#: modules/home-environment.nix:683 +#: modules/home-environment.nix:699 msgid "" "Oops, Nix failed to install your new Home Manager profile!\n" "\n" @@ -49,10 +49,14 @@ msgid "" "Then try activating your Home Manager configuration again." msgstr "" -#: modules/home-environment.nix:719 +#: modules/home-environment.nix:735 msgid "Activating %s" msgstr "" +#: modules/home-environment.nix:807 +msgid "%s: unknown option '%s'" +msgstr "" + #: modules/lib-bash/activation-init.sh:22 msgid "Migrating profile from %s to %s" msgstr "" diff --git a/nix-darwin/default.nix b/nix-darwin/default.nix index bc0e9b601..ffc6fb3c1 100644 --- a/nix-darwin/default.nix +++ b/nix-darwin/default.nix @@ -17,16 +17,22 @@ in { home-manager.extraSpecialArgs.darwinConfig = config; } (lib.mkIf (cfg.users != { }) { system.activationScripts.postActivation.text = lib.concatStringsSep "\n" ( - lib.mapAttrsToList (username: usercfg: '' - echo Activating home-manager configuration for ${usercfg.home.username} - launchctl asuser "$(id -u ${usercfg.home.username})" sudo -u ${usercfg.home.username} --set-home ${pkgs.writeShellScript "activation-${usercfg.home.username}" '' - ${lib.optionalString ( - cfg.backupFileExtension != null - ) "export HOME_MANAGER_BACKUP_EXT=${lib.escapeShellArg cfg.backupFileExtension}"} - ${lib.optionalString cfg.verbose "export VERBOSE=1"} - exec ${usercfg.home.activationPackage}/activate - ''} - '') cfg.users + lib.mapAttrsToList ( + username: usercfg: + let + driverVersion = if cfg.enableLegacyProfileManagement then "0" else "1"; + in + '' + echo Activating home-manager configuration for ${usercfg.home.username} + launchctl asuser "$(id -u ${usercfg.home.username})" sudo -u ${usercfg.home.username} --set-home ${pkgs.writeShellScript "activation-${usercfg.home.username}" '' + ${lib.optionalString ( + cfg.backupFileExtension != null + ) "export HOME_MANAGER_BACKUP_EXT=${lib.escapeShellArg cfg.backupFileExtension}"} + ${lib.optionalString cfg.verbose "export VERBOSE=1"} + exec ${usercfg.home.activationPackage}/activate --driver-version ${driverVersion} + ''} + '' + ) cfg.users ); }) ]; diff --git a/nixos/common.nix b/nixos/common.nix index b54ee602c..4dd3c1061 100644 --- a/nixos/common.nix +++ b/nixos/common.nix @@ -108,6 +108,19 @@ in verbose = mkEnableOption "verbose output on activation"; + enableLegacyProfileManagement = mkOption { + type = types.bool; + default = false; + description = '' + Whether to enable legacy profile (and garbage collection root) + management during activation. When enabled, the Home Manager activation + will produce a per-user `home-manager` Nix profile as well as a garbage + collection root, just like in the standalone installation of Home + Manager. Typically, this is not desired when Home Manager is embedded in + the system configuration. + ''; + }; + users = mkOption { type = types.attrsOf hmModule; default = { }; diff --git a/nixos/default.nix b/nixos/default.nix index fb9b7fda3..25defb1a6 100644 --- a/nixos/default.nix +++ b/nixos/default.nix @@ -36,6 +36,12 @@ in # Inherit glibcLocales setting from NixOS. i18n.glibcLocales = lib.mkDefault config.i18n.glibcLocales; + + # Legacy profile management is when the activation script + # generates GC root and home-manager profile. The modern way + # simply relies on the GC root that the system maintains, which + # should also protect the Home Manager activation package outputs. + home.activationGenerateGcRoot = cfg.enableLegacyProfileManagement; }; } ]; @@ -46,6 +52,7 @@ in _: usercfg: let username = usercfg.home.username; + driverVersion = if cfg.enableLegacyProfileManagement then "0" else "1"; in lib.nameValuePair "home-manager-${utils.escapeSystemdPath username}" { description = "Home Manager environment for ${username}"; @@ -94,7 +101,7 @@ in | ${sed} -En '/^(${exportedSystemdVariables})=/s/^/export /p' )" - exec "$1/activate" + exec "$1/activate" --driver-version ${driverVersion} ''; in "${setupEnv} ${usercfg.home.activationPackage}"; diff --git a/tests/integration/default.nix b/tests/integration/default.nix index 340cabd85..72ac04517 100644 --- a/tests/integration/default.nix +++ b/tests/integration/default.nix @@ -19,9 +19,11 @@ let mu = runTest ./standalone/mu; nh = runTest ./standalone/nh.nix; nixos-basics = runTest ./nixos/basics.nix; + nixos-legacy-profile-management = runTest ./nixos/legacy-profile-management.nix; rclone = runTest ./standalone/rclone; restic = runTest ./standalone/restic.nix; standalone-flake-basics = runTest ./standalone/flake-basics.nix; + standalone-specialisation = runTest ./standalone/specialisation.nix; standalone-standard-basics = runTest ./standalone/standard-basics.nix; }; in diff --git a/tests/integration/nixos/basics.nix b/tests/integration/nixos/basics.nix index 7c87aa1f6..189aa7588 100644 --- a/tests/integration/nixos/basics.nix +++ b/tests/integration/nixos/basics.nix @@ -82,17 +82,16 @@ logout_alice() - with subtest("GC root and profile"): - # There should be a GC root and Home Manager profile and they should point - # to the same path in the Nix store. - gcroot = "/home/alice/.local/state/home-manager/gcroots/current-home" - gcrootTarget = machine.succeed(f"readlink {gcroot}") + with subtest("no GC root and profile"): + # There should be no GC root and Home Manager profile since we are not + # using legacy profile management. + hmState = "/home/alice/.local/state/home-manager" + machine.succeed(f"test ! -e {hmState}") - profile = "/home/alice/.local/state/nix/profiles" - profileTarget = machine.succeed(f"readlink {profile}/home-manager") - profile1Target = machine.succeed(f"readlink {profile}/{profileTarget}") + hmProfile = "/home/alice/.local/state/nix/profiles/home-manager" + machine.succeed(f"test ! -e {hmProfile}") - assert gcrootTarget == profile1Target, \ - f"expected GC root and profile to point to same, but pointed to {gcrootTarget} and {profile1Target}" + hmGcroot = "/home/alice/.local/state/home-manager/gcroots/current-home" + machine.succeed(f"test ! -e {hmGcroot}") ''; } diff --git a/tests/integration/nixos/legacy-profile-management-new-conf.nix b/tests/integration/nixos/legacy-profile-management-new-conf.nix new file mode 100644 index 000000000..ff044faf0 --- /dev/null +++ b/tests/integration/nixos/legacy-profile-management-new-conf.nix @@ -0,0 +1,19 @@ +{ ... }: +{ + imports = [ ../../../nixos ]; # Import the HM NixOS module. + + system.stateVersion = "24.11"; + + users.users.alice = { + isNormalUser = true; + }; + + home-manager = { + users.alice = + { ... }: + { + home.stateVersion = "24.11"; + home.file.test.text = "testfile new profile"; + }; + }; +} diff --git a/tests/integration/nixos/legacy-profile-management.nix b/tests/integration/nixos/legacy-profile-management.nix new file mode 100644 index 000000000..1192badbe --- /dev/null +++ b/tests/integration/nixos/legacy-profile-management.nix @@ -0,0 +1,111 @@ +{ pkgs, ... }: + +{ + name = "nixos-legacy-profile-management"; + meta.maintainers = [ pkgs.lib.maintainers.rycee ]; + + nodes.machine = + { ... }: + { + imports = [ + # Make the nixpkgs channel available. + "${pkgs.path}/nixos/modules/installer/cd-dvd/channel.nix" + # Import the HM NixOS module. + ../../../nixos + ]; + + system.stateVersion = "24.11"; + + users.users.alice = { + isNormalUser = true; + }; + + specialisation = { + legacy.configuration = { + home-manager = { + # Force legacy profile management. + enableLegacyProfileManagement = true; + + users.alice = + { ... }: + { + home.stateVersion = "24.11"; + home.file.test.text = "testfile legacy"; + }; + }; + }; + + modern.configuration = { + home-manager = { + # Assert that we expect the option to default to false. + enableLegacyProfileManagement = pkgs.lib.mkOptionDefault false; + + users.alice = + { ... }: + { + home.stateVersion = "24.11"; + home.file.test.text = "testfile modern"; + }; + }; + }; + }; + }; + + testScript = + { nodes, ... }: + let + legacy = "${nodes.machine.system.build.toplevel}/specialisation/legacy"; + modern = "${nodes.machine.system.build.toplevel}/specialisation/modern"; + in + '' + start_all() + + machine.wait_for_unit("multi-user.target") + + machine.succeed("${legacy}/bin/switch-to-configuration test >&2") + machine.wait_for_console_text("Finished Home Manager environment for alice.") + + with subtest("Home Manager file"): + # The file should be linked with the expected content. + path = "/home/alice/test" + machine.succeed(f"test -L {path}") + actual = machine.succeed(f"cat {path}") + expected = "testfile legacy" + assert actual == expected, f"expected {path} to contain {expected}, but got {actual}" + + with subtest("GC root and profile"): + # There should be a GC root and Home Manager profile and they should point + # to the same path in the Nix store. + gcroot = "/home/alice/.local/state/home-manager/gcroots/current-home" + gcrootTarget = machine.succeed(f"readlink {gcroot}") + + profile = "/home/alice/.local/state/nix/profiles" + profileTarget = machine.succeed(f"readlink {profile}/home-manager") + profile1Target = machine.succeed(f"readlink {profile}/{profileTarget}") + + assert gcrootTarget == profile1Target, \ + f"expected GC root and profile to point to same, but pointed to {gcrootTarget} and {profile1Target}" + + with subtest("Switch to new profile management"): + machine.succeed("${modern}/bin/switch-to-configuration test >&2") + machine.wait_for_console_text("Finished Home Manager environment for alice.") + + # The file should be linked with the expected content. + path = "/home/alice/test" + machine.succeed(f"test -L {path}") + actual = machine.succeed(f"cat {path}") + expected = "testfile modern" + assert actual == expected, f"expected {path} to contain {expected}, but got {actual}" + + with subtest("Switch back to old profile management"): + machine.succeed("${legacy}/bin/switch-to-configuration test >&2") + machine.wait_for_console_text("Finished Home Manager environment for alice.") + + # The file should be linked with the expected content. + path = "/home/alice/test" + machine.succeed(f"test -L {path}") + actual = machine.succeed(f"cat {path}") + expected = "testfile legacy" + assert actual == expected, f"expected {path} to contain {expected}, but got {actual}" + ''; +} diff --git a/tests/integration/standalone/alice-home-specialisation.nix b/tests/integration/standalone/alice-home-specialisation.nix new file mode 100644 index 000000000..c1f46c43e --- /dev/null +++ b/tests/integration/standalone/alice-home-specialisation.nix @@ -0,0 +1,16 @@ +{ ... }: + +{ + home.username = "alice"; + home.homeDirectory = "/home/alice"; + home.stateVersion = "25.05"; + home.file.test.text = "test"; + home.sessionVariables.EDITOR = "emacs"; + programs.bash.enable = true; + programs.home-manager.enable = true; + + specialisation.pueue.configuration = { + # Enable a light-weight systemd service. + services.pueue.enable = true; + }; +} diff --git a/tests/integration/standalone/specialisation.nix b/tests/integration/standalone/specialisation.nix new file mode 100644 index 000000000..6f928c993 --- /dev/null +++ b/tests/integration/standalone/specialisation.nix @@ -0,0 +1,93 @@ +{ pkgs, ... }: + +{ + name = "standalone-specialisation"; + meta.maintainers = [ pkgs.lib.maintainers.rycee ]; + + nodes.machine = + { ... }: + { + imports = [ "${pkgs.path}/nixos/modules/installer/cd-dvd/channel.nix" ]; + virtualisation.memorySize = 2048; + users.users.alice = { + isNormalUser = true; + description = "Alice Foobar"; + password = "foobar"; + uid = 1000; + }; + }; + + testScript = '' + start_all() + machine.wait_for_unit("network.target") + machine.wait_for_unit("multi-user.target") + + home_manager = "${../../..}" + + def login_as_alice(): + machine.wait_until_tty_matches("1", "login: ") + machine.send_chars("alice\n") + machine.wait_until_tty_matches("1", "Password: ") + machine.send_chars("foobar\n") + machine.wait_until_tty_matches("1", "alice\\@machine") + + def logout_alice(): + machine.send_chars("exit\n") + + def alice_cmd(cmd): + return f"su -l alice --shell /bin/sh -c $'export XDG_RUNTIME_DIR=/run/user/$UID ; {cmd}'" + + def succeed_as_alice(cmd): + return machine.succeed(alice_cmd(cmd)) + + def fail_as_alice(cmd): + return machine.fail(alice_cmd(cmd)) + + # Create a persistent login so that Alice has a systemd session. + login_as_alice() + + # Set up a home-manager channel. + succeed_as_alice(" ; ".join([ + "mkdir -p /home/alice/.nix-defexpr/channels", + f"ln -s {home_manager} /home/alice/.nix-defexpr/channels/home-manager" + ])) + + with subtest("Home Manager installation"): + # Install the configuration with included specialisation. + succeed_as_alice("mkdir -p /home/alice/.config/home-manager") + succeed_as_alice("cp ${./alice-home-specialisation.nix} /home/alice/.config/home-manager/home.nix") + + # Install Home Manager with the unspecialised configuration. + succeed_as_alice("nix-shell \"\" -A install") + + # Ensure we are activated. + machine.succeed("test -L /home/alice/.cache/.keep") + + with subtest("Home Manager switch to missing specialisation"): + actual = fail_as_alice("home-manager switch --specialisation no-such-specialisation") + expected = "The configuration did not contain the specialisation \"no-such-specialisation\"" + assert expected in actual, \ + f"expected home-manager switch to contain {expected}, but got {actual}" + + with subtest("Home Manager switch to specialisation"): + actual = succeed_as_alice("home-manager switch --specialisation pueue") + expected = "Starting units: pueued.service" + assert expected in actual, \ + f"expected home-manager switch to contain {expected}, but got {actual}" + + actual = succeed_as_alice("pueue status") + expected = "running" + assert expected in actual, \ + f"expected pueue status to contain {expected}, but got {actual}" + + with subtest("Home Manager switch back to base configuration"): + actual = succeed_as_alice("home-manager switch") + expected = "Stopping units: pueued.service" + assert expected in actual, \ + f"expected home-manager switch to contain {expected}, but got {actual}" + + fail_as_alice("pueue status") + + logout_alice() + ''; +} diff --git a/xgettext b/xgettext index f0440f6f7..133875e45 100755 --- a/xgettext +++ b/xgettext @@ -1,5 +1,5 @@ #! /usr/bin/env nix-shell -#! nix-shell -I https://github.com/NixOS/nixpkgs/archive/62b852f6c6742134ade1abdd2a21685fd617a291.tar.gz -i bash -p gettext +#! nix-shell -I nixpkgs=https://github.com/NixOS/nixpkgs/archive/62b852f6c6742134ade1abdd2a21685fd617a291.tar.gz -i bash -p gettext set -euo pipefail shopt -s globstar