aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-13 21:09:04 +0000
committertv <tv@krebsco.de>2026-11-13 22:14:38 +0100
commit0273a55087e2614323d6ebd145b08c56b370a483 (patch)
tree14c5e7ea01a862d2d359319236c54d9760ea1ddd
parentc3ca6d4b991ea24192ac4ddec156d0530b462c3e (diff)
populate: check the hooks and submodules of Git work trees
git submodule update uses the .git of a submodule as it finds it in the work tree: a repository of its own, with configuration that runs commands, or a file naming a repository elsewhere. So git_problems also requires each .git below the work tree to be a file naming a repository in .git/modules. And it refuses hooks in .git/hooks, which populate's Git doesn't run, but Nix's does, writing the lock file of a flake in a git source. It's a shell function now, taking the work tree at run time, so drawing inputs can use it too. git-submodule plants a submodule's .git in the work tree: a repository of its own whose configuration names a filter, and a file naming a repository elsewhere. The fixture withsub has gitsrc as its submodule sub.
-rw-r--r--CHANGELOG.md3
-rw-r--r--README.md6
-rw-r--r--pkgs/populate/default.nix140
-rwxr-xr-xtests/fixtures.sh17
-rw-r--r--tests/scenarios.nix60
5 files changed, 171 insertions, 55 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 767652b..3875460 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,9 @@
- Git can't take a `git` source's `url` or `ref`, or a `pass` source's
`name`, for an option.
+- `git` sources also refuse work trees whose `.git` directory contains
+ hooks, or in which a submodule's `.git` isn't a file naming a repository
+ in `.git/modules`, unless `reclone` is set.
- `git` sources with a branch or tag as `ref` follow it: populate fetches
the branch or tag on every run, and resets the work tree to what it
fetched.
diff --git a/README.md b/README.md
index 3bef68c..7344135 100644
--- a/README.md
+++ b/README.md
@@ -316,8 +316,10 @@ everything else. It doesn't change or remove anything outside:
`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
+ symlinks, no `commondir` file, no linked work trees, and no hooks, and
+ only the configuration that `git clone` and populate write, and in which
+ each `.git` of a submodule is a file naming a repository in
+ `.git/modules`. 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.
diff --git a/pkgs/populate/default.nix b/pkgs/populate/default.nix
index dd0beab..b724b23 100644
--- a/pkgs/populate/default.nix
+++ b/pkgs/populate/default.nix
@@ -157,14 +157,16 @@ 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";
+ # Shell code defining git_problems PATH, which prints 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, no hooks,
+ # 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. And each .git below the work tree, which Git
+ # uses for a submodule there, is a file naming a repository in
+ # .git/modules.
+ gitProblems = let
# Keys of the configuration git clone writes, as patterns of case.
keys = [
"core.repositoryformatversion" "core.filemode" "core.bare"
@@ -175,49 +177,81 @@ let
"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 || :
- )'';
+ in /* sh */ ''
+ git_problems() (
+ path=$1
+ git_dir=$1/.git
+ links=$(find "$git_dir" -type l | head -n 3 | paste -s -d ' ' -)
+ if test -n "$links"; then
+ printf '%s contains symlinks: %s' "$git_dir" "$links"
+ exit
+ fi
+ common=$(find "$git_dir" -name commondir | head -n 1)
+ if test -n "$common"; then
+ printf '%s names another repository' "$common"
+ exit
+ fi
+ if test -e "$git_dir"/worktrees; then
+ printf '%s has linked work trees' "$git_dir"
+ exit
+ fi
+ if test -d "$git_dir"/hooks; then
+ hooks=$(find "$git_dir"/hooks -mindepth 1 ! -name '*.sample' | head -n 3 | paste -s -d ' ' -)
+ if test -n "$hooks"; then
+ printf '%s contains hooks: %s' "$git_dir" "$hooks"
+ exit
+ fi
+ fi
+ nested=$(
+ find "$path" -path "$git_dir" -prune -o -name .git -exec sh -c '
+ modules=$1
+ shift
+ for git; do
+ if ! test -L "$git" && test -f "$git"; then
+ gitdir=$(sed -n "s/^gitdir: //p" "$git")
+ resolved=$(cd "''${git%/.git}" && cd "$gitdir" 2>/dev/null && pwd -P) || :
+ case $resolved in
+ "$modules"/?*) continue ;;
+ esac
+ fi
+ printf "%s\n" "$git"
+ done
+ ' sh "$(cd "$git_dir" && pwd -P)/modules" {} + | head -n 1
+ )
+ if test -n "$nested"; then
+ printf '%s is not a file naming a repository in %s/modules' "$nested" "$git_dir"
+ exit
+ fi
+ find "$git_dir" -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
+ "$git_dir"/modules/*)
+ resolved=$(cd "$dir" && cd "$worktree" 2>/dev/null && pwd -P) || :
+ case $resolved in
+ "$(cd "$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";
@@ -1164,6 +1198,7 @@ let
git() {
command git -c core.hooksPath=/dev/null -c core.fsmonitor=false "$@"
}
+ ${gitProblems}
# 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
@@ -1179,8 +1214,7 @@ let
# 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
+ if problems=$(git_problems ${quote target.path}); then :; else
exit 1
fi
if test -n "$problems"; then
diff --git a/tests/fixtures.sh b/tests/fixtures.sh
index ccd55a0..85701a1 100755
--- a/tests/fixtures.sh
+++ b/tests/fixtures.sh
@@ -307,6 +307,23 @@ mkdir /fixtures/git
git clone -q --bare /fixtures/gitsrc /fixtures/git/gitsrc.git
git -C /fixtures/git/gitsrc.git config uploadpack.allowReachableSHA1InWant true
+# withsub: a Git repository with gitsrc as its submodule sub, served by git
+# daemon like gitsrc, which the tests reach as git://deployer/gitsrc.git.
+mkdir /fixtures/withsub
+cat > /fixtures/withsub/.gitmodules <<'GIT'
+[submodule "sub"]
+ path = sub
+ url = git://deployer/gitsrc.git
+GIT
+(
+ cd /fixtures/withsub
+ git init -q -b master
+ git update-index --add --cacheinfo 160000,"$git_head",sub
+ git add .gitmodules
+ git commit -q -m withsub
+)
+git clone -q --bare /fixtures/withsub /fixtures/git/withsub.git
+
# pass: password stores with x/a, without and with Git.
if ! gpg --list-secret-keys spam@krebsco.de >/dev/null 2>&1; then
gpg --batch --passphrase '' --quick-gen-key spam@krebsco.de default default never
diff --git a/tests/scenarios.nix b/tests/scenarios.nix
index 8093e02..5d08e2e 100644
--- a/tests/scenarios.nix
+++ b/tests/scenarios.nix
@@ -2104,6 +2104,66 @@ assert !(krops.isLocalTarget (targets.remote "/"));
}
];
}))
+ # A Git work tree whose submodule's .git, which Git uses as it finds it,
+ # isn't the gitfile git clone made, naming the repository in
+ # .git/modules: a repository of its own, whose configuration names a
+ # command, and a gitfile naming a repository elsewhere.
+ (s "git-submodule" (let
+ elsewhere = "/var/krops-elsewhere";
+ url = dirOf gitUrl + "/withsub.git";
+ git = reclone: { x.git = { inherit url reclone; ref = "master"; }; };
+ untouched = ''
+ test ! -e ${elsewhere}/ran || fail "a command in a .git directory ran"
+ expect_eq "HEAD elsewhere" "$(git -C ${elsewhere}/repo rev-parse HEAD)" ${gitFirst}
+ '';
+ in {
+ isolate = true;
+ prepare = ''
+ rm -fR ${elsewhere}
+ git clone -q ${gitUrl} ${elsewhere}/repo
+ git -C ${elsewhere}/repo checkout -q ${gitFirst}
+ '';
+ phases = [
+ {
+ sources = git false;
+ check = untouched + ''
+ expect_eq "sub HEAD" "$(git -C "$T"/x/sub rev-parse HEAD)" ${gitHead}
+ '';
+ mutate = ''
+ rm "$T"/x/sub/.git
+ cp -R "$T"/x/.git/modules/sub "$T"/x/sub/.git
+ git config --file "$T"/x/sub/.git/config --unset core.worktree
+ git config --file "$T"/x/sub/.git/config \
+ filter.x.smudge "touch ${elsewhere}/ran; cat"
+ echo '* filter=x' > "$T"/x/sub/.git/info/attributes
+ git -C "$T"/x/sub update-ref --no-deref HEAD ${gitFirst}
+ git -C "$T"/x/sub read-tree ${gitFirst}
+ rm "$T"/x/sub/two
+ '';
+ }
+ {
+ sources = git false;
+ expect.fail = "error: populate doesn't use the Git work tree";
+ check = untouched;
+ mutate = ''
+ rm -fR "$T"/x/sub
+ mkdir "$T"/x/sub
+ echo gitdir: ${elsewhere}/repo/.git > "$T"/x/sub/.git
+ '';
+ }
+ {
+ sources = git false;
+ expect.fail = "error: populate doesn't use the Git work tree";
+ check = untouched;
+ }
+ {
+ sources = git true;
+ check = untouched + ''
+ expect_eq "sub HEAD" "$(git -C "$T"/x/sub rev-parse HEAD)" ${gitHead}
+ '';
+ }
+ ];
+ }))
# A ref that git fetch would take for an option: --upload-pack runs a
# command where the repository is local.
(s "git-ref-option" (let