aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md12
-rw-r--r--lib/types/populate.nix4
-rw-r--r--pkgs/populate/default.nix95
-rw-r--r--tests/scenarios.nix86
4 files changed, 194 insertions, 3 deletions
diff --git a/README.md b/README.md
index 1b7e29e..969afcd 100644
--- a/README.md
+++ b/README.md
@@ -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