Splux Linux

source-based Linux using SPS

SPS · log · files · refs · tags · releases · people · languages · GitHub · commit

8fbfde3


src: add conservative Git and directory repository handling

Date
Commit8fbfde3920b2cb6d9935b00b9b8355cf277825bf
GitHubmirror

Parents

Stat

 .github/workflows/ci.yml |  24 +++++
 .gitignore               |   2 +-
 Makefile                 |  99 +++++++++++++++++++-
 bin/mkpkg                |  30 ++++--
 bin/sget                 |   8 +-
 bin/src                  | 237 +++++++++++++++++++++++++++++++++++++++--------
 examples/README          |  10 +-
 examples/repos.conf      |  13 ++-
 examples/sps.conf        |   1 +
 lib/common.sh            | 212 +++++++++++++++++++++++++++++++++++++++---
 lib/config.awk           |  19 ++--
 lib/deps.awk             |  41 +++++++-
 lib/index.awk            |  11 ++-
 lib/recipe.awk           |  26 +++++-
 lib/relations.awk        |  17 ++--
 lib/repository.awk       | 105 ++++++++++++++++-----
 man/mkpkg.1              |  46 ++++++++-
 man/sps.conf.5           |  34 ++++++-
 man/src.1                |  33 +++++--
 19 files changed, 840 insertions(+), 128 deletions(-)

Diff

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..471632a
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,24 @@
+name: check
+
+on:
+  push:
+  pull_request:
+
+permissions:
+  contents: read
+
+jobs:
+  check:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Check out source
+        uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+      - name: Run POSIX shell and AWK tests
+        shell: sh
+        run: make check
+      - name: Check tagged release archive
+        if: startsWith(github.ref, 'refs/tags/v')
+        shell: sh
+        run: make release-check
diff --git a/.gitignore b/.gitignore
index eea5120..3f084a0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,4 +4,4 @@
 *.pkg.tar.zst
 *.tmp
 .sps-*
-
+/dist/
diff --git a/Makefile b/Makefile
index a08bda7..547f49b 100644
--- a/Makefile
+++ b/Makefile
@@ -9,7 +9,8 @@ DESTDIR =
 PROGRAMS = src sget mkpkg pkin pkdel pkstat pkcheck pkmark
 AWK_LIBS = common.awk config.awk db.awk deps.awk index.awk package.awk recipe.awk repository.awk relations.awk version.awk
 
-.PHONY: all check lint install install-config clean
+.PHONY: all check lint check-version source-archive release-check \
+	install install-config clean
 
 all:
 	@printf '%s\n' "SPS is interpreted; run 'make check' or 'make install'."
@@ -32,6 +33,101 @@ lint:
 	@awk -f lib/common.awk -f lib/config.awk </dev/null >/dev/null
 	@awk -f lib/version.awk </dev/null >/dev/null
 
+check-version:
+	@LC_ALL=C awk '\
+		NR == 1 && $$0 ~ /^[0-9]+\.[0-9]+\.[0-9]+$$/ { valid = 1 } \
+		END { exit !(NR == 1 && valid) } \
+	' VERSION || { \
+		printf '%s\n' 'VERSION must contain one X.Y.Z release number' >&2; \
+		exit 1; \
+	}
+
+# Build from the committed tree so untracked files and local edits can never
+# enter a published source archive. gzip -n omits its timestamp and filename.
+source-archive: check-version
+	@set -eu; \
+	version=$$(sed -n '1p' VERSION); \
+	for tool in git gzip mktemp; do \
+		command -v "$$tool" >/dev/null 2>&1 || { \
+			printf 'source-archive: required tool not found: %s\n' "$$tool" >&2; \
+			exit 1; \
+		}; \
+	done; \
+	git rev-parse --is-inside-work-tree >/dev/null 2>&1 || { \
+		printf '%s\n' 'source-archive: not inside a Git work tree' >&2; \
+		exit 1; \
+	}; \
+	mkdir -p dist; \
+	archive="dist/sps-$$version.tar.gz"; \
+	tmp_tar=$$(mktemp "$${TMPDIR:-/tmp}/sps-source.XXXXXX"); \
+	tmp_archive="$$archive.tmp"; \
+	trap 'rm -f "$$tmp_tar" "$$tmp_archive"' 0 HUP INT TERM; \
+	git archive --format=tar --prefix="sps-$$version/" \
+		--output="$$tmp_tar" HEAD; \
+	gzip -n -9 -c "$$tmp_tar" >"$$tmp_archive"; \
+	mv "$$tmp_archive" "$$archive"; \
+	printf '%s\n' "$$archive"
+
+# Run this after creating vVERSION and before pushing the tag. It verifies
+# that the tag, committed VERSION, work tree, and reproducible archive agree.
+release-check: check source-archive
+	@set -eu; \
+	version=$$(sed -n '1p' VERSION); \
+	tag="v$$version"; \
+	archive="dist/sps-$$version.tar.gz"; \
+	prefix="sps-$$version/"; \
+	if [ -n "$$(git status --porcelain --untracked-files=normal)" ]; then \
+		printf '%s\n' 'release-check: Git work tree is not clean' >&2; \
+		exit 1; \
+	fi; \
+	git diff --check; \
+	tag_commit=$$(git rev-parse -q --verify "$$tag^{commit}") || { \
+		printf 'release-check: tag %s does not exist\n' "$$tag" >&2; \
+		exit 1; \
+	}; \
+	[ "$$(git cat-file -t "$$tag")" = tag ] || { \
+		printf 'release-check: tag %s is not annotated\n' "$$tag" >&2; \
+		exit 1; \
+	}; \
+	head_commit=$$(git rev-parse HEAD); \
+	[ "$$tag_commit" = "$$head_commit" ] || { \
+		printf 'release-check: tag %s does not point to HEAD\n' "$$tag" >&2; \
+		exit 1; \
+	}; \
+	gzip -t "$$archive"; \
+	members=$$(mktemp "$${TMPDIR:-/tmp}/sps-members.XXXXXX"); \
+	repro_tar=$$(mktemp "$${TMPDIR:-/tmp}/sps-repro.XXXXXX"); \
+	repro_archive=$$(mktemp "$${TMPDIR:-/tmp}/sps-repro-gz.XXXXXX"); \
+	trap 'rm -f "$$members" "$$repro_tar" "$$repro_archive"' \
+		0 HUP INT TERM; \
+	tar -tzf "$$archive" >"$$members"; \
+	awk -v prefix="$$prefix" '\
+		index($$0, prefix) != 1 { bad = 1 } \
+		END { exit bad || NR == 0 } \
+	' "$$members" || { \
+		printf '%s\n' 'release-check: archive has an invalid member path' >&2; \
+		exit 1; \
+	}; \
+	for required in VERSION Makefile bin/src; do \
+		grep -F -x "$$prefix$$required" "$$members" >/dev/null || { \
+			printf 'release-check: archive is missing %s\n' "$$required" >&2; \
+			exit 1; \
+		}; \
+	done; \
+	archive_version=$$(tar -xOzf "$$archive" "$$prefix"VERSION); \
+	[ "$$archive_version" = "$$version" ] || { \
+		printf '%s\n' 'release-check: archive VERSION does not match' >&2; \
+		exit 1; \
+	}; \
+	git archive --format=tar --prefix="$$prefix" \
+		--output="$$repro_tar" HEAD; \
+	gzip -n -9 -c "$$repro_tar" >"$$repro_archive"; \
+	cmp "$$archive" "$$repro_archive" >/dev/null || { \
+		printf '%s\n' 'release-check: source archive is not reproducible' >&2; \
+		exit 1; \
+	}; \
+	printf 'release-check: %s is ready\n' "$$archive"
+
 install:
 	install -d "$(DESTDIR)$(BINDIR)" "$(DESTDIR)$(LIBDIR)" \
 		"$(DESTDIR)$(MANDIR)/man1" "$(DESTDIR)$(MANDIR)/man5"
@@ -65,4 +161,3 @@ install-config:
 clean:
 	@find . -type f \( -name '*.pkg.tar' -o -name '*.pkg.tar.gz' -o \
 		-name '*.pkg.tar.xz' -o -name '*.pkg.tar.zst' \) -exec rm -f {} \;
-
diff --git a/bin/mkpkg b/bin/mkpkg
index a06beae..ca04b67 100755
--- a/bin/mkpkg
+++ b/bin/mkpkg
@@ -183,6 +183,8 @@ command -v mktemp >/dev/null 2>&1 || die "mktemp is required"
 recipe_dir=$(CDPATH= cd "$(dirname "$recipe")" 2>/dev/null && pwd) ||
 	die "cannot enter recipe directory"
 recipe_file=$recipe_dir/$(basename "$recipe")
+sps_definition_sha256 "$recipe_file" >/dev/null ||
+	die_status 4 "invalid package definition support inputs"
 default_arch=${SPS_ARCH:-}
 if [ -z "$default_arch" ]; then
     default_arch=$(uname -m 2>/dev/null) || die "cannot determine build architecture"
@@ -283,6 +285,18 @@ function replace(text, token, value, at, out) {
 	}
 	return out text
 }
+function expand(value, pass, previous) {
+	for (pass = 1; pass <= 8; pass++) {
+		previous = value
+		value = replace(value, "${name}", value_for["name"])
+		value = replace(value, "${version}", value_for["version"])
+		value = replace(value, "${release}", value_for["release"])
+		value = replace(value, "${arch}", value_for["arch"])
+		if (value == previous)
+			break
+	}
+	return value
+}
 NR == FNR {
 	key = $1
 	value = substr($0, index($0, "\t") + 1)
@@ -307,10 +321,7 @@ NR == FNR {
 	}
 	key = $1
 	value = substr($0, index($0, "\t") + 1)
-	value = replace(value, "${name}", value_for["name"])
-	value = replace(value, "${version}", value_for["version"])
-	value = replace(value, "${release}", value_for["release"])
-	value = replace(value, "${arch}", value_for["arch"])
+	value = expand(value)
 	print key "\t" value
 }
 END {
@@ -352,6 +363,12 @@ for field in name version release arch; do
 				die_status 4 "invalid $field '$value'"
 				;;
 		esac
+	elif [ "$field" = arch ]; then
+		case $value in
+			''|*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._+-]*)
+				die_status 4 "invalid $field '$value'"
+				;;
+		esac
 	else
 		case $value in
 			''|*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._+~-]*)
@@ -507,8 +524,9 @@ while IFS='	' read -r source hash_record; do
 done <"$source_pairs"
 
 for support in files patches; do
-	if [ -d "$recipe_dir/$support" ] || [ -L "$recipe_dir/$support" ]; then
-		[ -d "$recipe_dir/$support" ] || die "$recipe_dir/$support is not a directory"
+	if [ -e "$recipe_dir/$support" ] || [ -L "$recipe_dir/$support" ]; then
+		[ -d "$recipe_dir/$support" ] && [ ! -L "$recipe_dir/$support" ] ||
+			die_status 4 "recipe $support must be a real directory"
 		if [ -e "$source_dir/$support" ] || [ -L "$source_dir/$support" ]; then
 			die_status 4 "source conflicts with recipe $support directory"
 		fi
diff --git a/bin/sget b/bin/sget
index b0c735b..5816423 100755
--- a/bin/sget
+++ b/bin/sget
@@ -380,10 +380,10 @@ sget_install()
             [ -n "$sget_action" ] || continue
             [ -f "$sget_recipe" ] ||
                 sps_die "$SPS_EX_NOTFOUND" "recipe for '$sget_package' is missing: $sget_recipe"
-            sget_current_hash=$(sps_sha256 "$sget_recipe") ||
-                sps_die "$SPS_EX_CHECKSUM" "cannot hash recipe for '$sget_package'"
-            [ "$sget_current_hash" = "$sget_recipe_hash" ] ||
-                sps_die "$SPS_EX_CHECKSUM" "recipe for '$sget_package' changed since indexing; run 'src update'"
+			sget_current_hash=$(sps_definition_sha256 "$sget_recipe") ||
+				sps_die "$SPS_EX_CHECKSUM" "cannot hash package definition for '$sget_package'"
+			[ "$sget_current_hash" = "$sget_recipe_hash" ] ||
+				sps_die "$SPS_EX_CHECKSUM" "package definition for '$sget_package' changed since indexing; run 'src update'"
             sget_recipe_dir=${sget_recipe%/*}
             sget_recipe_name=${sget_recipe##*/}
 
diff --git a/bin/src b/bin/src
index 3cac1aa..f91cc16 100755
--- a/bin/src
+++ b/bin/src
@@ -39,12 +39,150 @@ src_need_index()
 
 src_cleanup_update()
 {
-    [ -n "${src_work:-}" ] || return
-    rm -f "$src_work/repos.raw" "$src_work/repos" \
-          "$src_work/recipes.unsorted" "$src_work/recipes" \
-          "$src_work/packages.index"
-    rmdir "$src_work" 2>/dev/null || :
-    src_work=
+	if [ -n "${src_clone_work:-}" ]; then
+		rm -rf "$src_clone_work"
+		src_clone_work=
+	fi
+	if [ -n "${src_work:-}" ]; then
+		rm -f "$src_work/repos.raw" "$src_work/repos" \
+		      "$src_work/recipes.unsorted" "$src_work/recipes" \
+		      "$src_work/packages.index"
+		rmdir "$src_work" 2>/dev/null || :
+		src_work=
+	fi
+	sps_lock_release
+}
+
+src_git_verify_checkout()
+{
+	src_verify_checkout=$1
+	src_verify_source=$2
+
+	[ -d "$src_verify_checkout" ] && [ ! -L "$src_verify_checkout" ] ||
+		return 1
+	src_verify_top=$(git -C "$src_verify_checkout" rev-parse --show-toplevel \
+		2>/dev/null) || return 1
+	src_verify_top=$(CDPATH= cd -P "$src_verify_top" 2>/dev/null && pwd -P) ||
+		return 1
+	src_verify_expected=$(CDPATH= cd -P "$src_verify_checkout" 2>/dev/null &&
+		pwd -P) || return 1
+	[ "$src_verify_top" = "$src_verify_expected" ] || return 1
+	src_verify_origin=$(git -C "$src_verify_checkout" remote get-url origin \
+		2>/dev/null) || return 1
+	[ "$src_verify_origin" = "$src_verify_source" ]
+}
+
+src_sync_git()
+{
+	src_git_name=$1
+	src_git_source=$2
+	src_git_checkout=$3
+	sps_require_cmd git
+
+	src_git_parent=${src_git_checkout%/*}
+	[ -n "$src_git_parent" ] || src_git_parent=/
+	sps_mkdir "$src_git_parent"
+
+	if [ ! -e "$src_git_checkout" ] && [ ! -L "$src_git_checkout" ]; then
+		src_clone_work=$(sps_mktemp_dir "$src_git_parent" \
+			"clone-$src_git_name")
+		src_clone_checkout=$src_clone_work/repository
+		if ! git clone -- "$src_git_source" "$src_clone_checkout"; then
+			sps_die "$SPS_EX_NETWORK" \
+				"cannot clone repository '$src_git_name'"
+		fi
+		if ! src_git_verify_checkout "$src_clone_checkout" "$src_git_source"; then
+			sps_die "$SPS_EX_PACKAGE" \
+				"cloned repository '$src_git_name' failed checkout verification"
+		fi
+		if [ -e "$src_git_checkout" ] || [ -L "$src_git_checkout" ]; then
+			sps_die "$SPS_EX_CONFLICT" \
+				"repository checkout appeared while cloning: $src_git_checkout"
+		fi
+		if ! mv "$src_clone_checkout" "$src_git_checkout"; then
+			sps_die "$SPS_EX_PERMISSION" \
+				"cannot commit repository checkout: $src_git_checkout"
+		fi
+		rmdir "$src_clone_work" 2>/dev/null || :
+		src_clone_work=
+		printf "%s: cloned repository '%s' into %s\n" \
+			"$sps_prog" "$src_git_name" "$src_git_checkout" >&2
+		return 0
+	fi
+
+	if ! src_git_verify_checkout "$src_git_checkout" "$src_git_source"; then
+		if [ -d "$src_git_checkout" ] && [ ! -L "$src_git_checkout" ] &&
+		   git -C "$src_git_checkout" rev-parse --is-inside-work-tree \
+			>/dev/null 2>&1; then
+			sps_die "$SPS_EX_CONFLICT" \
+				"repository '$src_git_name' origin does not match its configured source"
+		fi
+		sps_die "$SPS_EX_CONFLICT" \
+			"repository checkout is not the expected Git worktree: $src_git_checkout"
+	fi
+
+	src_git_dirty=$(git -C "$src_git_checkout" status --porcelain \
+		--untracked-files=normal) ||
+		sps_die "$SPS_EX_PACKAGE" \
+			"cannot inspect repository '$src_git_name'"
+	if [ -n "$src_git_dirty" ]; then
+		sps_die "$SPS_EX_CONFLICT" \
+			"repository '$src_git_name' has local modifications; refusing to update $src_git_checkout"
+	fi
+
+	if ! git -C "$src_git_checkout" fetch origin; then
+		sps_die "$SPS_EX_NETWORK" \
+			"cannot fetch repository '$src_git_name'"
+	fi
+
+	if ! src_git_branch=$(git -C "$src_git_checkout" symbolic-ref -q \
+		--short HEAD 2>/dev/null); then
+		printf "%s: repository '%s' is detached and remains pinned at %s\n" \
+			"$sps_prog" "$src_git_name" \
+			"$(git -C "$src_git_checkout" rev-parse --short HEAD)" >&2
+		return 0
+	fi
+	if ! src_git_upstream=$(git -C "$src_git_checkout" rev-parse \
+		--abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null); then
+		printf "%s: repository '%s' branch '%s' has no upstream and remains pinned\n" \
+			"$sps_prog" "$src_git_name" "$src_git_branch" >&2
+		return 0
+	fi
+	case $src_git_upstream in
+		origin/*) ;;
+		*)
+			printf "%s: repository '%s' tracks '%s', not origin, and remains pinned\n" \
+				"$sps_prog" "$src_git_name" "$src_git_upstream" >&2
+			return 0
+			;;
+	esac
+
+	src_git_head=$(git -C "$src_git_checkout" rev-parse HEAD) ||
+		sps_die "$SPS_EX_PACKAGE" "cannot read HEAD for repository '$src_git_name'"
+	src_git_upstream_head=$(git -C "$src_git_checkout" rev-parse \
+		"$src_git_upstream") ||
+		sps_die "$SPS_EX_PACKAGE" \
+			"cannot read upstream for repository '$src_git_name'"
+	if [ "$src_git_head" = "$src_git_upstream_head" ]; then
+		return 0
+	fi
+	if git -C "$src_git_checkout" merge-base --is-ancestor \
+		"$src_git_head" "$src_git_upstream_head"; then
+		if ! git -C "$src_git_checkout" merge --ff-only \
+			"$src_git_upstream" >&2; then
+			sps_die "$SPS_EX_CONFLICT" \
+				"cannot fast-forward repository '$src_git_name'"
+		fi
+		return 0
+	fi
+	if git -C "$src_git_checkout" merge-base --is-ancestor \
+		"$src_git_upstream_head" "$src_git_head"; then
+		printf "%s: repository '%s' is ahead of %s and was left unchanged\n" \
+			"$sps_prog" "$src_git_name" "$src_git_upstream" >&2
+		return 0
+	fi
+	sps_die "$SPS_EX_CONFLICT" \
+		"repository '$src_git_name' has diverged from $src_git_upstream; refusing to update"
 }
 
 src_update()
@@ -54,19 +192,24 @@ src_update()
         exit "$SPS_EX_USAGE"
     }
 
-    sps_mkdir "$sps_index_dir"
-    src_work=$(sps_mktemp_dir "$sps_index_dir" update)
-    trap src_cleanup_update 0
-    trap 'exit 129' 1
-    trap 'exit 130' 2
-    trap 'exit 131' 3
-    trap 'exit 143' 15
+	sps_mkdir "$sps_index_dir"
+	src_work=
+	src_clone_work=
+	sps_lock_acquire "$SPS_CACHE" .src-update 'repository update' ||
+		sps_die "$SPS_EX_CONFLICT" "another repository update is active"
+	trap src_cleanup_update 0
+	trap 'exit 129' 1
+	trap 'exit 130' 2
+	trap 'exit 131' 3
+	trap 'exit 143' 15
+	src_work=$(sps_mktemp_dir "$sps_index_dir" update)
 
     if ! sps_repo_lines > "$src_work/repos.raw"; then
         sps_die "$SPS_EX_USAGE" "cannot read repository configuration"
     fi
-    if ! awk -v action=normalize -v program=src \
-        -f "$SPS_LIBDIR/repository.awk" "$src_work/repos.raw" \
+	if ! awk -v action=normalize -v program=src \
+		-v repo_root="$SPS_REPO_ROOT" \
+		-f "$SPS_LIBDIR/repository.awk" "$src_work/repos.raw" \
         > "$src_work/repos"; then
         sps_die "$SPS_EX_USAGE" "invalid repository configuration"
     fi
@@ -78,18 +221,20 @@ src_update()
     src_package_count=0
     src_failed=0
 
-    while IFS="$sps_tab" read -r src_repo_name src_repo_path \
-          src_repo_priority src_repo_order
-    do
-        [ -n "$src_repo_name" ] || continue
-        case $src_repo_path in
-            *://*|git@*:*)
-                printf "%s: repository '%s' uses an unsupported remote location: %s\n" \
-                    "$sps_prog" "$src_repo_name" "$src_repo_path" >&2
-                src_failed=1
-                break
-                ;;
-        esac
+	while IFS="$sps_tab" read -r src_repo_kind src_repo_name \
+	      src_repo_source src_repo_path src_repo_priority src_repo_order
+	do
+		[ -n "$src_repo_name" ] || continue
+		case $src_repo_kind in
+			git) src_sync_git "$src_repo_name" "$src_repo_source" "$src_repo_path" ;;
+			dir) ;;
+			*)
+				printf "%s: internal error: unsupported repository kind '%s'\n" \
+					"$sps_prog" "$src_repo_kind" >&2
+				src_failed=1
+				break
+				;;
+		esac
         if [ ! -d "$src_repo_path" ]; then
             printf "%s: repository '%s' is not a local directory: %s\n" \
                 "$sps_prog" "$src_repo_name" "$src_repo_path" >&2
@@ -104,8 +249,11 @@ src_update()
             break
         }
 
-        if ! find "$src_repo_absolute" -type f -name recipe -print \
-            > "$src_work/recipes.unsorted"; then
+		if ! find "$src_repo_absolute" \
+			\( -type d \( -name .git -o -name files -o -name patches -o \
+				-name hooks \) -prune \) -o \
+			\( -type f -name recipe -print \) \
+			> "$src_work/recipes.unsorted"; then
             printf "%s: cannot scan repository '%s': %s\n" \
                 "$sps_prog" "$src_repo_name" "$src_repo_absolute" >&2
             src_failed=1
@@ -128,22 +276,22 @@ src_update()
                 src_failed=1
                 break
             fi
-            src_digest=$(sps_sha256 "$src_recipe") || {
-                printf "%s: cannot hash recipe: %s\n" \
-                    "$sps_prog" "$src_recipe" >&2
+			src_digest=$(sps_definition_sha256 "$src_recipe") || {
+				printf "%s: cannot hash package definition: %s\n" \
+					"$sps_prog" "$src_recipe" >&2
                 src_failed=1
                 break
             }
             case $src_digest in
                 ''|*[!0123456789abcdefABCDEF]*)
-                    printf "%s: invalid SHA-256 result for recipe: %s\n" \
+					printf "%s: invalid package definition SHA-256: %s\n" \
                         "$sps_prog" "$src_recipe" >&2
                     src_failed=1
                     break
                     ;;
             esac
             if [ "${#src_digest}" -ne 64 ]; then
-                printf "%s: invalid SHA-256 result for recipe: %s\n" \
+				printf "%s: invalid package definition SHA-256: %s\n" \
                     "$sps_prog" "$src_recipe" >&2
                 src_failed=1
                 break
@@ -170,11 +318,17 @@ src_update()
         sps_die "$SPS_EX_PACKAGE" \
             "repository update failed; previous index was preserved"
     fi
-    if ! awk -v action=validate -v program=src \
-        -f "$SPS_LIBDIR/index.awk" "$src_work/packages.index"; then
+	if ! awk -v action=validate -v program=src \
+		-f "$SPS_LIBDIR/index.awk" "$src_work/packages.index"; then
         sps_die "$SPS_EX_PACKAGE" \
-            "generated repository index is malformed"
-    fi
+			"generated repository index is malformed"
+	fi
+	if ! awk -v action=validate -v include_build=1 -v program=src \
+		-f "$SPS_LIBDIR/version.awk" -f "$SPS_LIBDIR/deps.awk" \
+		"$src_work/packages.index"; then
+		sps_die "$SPS_EX_DEPENDENCY" \
+			"generated repository dependency graph is invalid"
+	fi
     if ! mv "$src_work/packages.index" "$sps_index"; then
         sps_die "$SPS_EX_PERMISSION" "cannot commit repository index"
     fi
@@ -202,9 +356,10 @@ src_repositories()
     else
         [ "$src_raw" -eq 0 ] || src_repo_action=status_raw
     fi
-    sps_repo_lines |
-        awk -v action="$src_repo_action" -v program=src \
-            -v index_file="$sps_index" \
+	sps_repo_lines |
+		awk -v action="$src_repo_action" -v program=src \
+			-v repo_root="$SPS_REPO_ROOT" \
+			-v index_file="$sps_index" \
             -f "$SPS_LIBDIR/repository.awk"
 }
 
diff --git a/examples/README b/examples/README
index 91365a4..7b26617 100644
--- a/examples/README
+++ b/examples/README
@@ -10,9 +10,15 @@ point for new recipes.
 Local source paths are resolved from the recipe directory. Downloaded or copied
 sources are available below $SRC. Tar sources are unpacked below $WORK; when an
 archive contains one top-level directory, that directory becomes $WORK.
+Every source has a corresponding `hash sha256:...` record with exactly 64
+hexadecimal digits. Checksums cannot be skipped.
 
 The install phase writes into $PKG. It must not install directly into the live
-root.
+root. At least one install record and a non-empty payload are required.
 
 If files/ or patches/ exist beside the recipe, mkpkg copies them to $SRC/files
-and $SRC/patches.
+and $SRC/patches. Recipes apply patches and install local files explicitly.
+
+Use `arch any` only for architecture-independent packages. Compiled recipes can
+omit `arch` to use the configured target architecture. See
+`../PACKAGE-MAINTAINER.md` for the full recipe and test procedure.
diff --git a/examples/repos.conf b/examples/repos.conf
index b5e9260..7f8817b 100644
--- a/examples/repos.conf
+++ b/examples/repos.conf
@@ -1,5 +1,10 @@
-# Format: repo NAME PATH [PRIORITY]
+# Formats:
+# git NAME SOURCE [PRIORITY]
+# dir NAME ABSOLUTE_PATH [PRIORITY]
 #
-# Example:
-# repo core /usr/src/core 100
-# repo local /usr/src/local 200
+# Official repositories are cloned below repo_root/NAME. Local trees are
+# indexed in place. Higher priority wins.
+#
+# dir local /usr/local/src/sps-local 200
+# git core https://github.com/RobertFlexx/sps-core.git 100
+# git extra https://github.com/RobertFlexx/sps-extra.git 80
diff --git a/examples/sps.conf b/examples/sps.conf
index 24f1dde..f1e0c5d 100644
--- a/examples/sps.conf
+++ b/examples/sps.conf
@@ -3,6 +3,7 @@
 db /var/lib/sps
 cache /var/cache/sps
 build /var/tmp/sps
+repo_root /usr/src/sps
 makejobs 1
 compression auto
 preserve etc
diff --git a/lib/common.sh b/lib/common.sh
index 0206ac4..fa164fe 100644
--- a/lib/common.sh
+++ b/lib/common.sh
@@ -108,6 +108,170 @@ sps_sha256()
 	fi
 }
 
+# Hash every input which makes up one package definition.  The manifest uses
+# only fixed, package-relative names, so moving an unchanged package directory
+# does not change its digest.  files/ and patches/ are copied recursively by
+# mkpkg, making their directory and file modes observable build inputs too.
+sps_definition_sha256()
+(
+	sps_definition_input=${1-}
+	case $sps_definition_input in
+		'') sps_warn 'package definition recipe is missing'; return 1 ;;
+		*/*)
+			sps_definition_parent=${sps_definition_input%/*}
+			sps_definition_leaf=${sps_definition_input##*/}
+			;;
+		*)
+			sps_definition_parent=.
+			sps_definition_leaf=$sps_definition_input
+			;;
+	esac
+	case $sps_definition_parent in
+		-*) sps_definition_parent=./$sps_definition_parent ;;
+	esac
+	sps_definition_dir=$(CDPATH= cd -P "$sps_definition_parent" 2>/dev/null &&
+		pwd -P) || {
+		sps_warn 'cannot enter package definition directory'
+		return 1
+	}
+	sps_definition_recipe=$sps_definition_dir/$sps_definition_leaf
+	if [ ! -f "$sps_definition_recipe" ] ||
+	   [ -L "$sps_definition_recipe" ] ||
+	   [ ! -r "$sps_definition_recipe" ]; then
+		sps_warn 'package definition recipe must be a readable regular file'
+		return 1
+	fi
+
+	sps_definition_tmp=$(mktemp -d \
+		"${TMPDIR:-/tmp}/.sps-definition.XXXXXX" 2>/dev/null) || {
+		sps_warn 'cannot create package definition hash workspace'
+		return 1
+	}
+	sps_definition_cleanup()
+	{
+		rm -f "$sps_definition_tmp/manifest" \
+			"$sps_definition_tmp/paths.raw" \
+			"$sps_definition_tmp/paths" \
+			"$sps_definition_tmp/unsafe-path" \
+			"$sps_definition_tmp/unsafe-type"
+		rmdir "$sps_definition_tmp" 2>/dev/null || :
+	}
+	trap 'sps_definition_cleanup' 0
+	trap 'exit 1' 1 2 3 15
+
+	sps_definition_hash_file()
+	{
+		sps_definition_value=$(sps_sha256 "$1") || return $?
+		sps_definition_value=$(printf '%s\n' "$sps_definition_value" |
+			LC_ALL=C tr 'ABCDEF' 'abcdef') || return $?
+		case $sps_definition_value in
+			''|*[!0123456789abcdef]*) return 1 ;;
+		esac
+		[ "${#sps_definition_value}" -eq 64 ] || return 1
+		printf '%s\n' "$sps_definition_value"
+	}
+
+	sps_definition_manifest=$sps_definition_tmp/manifest
+	printf 'sps-package-definition\t1\n' >"$sps_definition_manifest" ||
+		return 1
+	sps_definition_hash=$(sps_definition_hash_file \
+		"$sps_definition_recipe") || {
+		sps_warn 'cannot hash package definition recipe'
+		return 1
+	}
+	printf 'file\t-\t%s\trecipe\n' "$sps_definition_hash" \
+		>>"$sps_definition_manifest" || return 1
+
+	cd "$sps_definition_dir" || return 1
+	for sps_definition_support in files patches hooks; do
+		if [ ! -e "$sps_definition_support" ] &&
+		   [ ! -L "$sps_definition_support" ]; then
+			continue
+		fi
+		if [ ! -d "$sps_definition_support" ] ||
+		   [ -L "$sps_definition_support" ]; then
+			sps_warn "recipe $sps_definition_support must be a real directory"
+			return 1
+		fi
+
+		rm -f "$sps_definition_tmp/unsafe-path" \
+			"$sps_definition_tmp/unsafe-type"
+		if ! find "$sps_definition_support" -exec sh -c '
+			marker=$1
+			shift
+			for path do
+				case $path in
+					*"	"*|*"
+"*) : >"$marker"; exit 0 ;;
+				esac
+			done
+		' sh "$sps_definition_tmp/unsafe-path" {} +; then
+			sps_warn "cannot inspect package definition $sps_definition_support"
+			return 1
+		fi
+		if [ -e "$sps_definition_tmp/unsafe-path" ]; then
+			sps_warn "package definition $sps_definition_support contains a tab or newline in a path"
+			return 1
+		fi
+		if ! find "$sps_definition_support" ! -type d ! -type f \
+			-exec sh -c ': >"$1"' sh \
+			"$sps_definition_tmp/unsafe-type" {} +; then
+			sps_warn "cannot inspect package definition $sps_definition_support"
+			return 1
+		fi
+		if [ -e "$sps_definition_tmp/unsafe-type" ]; then
+			sps_warn "package definition $sps_definition_support contains a symlink or special file"
+			return 1
+		fi
+		if ! find "$sps_definition_support" -print \
+			>"$sps_definition_tmp/paths.raw"; then
+			sps_warn "cannot list package definition $sps_definition_support"
+			return 1
+		fi
+		if ! LC_ALL=C sort "$sps_definition_tmp/paths.raw" \
+			>"$sps_definition_tmp/paths"; then
+			sps_warn "cannot sort package definition $sps_definition_support"
+			return 1
+		fi
+
+		while IFS= read -r sps_definition_path ||
+		      [ -n "$sps_definition_path" ]; do
+			[ -n "$sps_definition_path" ] || continue
+			if [ -d "$sps_definition_path" ] &&
+			   [ ! -L "$sps_definition_path" ]; then
+				sps_definition_mode=$(sps_mode_of \
+					"$sps_definition_path") || {
+					sps_warn "cannot read a $sps_definition_support directory mode"
+					return 1
+				}
+				printf 'directory\t%s\t-\t%s\n' \
+					"$sps_definition_mode" "$sps_definition_path" \
+					>>"$sps_definition_manifest" || return 1
+			elif [ -f "$sps_definition_path" ] &&
+			     [ ! -L "$sps_definition_path" ]; then
+				sps_definition_mode=$(sps_mode_of \
+					"$sps_definition_path") || {
+					sps_warn "cannot read a $sps_definition_support file mode"
+					return 1
+				}
+				sps_definition_hash=$(sps_definition_hash_file \
+					"$sps_definition_path") || {
+					sps_warn "cannot hash a $sps_definition_support file"
+					return 1
+				}
+				printf 'file\t%s\t%s\t%s\n' "$sps_definition_mode" \
+					"$sps_definition_hash" "$sps_definition_path" \
+					>>"$sps_definition_manifest" || return 1
+			else
+				sps_warn "package definition $sps_definition_support changed while hashing"
+				return 1
+			fi
+		done <"$sps_definition_tmp/paths"
+	done
+
+	sps_definition_hash_file "$sps_definition_manifest"
+)
+
 sps_validate_package_name()
 {
 	case ${1-} in
@@ -208,6 +372,7 @@ sps_lock_acquire()
 {
 	sps_lock_base=$1
 	sps_lock_name=${2:-.lock}
+	sps_lock_label=${3:-package database}
 	case $sps_lock_name in ''|*/*|.|..) return 1 ;; esac
 	sps_lock=$sps_lock_base/$sps_lock_name
 	sps_lock_owned=0
@@ -228,7 +393,7 @@ sps_lock_acquire()
 		return 1
 	fi
 
-	sps_warn "removing stale package database lock from pid $sps_lock_pid"
+	sps_warn "removing stale $sps_lock_label lock '$sps_lock' from pid $sps_lock_pid"
 	rm -f "$sps_lock/pid" 2>/dev/null || return 1
 	rmdir "$sps_lock" 2>/dev/null || return 1
 	if mkdir "$sps_lock" 2>/dev/null; then
@@ -250,19 +415,35 @@ sps_lock_release()
 	sps_lock_owned=0
 }
 
-# Print repo lines from the main config and repos.conf.
+# Print repository declarations without normalizing or truncating them. General
+# settings in sps.conf are ignored here; repos.conf is repository-only, so an
+# unknown declaration there is preserved for repository.awk to reject.
 sps_repo_lines()
 {
+	sps_repo_previous=
 	for sps_repo_file in "$SPS_CONFIG" "$SPS_REPOS_CONFIG"; do
 		[ -r "$sps_repo_file" ] || continue
-		awk '
-			/^[[:space:]]*#/ || /^[[:space:]]*$/ { next }
-			$1 == "repo" && NF >= 3 {
-				printf "repo %s %s", $2, $3
-				if (NF >= 4) printf " %s", $4
-				printf "\n"
+		[ "$sps_repo_file" != "$sps_repo_previous" ] || continue
+		if [ "$sps_repo_file" = "$SPS_CONFIG" ]; then
+			sps_repo_main=1
+		else
+			sps_repo_main=0
+		fi
+		awk -v main_config="$sps_repo_main" '
+			{
+				line = $0
+				sub(/\r$/, "", line)
+				probe = line
+				sub(/^[ \t]+/, "", probe)
+				if (probe == "" || probe ~ /^#/)
+					next
+				split(probe, field, /[ \t]+/)
+				if (!main_config || field[1] == "git" ||
+				    field[1] == "dir" || field[1] == "repo")
+					print line
 			}
-		' "$sps_repo_file"
+		' "$sps_repo_file" || return $?
+		sps_repo_previous=$sps_repo_file
 	done
 }
 
@@ -272,6 +453,7 @@ sps_load_config()
 	[ "${SPS_DB+x}" = x ] && sps_env_db=1 || sps_env_db=0
 	[ "${SPS_CACHE+x}" = x ] && sps_env_cache=1 || sps_env_cache=0
 	[ "${SPS_BUILD+x}" = x ] && sps_env_build=1 || sps_env_build=0
+	[ "${SPS_REPO_ROOT+x}" = x ] && sps_env_repo_root=1 || sps_env_repo_root=0
 	[ "${SPS_MAKEJOBS+x}" = x ] && sps_env_makejobs=1 || sps_env_makejobs=0
 	[ "${SPS_COMPRESSION+x}" = x ] && sps_env_compression=1 || sps_env_compression=0
 	[ "${SPS_ARCH+x}" = x ] && sps_env_arch=1 || sps_env_arch=0
@@ -295,6 +477,7 @@ sps_load_config()
 	sps_cfg_db=/var/lib/sps
 	sps_cfg_cache=/var/cache/sps
 	sps_cfg_build=/var/tmp/sps
+	sps_cfg_repo_root=/usr/src/sps
 	sps_cfg_makejobs=1
 	sps_cfg_compression=auto
 	sps_cfg_arch=$(uname -m 2>/dev/null || printf '%s' unknown)
@@ -308,11 +491,12 @@ sps_load_config()
 				db) sps_cfg_db=$sps_value ;;
 				cache) sps_cfg_cache=$sps_value ;;
 				build) sps_cfg_build=$sps_value ;;
+				repo_root) sps_cfg_repo_root=$sps_value ;;
 				makejobs) sps_cfg_makejobs=$sps_value ;;
 				compression) sps_cfg_compression=$sps_value ;;
 				arch) sps_cfg_arch=$sps_value ;;
 				preserve) sps_cfg_preserve=$sps_value ;;
-				repo) : ;;
+				repo|git|dir) : ;;
 				*) sps_warn "ignoring unknown configuration key '$sps_key' in $SPS_CONFIG" ;;
 			esac
 		done < "$SPS_CONFIG"
@@ -326,6 +510,7 @@ sps_load_config()
 	[ "$sps_env_db" -eq 1 ] || SPS_DB=$(sps_root_path "$sps_cfg_db")
 	[ "$sps_env_cache" -eq 1 ] || SPS_CACHE=$(sps_root_path "$sps_cfg_cache")
 	[ "$sps_env_build" -eq 1 ] || SPS_BUILD=$(sps_root_path "$sps_cfg_build")
+	[ "$sps_env_repo_root" -eq 1 ] || SPS_REPO_ROOT=$(sps_root_path "$sps_cfg_repo_root")
 	[ "$sps_env_makejobs" -eq 1 ] || SPS_MAKEJOBS=$sps_cfg_makejobs
 	[ "$sps_env_compression" -eq 1 ] || SPS_COMPRESSION=$sps_cfg_compression
 	[ "$sps_env_arch" -eq 1 ] || SPS_ARCH=$sps_cfg_arch
@@ -341,12 +526,17 @@ sps_load_config()
 		''|*[!A-Za-z0-9._+-]*|[!A-Za-z0-9]*)
 			sps_die "$SPS_EX_USAGE" "invalid architecture '$SPS_ARCH'" ;;
 	esac
+	case $SPS_REPO_ROOT in
+		/*) ;;
+		*) sps_die "$SPS_EX_USAGE" "SPS_REPO_ROOT must be an absolute path: $SPS_REPO_ROOT" ;;
+	esac
+	[ "$SPS_REPO_ROOT" = / ] || SPS_REPO_ROOT=${SPS_REPO_ROOT%/}
 	case $SPS_PRESERVE in
 		'') sps_die "$SPS_EX_USAGE" "preserve may not be empty" ;;
 		*" "*|*"	"*|*"
 "*) sps_die "$SPS_EX_USAGE" "preserve must be a comma-separated path-prefix list" ;;
 	esac
 
-	export SPS_ROOT SPS_DB SPS_CACHE SPS_BUILD SPS_CONFIG SPS_REPOS_CONFIG
+	export SPS_ROOT SPS_DB SPS_CACHE SPS_BUILD SPS_REPO_ROOT SPS_CONFIG SPS_REPOS_CONFIG
 	export SPS_MAKEJOBS SPS_COMPRESSION SPS_ARCH SPS_PRESERVE
 }
diff --git a/lib/config.awk b/lib/config.awk
index 7f0918f..e718ec8 100644
--- a/lib/config.awk
+++ b/lib/config.awk
@@ -1,19 +1,23 @@
-# Read SPS config into caller-provided arrays. Repo lines are kept separately
-# because there can be more than one.
+# Read SPS config into caller-provided arrays. Repository declarations are kept
+# separately because there can be more than one. `repo` is the compatibility
+# spelling of `dir`.
 
-function config_read(path, values, repo_name, repo_path, repo_priority,
-		line, fields, n, key, rest, count) {
+function config_read(path, values, repo_kind, repo_name, repo_source,
+		repo_priority, line, fields, n, key, rest, count, kind) {
 	count = 0
 	while ((getline line < path) > 0) {
 		line = sps_trim(line)
 		if (line == "" || line ~ /^#/) continue
 		n = split(line, fields, /[[:space:]]+/)
 		key = fields[1]
-		if (key == "repo") {
-			if (n < 3 || n > 4) sps_error(path ": malformed repo declaration")
+		if (key == "git" || key == "dir" || key == "repo") {
+			if (n < 3 || n > 4)
+				sps_error(path ": malformed repository declaration")
+			kind = key == "repo" ? "dir" : key
 			count++
+			repo_kind[count] = kind
 			repo_name[count] = fields[2]
-			repo_path[count] = fields[3]
+			repo_source[count] = fields[3]
 			repo_priority[count] = n == 4 ? fields[4] : 0
 			continue
 		}
@@ -24,4 +28,3 @@ function config_read(path, values, repo_name, repo_path, repo_priority,
 	close(path)
 	return count
 }
-
diff --git a/lib/deps.awk b/lib/deps.awk
index 275c7a4..c1eefa4 100644
--- a/lib/deps.awk
+++ b/lib/deps.awk
@@ -19,6 +19,22 @@ function deps_error(message) {
     deps_failed = 1
 }
 
+function deps_sort_names(values, count,    gap, i, j, saved) {
+    gap = int(count / 2)
+    while (gap > 0) {
+        for (i = gap + 1; i <= count; i++) {
+            saved = values[i]
+            j = i
+            while (j > gap && values[j - gap] > saved) {
+                values[j] = values[j - gap]
+                j -= gap
+            }
+            values[j] = saved
+        }
+        gap = int(gap / 2)
+    }
+}
+
 function deps_dependency_name(specification, parent,    position, operator,
                               name, wanted) {
     specification = deps_trim(specification)
@@ -164,8 +180,18 @@ BEGIN {
         next
     }
 
-    if (!($1 in deps_selected_priority) ||
-        ($9 + 0) > deps_selected_priority[$1]) {
+    if (!($1 in deps_selected_priority)) {
+        deps_name_count++
+        deps_names[deps_name_count] = $1
+        deps_selected_priority[$1] = $9 + 0
+        deps_version[$1] = $2
+        deps_release[$1] = $3
+        deps_arch[$1] = $4
+        deps_runtime[$1] = $5
+        deps_build[$1] = $6
+        deps_recipe[$1] = $10
+        deps_recipe_sha256[$1] = $12
+    } else if (($9 + 0) > deps_selected_priority[$1]) {
         deps_selected_priority[$1] = $9 + 0
         deps_version[$1] = $2
         deps_release[$1] = $3
@@ -180,6 +206,17 @@ BEGIN {
 END {
     if (deps_failed)
         exit 5
+    if (action == "validate") {
+        deps_sort_names(deps_names, deps_name_count)
+        for (deps_i = 1; deps_i <= deps_name_count; deps_i++) {
+            deps_visit(deps_names[deps_i], "")
+            if (deps_failed)
+                break
+        }
+        if (deps_failed)
+            exit 5
+        exit 0
+    }
     if (roots == "") {
         deps_error("no package specified")
         exit 2
diff --git a/lib/index.awk b/lib/index.awk
index 36ecd92..34e16cb 100644
--- a/lib/index.awk
+++ b/lib/index.awk
@@ -48,7 +48,7 @@ function index_print_show(name) {
                                   ? "-" : index_output_fields[6])
     print "optional dependencies: " (index_output_fields[7] == "" \
                                      ? "-" : index_output_fields[7])
-    print "recipe sha256: " index_output_fields[12]
+    print "definition sha256: " index_output_fields[12]
 }
 
 BEGIN {
@@ -75,6 +75,15 @@ BEGIN {
         next
     }
 
+    index_repository_key = $8 SUBSEP $1
+    if (index_repository_key in index_repository_recipe) {
+        index_error(FILENAME ":" FNR ": duplicate package '" $1 \
+                    "' in repository '" $8 "' (also " \
+                    index_repository_recipe[index_repository_key] ")")
+    } else {
+        index_repository_recipe[index_repository_key] = $10
+    }
+
     index_records++
     index_record_line[index_records] = $0
     index_record_name[index_records] = $1
diff --git a/lib/recipe.awk b/lib/recipe.awk
index 75c2b79..e3e1a1d 100644
--- a/lib/recipe.awk
+++ b/lib/recipe.awk
@@ -117,9 +117,15 @@ function recipe_process(line,    key, value, separator) {
         recipe_set_once(key, value)
     } else if (key == "depend" || key == "builddep" || key == "optional") {
         recipe_append_dependency(key, value)
-    } else if (key == "source" || key == "hash" || key == "prepare" ||
-               key == "configure" || key == "build" || key == "install") {
-        # mkpkg uses these later; the indexer only records that the fields are valid.
+    } else if (key == "source") {
+        recipe_source_count++
+    } else if (key == "hash") {
+        recipe_hash_count++
+        recipe_hash[recipe_hash_count] = value
+    } else if (key == "install") {
+        recipe_install_count++
+    } else if (key == "prepare" || key == "configure" || key == "build") {
+        # mkpkg executes phase records later; indexing only validates their syntax.
     } else {
         recipe_error("unknown field '" key "'")
     }
@@ -155,6 +161,10 @@ END {
         recipe_error("missing required 'version' field")
     if (!recipe_seen["release"])
         recipe_error("missing required 'release' field")
+    if (!recipe_install_count)
+        recipe_error("missing required 'install' command")
+    if (recipe_source_count != recipe_hash_count)
+        recipe_error("each source must have one corresponding hash")
     if (!recipe_seen["arch"]) {
         if (recipe_default_arch == "") {
             "uname -m" | getline recipe_default_arch
@@ -172,6 +182,16 @@ END {
     recipe_value["builddep"] = recipe_expand(recipe_value["builddep"])
     recipe_value["optional"] = recipe_expand(recipe_value["optional"])
 
+    for (recipe_i = 1; recipe_i <= recipe_hash_count; recipe_i++) {
+        recipe_hash_value = recipe_expand(recipe_hash[recipe_i])
+        recipe_hash_hex = substr(recipe_hash_value, 8)
+        if (substr(recipe_hash_value, 1, 7) != "sha256:" ||
+            length(recipe_hash_hex) != 64 ||
+            recipe_hash_hex !~ /^[0123456789abcdefABCDEF]+$/)
+            recipe_error("invalid source hash '" recipe_hash_value \
+                         "' (expected sha256:<64 hex digits>)")
+    }
+
     if (recipe_value["name"] !~ /^[A-Za-z0-9][A-Za-z0-9+_.-]*$/)
         recipe_error("invalid package name '" recipe_value["name"] "'")
     if (recipe_value["version"] !~ \
diff --git a/lib/relations.awk b/lib/relations.awk
index b2f91b0..e2d27dc 100644
--- a/lib/relations.awk
+++ b/lib/relations.awk
@@ -15,12 +15,17 @@ function rel_dep_name(spec,    p) {
     return spec
 }
 
-function rel_add_edges(pkg, list,    n, a, i, d) {
+function rel_add_edges(pkg, list,    n, a, i, d, key) {
     if (list == "") return
     n = split(list, a, /,/)
     for (i = 1; i <= n; i++) {
         d = rel_dep_name(a[i])
-        if (d != "") edge[pkg SUBSEP d] = 1
+        key = pkg SUBSEP d
+        if (d != "" && !(key in edge)) {
+            edge[key] = 1
+            child_count[pkg]++
+            child[pkg SUBSEP child_count[pkg]] = d
+        }
     }
 }
 
@@ -36,13 +41,11 @@ function rel_sort(a, n,    gap,i,j,x) {
     }
 }
 
-function rel_find(cur, target,    k,p,d) {
+function rel_find(cur, target,    i,d) {
     if (cur == target) return 1
     seen[cur]=1
-    for (k in edge) {
-        split(k,p,SUBSEP)
-        if (p[1] != cur) continue
-        d=p[2]
+    for (i = 1; i <= child_count[cur]; i++) {
+        d=child[cur SUBSEP i]
         if (seen[d]) continue
         parent[d]=cur
         if (rel_find(d,target)) return 1
diff --git a/lib/repository.awk b/lib/repository.awk
index f898766..9f943ad 100644
--- a/lib/repository.awk
+++ b/lib/repository.awk
@@ -1,4 +1,12 @@
-# Parse and print repository configuration. Input is: repo NAME PATH [PRIORITY]
+# Parse and print repository configuration.
+#
+# Input forms:
+#   git NAME SOURCE [PRIORITY]
+#   dir NAME ABSOLUTE_PATH [PRIORITY]
+#   repo NAME ABSOLUTE_PATH [PRIORITY]   (compatibility alias for dir)
+#
+# Normalized records are tab-separated:
+#   KIND NAME SOURCE CHECKOUT PRIORITY ORDER
 
 function repository_trim(value) {
     sub(/^[ \t]+/, "", value)
@@ -27,15 +35,19 @@ function repository_error(message) {
 
     repository_count_fields = split(repository_line, repository_fields,
                                     /[ \t]+/)
+    repository_kind = repository_fields[1]
+    if (repository_kind == "repo")
+        repository_kind = "dir"
     if (repository_count_fields < 3 || repository_count_fields > 4 ||
-        repository_fields[1] != "repo") {
+        (repository_kind != "git" && repository_kind != "dir")) {
         repository_error(FILENAME ":" FNR \
-                         ": expected 'repo NAME PATH [PRIORITY]'")
+                         ": expected 'git NAME SOURCE [PRIORITY]' or " \
+                         "'dir NAME ABSOLUTE_PATH [PRIORITY]'")
         next
     }
 
     repository_name = repository_fields[2]
-    repository_path = repository_fields[3]
+    repository_source = repository_fields[3]
     repository_priority = repository_count_fields == 4 \
                           ? repository_fields[4] : 0
 
@@ -44,8 +56,26 @@ function repository_error(message) {
                          repository_name "'")
         next
     }
-    if (repository_path == "" || repository_path ~ /[[:cntrl:]]/) {
-        repository_error(FILENAME ":" FNR ": invalid path for repository '" \
+    if (repository_source == "" || repository_source ~ /[[:cntrl:]]/) {
+        repository_error(FILENAME ":" FNR ": invalid source for repository '" \
+                         repository_name "'")
+        next
+    }
+    if (repository_kind == "dir" && repository_source !~ /^\//) {
+        repository_error(FILENAME ":" FNR ": directory repository '" \
+                         repository_name "' requires an absolute path")
+        next
+    }
+    if (repository_kind == "git" && substr(repository_source, 1, 1) == "-") {
+        repository_error(FILENAME ":" FNR ": invalid Git source for repository '" \
+                         repository_name "'")
+        next
+    }
+    repository_source_lower = tolower(repository_source)
+    if (repository_kind == "git" &&
+        repository_source_lower ~ /^https?:\/\/[^\/]*@/) {
+        repository_error(FILENAME ":" FNR \
+                         ": credential-bearing HTTP(S) Git source is not permitted for repository '" \
                          repository_name "'")
         next
     }
@@ -54,17 +84,39 @@ function repository_error(message) {
                          repository_priority "'")
         next
     }
+    repository_priority_number = repository_priority + 0
+    if (repository_priority_number < -2147483648 ||
+        repository_priority_number > 2147483647) {
+        repository_error(FILENAME ":" FNR ": priority out of range '" \
+                         repository_priority "'")
+        next
+    }
     if (repository_seen[repository_name]) {
         repository_error(FILENAME ":" FNR ": duplicate repository '" \
                          repository_name "'")
         next
     }
 
+    if (repository_kind == "git") {
+        if (repo_root !~ /^\// || repo_root ~ /[[:cntrl:]]/) {
+            repository_error("invalid Git repository checkout root '" \
+                             repo_root "'")
+            next
+        }
+        repository_checkout = repo_root == "/" \
+                              ? "/" repository_name \
+                              : repo_root "/" repository_name
+    } else {
+        repository_checkout = repository_source
+    }
+
     repository_seen[repository_name] = 1
     repository_total++
+    repository_kinds[repository_total] = repository_kind
     repository_names[repository_total] = repository_name
-    repository_paths[repository_total] = repository_path
-    repository_priorities[repository_total] = repository_priority
+    repository_sources[repository_total] = repository_source
+    repository_checkouts[repository_total] = repository_checkout
+    repository_priorities[repository_total] = repository_priority_number
 }
 
 END {
@@ -75,16 +127,23 @@ END {
     if (action == "normalize" || action == "raw") {
         for (repository_i = 1; repository_i <= repository_total;
              repository_i++)
-            print repository_names[repository_i],
-                  repository_paths[repository_i],
+            print repository_kinds[repository_i],
+                  repository_names[repository_i],
+                  repository_sources[repository_i],
+                  repository_checkouts[repository_i],
                   repository_priorities[repository_i], repository_i
     } else if (action == "list" || action == "") {
         for (repository_i = 1; repository_i <= repository_total;
-             repository_i++)
-            printf "%s\tpriority %s\t%s\n",
-                   repository_names[repository_i],
+             repository_i++) {
+            repository_kind = repository_kinds[repository_i]
+            printf "%s\t%s\tpriority %s\t%s",
+                   repository_names[repository_i], repository_kind,
                    repository_priorities[repository_i],
-                   repository_paths[repository_i]
+                   repository_sources[repository_i]
+            if (repository_kind == "git")
+                printf "\tcheckout %s", repository_checkouts[repository_i]
+            printf "\n"
+        }
     } else if (action == "status" || action == "status_raw") {
         repository_index_state = "missing"
         if (index_file != "") {
@@ -105,23 +164,25 @@ END {
              repository_i++) {
             repository_name = repository_names[repository_i]
             if (action == "status_raw")
-                print repository_name,
-                      repository_priorities[repository_i],
-                      repository_paths[repository_i],
+                print repository_kinds[repository_i], repository_name,
+                      repository_sources[repository_i],
+                      repository_checkouts[repository_i],
+                      repository_priorities[repository_i], repository_i,
                       (repository_index_state == "present" \
                        ? repository_package_count[repository_name] + 0 \
                        : "not-indexed")
             else if (repository_index_state == "present")
-                printf "%s: %d packages (priority %s, %s)\n",
+                printf "%s: %d packages (%s, priority %s, %s)\n",
                        repository_name,
                        repository_package_count[repository_name] + 0,
+                       repository_kinds[repository_i],
                        repository_priorities[repository_i],
-                       repository_paths[repository_i]
+                       repository_checkouts[repository_i]
             else
-                printf "%s: not indexed (priority %s, %s)\n",
-                       repository_name,
+                printf "%s: not indexed (%s, priority %s, %s)\n",
+                       repository_name, repository_kinds[repository_i],
                        repository_priorities[repository_i],
-                       repository_paths[repository_i]
+                       repository_checkouts[repository_i]
         }
     } else {
         repository_error("unknown repository action '" action "'")
diff --git a/man/mkpkg.1 b/man/mkpkg.1
index e918228..fc5a9b0 100644
--- a/man/mkpkg.1
+++ b/man/mkpkg.1
@@ -40,6 +40,9 @@ Required keys are
 .BR version ,
 and
 .BR release .
+A recipe must also contain at least one
+.B install
+record, and installation must produce a non-empty staged payload.
 Common repeatable keys include
 .BR source ,
 .BR hash ,
@@ -54,11 +57,49 @@ and
 .BR install .
 Metadata parsing never executes these phase records.
 .PP
+Each
+.B source
+must have one corresponding
+.B hash
+in declaration order. The only accepted form is
+.BR sha256:HEX ,
+with exactly 64 hexadecimal digits. There is no checksum-skip value.
+.PP
+The optional
+.B arch
+key defaults to
+.BR SPS_ARCH .
+Use
+.B any
+for an architecture-independent package;
+.B noarch
+is accepted as a compatibility spelling.
+.PP
+A real
+.B files
+or
+.B patches
+directory beside the recipe is copied to
+.B $SRC/files
+or
+.BR $SRC/patches .
+Nothing in these directories is applied or installed automatically.
+.PP
 A recipe directory may provide explicit lifecycle scripts in
 .B hooks/pre-install,
 hooks/post-install, hooks/pre-remove, and hooks/post-remove. No other hook
 names are accepted.
 .SH ENVIRONMENT
+.B PKG
+is the package staging root;
+.B SRC
+contains acquired sources and support trees;
+.B WORK
+is the extracted source root. Each phase starts there and runs with
+.B /bin/sh
+and options
+.BR -eu .
+.PP
 .B SPS_ARCH
 provides the default architecture.
 .B SPS_MAKEJOBS
@@ -74,8 +115,9 @@ created package artifact, suitable for consumption by
 .BR sget (1).
 .SH EXIT STATUS
 Returns 0 on success, 2 for usage, 3 for missing recipe/source, 4 for invalid
-metadata/package content, 6 for checksum failure, 8 for filesystem failure,
-9 for download failure, and 10 for build failure.
+metadata/package content, 6 for checksum failure, 7 when the output artifact
+already exists, 8 for filesystem failure, 9 for download failure, and 10 for
+build failure.
 .SH SEE ALSO
 .BR sget (1),
 .BR pkin (1),
diff --git a/man/sps.conf.5 b/man/sps.conf.5
index b6ee9a2..0581570 100644
--- a/man/sps.conf.5
+++ b/man/sps.conf.5
@@ -24,6 +24,12 @@ Source, repository-index, and built-package cache; default
 Temporary build/staging root; default
 .BR /var/tmp/sps .
 .TP
+.BI repo_root " path"
+Parent directory for Git repository checkouts; default
+.BR /usr/src/sps .
+The checkout for repository NAME is
+.IR repo_root /NAME.
+.TP
 .BI makejobs " count"
 Positive per-package build parallelism exported to recipes as
 .BR MAKEJOBS .
@@ -59,16 +65,32 @@ to disable the policy.
 A declaration has the form:
 .PP
 .nf
-repo NAME PATH [PRIORITY]
+git NAME SOURCE [PRIORITY]
+dir NAME ABSOLUTE_PATH [PRIORITY]
 .fi
 .PP
-PATH is a local directory tree. Larger numeric priorities win; configuration
-order breaks equal priorities. Repository transport is outside SPS.
+For
+.BR git ,
+SOURCE is the exact URL or path recorded as the checkout's
+.B origin
+and synchronized by
+.BR src (1).
+For
+.BR dir ,
+ABSOLUTE_PATH is an administrator-managed local recipe tree and SPS performs no
+transport operation. The older keyword
+.B repo
+is accepted as a compatibility alias for
+.BR dir .
+.PP
+PRIORITY is a signed 32-bit integer and defaults to zero. Larger values win;
+configuration order breaks equal priorities. Repository names must be unique.
 .SH ENVIRONMENT
 .BR SPS_ROOT ,
 .BR SPS_DB ,
 .BR SPS_CACHE ,
 .BR SPS_BUILD ,
+.BR SPS_REPO_ROOT ,
 .BR SPS_MAKEJOBS ,
 .BR SPS_COMPRESSION ,
 .BR SPS_ARCH ,
@@ -85,12 +107,14 @@ root /
 db /var/lib/sps
 cache /var/cache/sps
 build /var/tmp/sps
+repo_root /usr/src/sps
 makejobs 8
 compression zstd
 preserve etc
 
-repo core  /usr/src/core  100
-repo local /usr/src/local 200
+dir local /usr/local/src/sps-local 200
+git core https://github.com/RobertFlexx/sps-core.git 100
+git extra https://github.com/RobertFlexx/sps-extra.git 80
 .fi
 .SH SEE ALSO
 .BR src (1),
diff --git a/man/src.1 b/man/src.1
index 3670a73..a4a1e26 100644
--- a/man/src.1
+++ b/man/src.1
@@ -13,13 +13,15 @@ src status [--raw]
 .SH DESCRIPTION
 .B src
 manages source repository metadata. It never builds or installs packages.
-Configured repositories are local directory trees. Use git, rsync, a release
-archive, or a distribution update script to put repository data on disk.
+It synchronizes configured Git repositories conservatively and indexes those
+checkouts together with configured local directory repositories.
 .SH COMMANDS
 .TP
 .B update
-Scan configured repositories, validate recipes, and atomically replace the
-local package index. Invalid input leaves the previous index in place.
+Clone absent Git repositories, fetch existing clean Git repositories,
+fast-forward safe tracking branches, scan configured trees, validate recipes
+and the selected dependency graph, and atomically replace the local package
+index. Invalid input leaves the previous index in place.
 .TP
 .BI search " pattern"
 Search indexed names and descriptions.
@@ -40,7 +42,23 @@ Show repository paths, priorities, and index state.
 selects stable tab-separated output where supported.
 .SH SELECTION
 Higher numeric repository priority wins. Configuration order breaks equal
-priority, followed by lexical recipe path for deterministic selection.
+priority. Duplicate package names within one repository are rejected.
+.SH GIT SAFETY
+Git checkouts are created below
+.BR $SPS_REPO_ROOT ,
+which defaults to
+.BR /usr/src/sps .
+An existing checkout must be the expected top-level worktree and its
+.B origin
+URL must match the configured source exactly.
+.PP
+.B src update
+refuses tracked or untracked local changes before fetching. It never runs
+.BR "git reset" ,
+.BR "git clean" ,
+or an implicit checkout. A clean tracking branch is fast-forwarded only when
+safe. Detached heads and branches without an origin upstream remain pinned;
+locally ahead branches remain unchanged; divergent branches are refused.
 .SH FILES
 .TP
 .B /etc/sps/sps.conf
@@ -53,8 +71,9 @@ Repository declarations.
 Generated local package index.
 .SH EXIT STATUS
 Returns 0 on success, 2 for usage/configuration, 3 for missing index/package,
-4 for malformed recipes/indexes, 6 for hashing failure, and 8 for permission
-failure.
+4 for malformed recipes/indexes, 5 for an invalid dependency graph, 6 for
+hashing failure, 7 for a checkout conflict, 8 for permission failure, and 9 for
+a Git network failure.
 .SH SEE ALSO
 .BR sget (1),
 .BR mkpkg (1),