diff options
| -rw-r--r-- | README.md | 12 | ||||
| -rw-r--r-- | lib/types/populate.nix | 4 | ||||
| -rw-r--r-- | pkgs/populate/default.nix | 95 | ||||
| -rw-r--r-- | tests/scenarios.nix | 86 |
4 files changed, 194 insertions, 3 deletions
@@ -309,6 +309,13 @@ everything else. It doesn't change or remove anything outside: is a symlink, or a file naming a repository elsewhere, gets replaced by a `git` source as well. +* A `git` source only uses a work tree whose `.git` directory contains no + symlinks, no `commondir` file and no linked work trees, and only the + configuration that `git clone` and populate write. Otherwise Git's + commands could change what's elsewhere, or run commands, so populating + fails, telling what's wrong, unless the source's `reclone` is set. Git + runs no hooks. + * Populate keeps its state in the sentinel, `.populate`, which becomes a directory once there is state to keep. Populating fails if the sentinel, or a directory populate needs therein, is a symlink. @@ -556,6 +563,11 @@ Supported attributes: boolean that controls whether only the requested commit ref. should be fetched instead of the whole history, to save disk space and bandwith. Defaults to `false`. +* `reclone` (optional) - + boolean that controls whether a work tree populate can't use, see + [The target directory](#the-target-directory), gets replaced by a fresh + clone, instead of failing. Defaults to `false`. + * `inputs` (optional) - inputs of the flake in this source to draw from other sources, see [Flake inputs from sources](#flake-inputs-from-sources). diff --git a/lib/types/populate.nix b/lib/types/populate.nix index 1a18b62..56b0292 100644 --- a/lib/types/populate.nix +++ b/lib/types/populate.nix @@ -258,6 +258,10 @@ ref = lib.mkOption { type = lib.types.str; # TODO lib.types.git.ref }; + reclone = lib.mkOption { + default = false; + type = lib.types.bool; + }; rewriteLock = lib.mkOption { default = true; type = lib.types.bool; diff --git a/pkgs/populate/default.nix b/pkgs/populate/default.nix index c707b7c..7130570 100644 --- a/pkgs/populate/default.nix +++ b/pkgs/populate/default.nix @@ -142,6 +142,68 @@ let fi ''; + # Shell code printing why populate can't use the .git directory of the + # Git work tree at path, or nothing. A .git directory that git clone and + # populate made has no symlinks, no commondir file, which names another + # repository, no linked work trees, and only the configuration they write: + # the repository's format, the remote, branches, and submodules, whose + # repositories have their work tree in the work tree. + gitProblems = path: let + gitDir = "${path}/.git"; + # Keys of the configuration git clone writes, as patterns of case. + keys = [ + "core.repositoryformatversion" "core.filemode" "core.bare" + "core.logallrefupdates" "core.symlinks" "core.ignorecase" + "core.precomposeunicode" + "extensions.objectformat" "extensions.refstorage" + "remote.origin.url" "remote.origin.fetch" + "branch.*.remote" "branch.*.merge" + "submodule.active" "submodule.*.url" "submodule.*.active" + ]; + in /* sh */ ''( + links=$(find ${quote gitDir} -type l | head -n 3 | paste -s -d ' ' -) + if test -n "$links"; then + printf '%s contains symlinks: %s' ${quote gitDir} "$links" + exit + fi + common=$(find ${quote gitDir} -name commondir | head -n 1) + if test -n "$common"; then + printf '%s names another repository' "$common" + exit + fi + if test -e ${quote gitDir}/worktrees; then + printf '%s has linked work trees' ${quote gitDir} + exit + fi + find ${quote gitDir} -name config -type f | while IFS= read -r config; do + command git config --file "$config" --list --name-only | + while IFS= read -r key; do + case $key in + ${concatStringsSep "|" keys}) ;; + core.worktree) + # Submodules' repositories have their work tree in the work tree. + worktree=$(command git config --file "$config" core.worktree) + dir=$(dirname "$config") + case $dir/$worktree in + ${quote gitDir}/modules/*) + resolved=$(cd "$dir" && cd "$worktree" 2>/dev/null && pwd -P) || : + case $resolved in + "$(cd ${quote path} && pwd -P)"/*) continue ;; + esac + ;; + esac + printf '%s sets core.worktree to %s' "$config" "$worktree" + exit 1 + ;; + *) + printf '%s sets %s, which neither git clone nor populate does' "$config" "$key" + exit 1 + ;; + esac + done || exit 1 + done || : + )''; + check = { force, target }: let sentinelFile = "${target.path}/.populate"; in runShell target /* sh */ '' @@ -1000,6 +1062,12 @@ let pop.git = target: source: runShell target /* sh */ '' set -efu + # Hooks and commands a repository's configuration names run with Git's + # commands, so populate's Git doesn't run any. The configuration gets + # checked as well, but Git also runs a hook it has itself. + git() { + command git -c core.hooksPath=/dev/null -c core.fsmonitor=false "$@" + } # Remove target path if it doesn't look like a git worktree. # This can happen e.g. when it had a different type earlier. A work # tree that a symlink points to, or whose .git is a symlink or a file @@ -1010,6 +1078,27 @@ let ! test -d ${quote target.path}/.git; then rm -fR ${quote target.path} fi + # A .git directory can name places elsewhere too, which Git's commands + # change, or commands, which they run. Populate only uses a work tree + # whose .git directory has nothing like that, and else replaces it if + # reclone is set, or fails, telling why. + if test -e ${quote target.path}; then + # Not $((, which would be arithmetic. + if problems=$( ${gitProblems target.path} ); then :; else + exit 1 + fi + if test -n "$problems"; then + ${if source.reclone then /* sh */ '' + >&2 printf 'replacing the Git work tree %s, because %s\n' \ + ${quote target.path} "$problems" + rm -fR ${quote target.path} + '' else /* sh */ '' + >&2 printf 'error: populate doesn'"'"'t use the Git work tree %s, because %s. Fix that, remove the work tree, or set the source'"'"'s reclone to true to replace it by a fresh clone.\n' \ + ${quote target.path} "$problems" + exit 1 + ''} + fi + fi if ! test -e ${quote target.path}; then ${if source.shallow then /* sh */ '' git init ${quote target.path} @@ -1043,9 +1132,11 @@ let ''} fi ''} - git reset --hard "$hash" >&2 - git submodule update --init --recursive fi + # Also if HEAD is there already: rewriting a lock file in place, e.g., + # changes the work tree, and stages the lock file if Nix created it. + git reset --hard "$hash" >&2 + git submodule update --init --recursive git clean -dfx \ ${concatMapStringsSep " " diff --git a/tests/scenarios.nix b/tests/scenarios.nix index 4429409..6e1f89b 100644 --- a/tests/scenarios.nix +++ b/tests/scenarios.nix @@ -586,7 +586,29 @@ assert !(krops.isLocalTarget (targets.remote "/")); ''; } ]; - })) targetKinds; + })) targetKinds + # A git source stops drawing inputs: its lock file, which Nix created and + # staged, goes, so the work tree is as the commit has it. + ++ map (targetKind: scenario { + name = "git-${targetKind}-dropInputs"; + inherit targetKind; + phases = [ + { + sources = alt // { + x.git = { url = gitUrl; ref = gitHead; inputs.dep.follows = "alt"; }; + }; + check = '' + expect_eq msg "$(nix_eval_online git+file:"$T"/x#msg)" git:alt + ''; + } + { + sources = alt // { x.git = { url = gitUrl; ref = gitHead; }; }; + check = '' + expect_eq status "$(git -C "$T"/x status --porcelain)" "" + ''; + } + ]; + }) targetKinds; pass = matrix "pass" { store = [ "pass" "pass-git" ]; @@ -1929,6 +1951,68 @@ assert !(krops.isLocalTarget (targets.remote "/")); ''; }; }) + # Git work trees whose .git directory names places elsewhere, or a + # command: a work tree elsewhere, a configuration file elsewhere, another + # repository, and a command run when Git checks for changes. Populate + # refuses them, telling why, and replaces them by fresh clones with + # reclone. A hook doesn't run. + (s "git-foreign" (let + elsewhere = "/var/krops-elsewhere"; + git = name: reclone: { + ${name}.git = { url = gitUrl; ref = gitHead; inherit reclone; }; + }; + untouched = '' + cmp -s ${elsewhere}/config ${elsewhere}/config.orig || + fail "what b/.git/config links to changed" + test ! -e ${elsewhere}/ran || fail "a command in a .git directory ran" + ''; + refused = name: message: { + sources = git name false; + expect.fail = "error: populate doesn't use the Git work tree /var/krops/git-foreign-${targetKind}/${name}, because ${message}. Fix that, remove the work tree, or set the source's reclone to true to replace it by a fresh clone."; + }; + in { + isolate = true; + prepare = '' + rm -fR ${elsewhere} + mkdir -p ${elsewhere} "$T" + for name in a b c d e; do + git clone -q ${gitUrl} "$T"/$name + git -C "$T"/$name checkout -q ${gitFirst} + done + git -C "$T"/a config core.worktree ${outside}/dir + mv "$T"/b/.git/config ${elsewhere}/config + ln -s ${elsewhere}/config "$T"/b/.git/config + cp -p ${elsewhere}/config ${elsewhere}/config.orig + printf '#! /bin/sh\ntouch ${elsewhere}/ran\n' > "$T"/c/.git/hooks/reference-transaction + chmod +x "$T"/c/.git/hooks/reference-transaction + echo ${outside}/repo/.git > "$T"/d/.git/commondir + git -C "$T"/e config core.fsmonitor "touch ${elsewhere}/ran" + ''; + phases = [ + (refused "a" "/var/krops/git-foreign-${targetKind}/a/.git/config sets core.worktree to ${outside}/dir") + (refused "b" "/var/krops/git-foreign-${targetKind}/b/.git contains symlinks: /var/krops/git-foreign-${targetKind}/b/.git/config") + { + sources = git "c" false; + check = untouched + '' + expect_eq "c HEAD" "$(git -C "$T"/c rev-parse HEAD)" ${gitHead} + ''; + } + (refused "d" "/var/krops/git-foreign-${targetKind}/d/.git/commondir names another repository") + (refused "e" "/var/krops/git-foreign-${targetKind}/e/.git/config sets core.fsmonitor, which neither git clone nor populate does") + { + sources = git "a" true // git "b" true // git "c" true // git "d" true // git "e" true; + output = map + (name: "replacing the Git work tree /var/krops/git-foreign-${targetKind}/${name}, because") + [ "a" "b" "d" "e" ]; + check = untouched + '' + for name in a b c d e; do + expect_eq "$name HEAD" "$(git -C "$T"/$name rev-parse HEAD)" ${gitHead} + test -e "$T"/$name/two || fail "$name/two missing" + done + ''; + } + ]; + })) (lib.optionals (targetKind == "local") [ # Rewriting a lock file changes no other hard link to it. The lock # file is like the source's, with its modification time, so rsync |
