From c6aa9732a1efd00e4854649658ed04be888e7238 Mon Sep 17 00:00:00 2001 From: Burke Libbey Date: Sat, 30 May 2026 10:56:39 -0400 Subject: [PATCH 1/4] fix(tectonix): handle dirty zones in full checkouts Expose full-checkout detection based on core.sparseCheckout so companion Tectonix code can distinguish sparse checkouts from regular source checkouts. In full checkouts, map dirty git-status paths back to their containing zones and only materialize dirty entries instead of expanding a manifest-sized clean map. Keep sparse checkout behavior compatible by initializing sparse roots as clean and add a per-zone dirty builtin for source-available mode. --- src/libexpr/eval.cc | 124 +++++++++++++++++++---- src/libexpr/include/nix/expr/eval.hh | 12 ++- src/libexpr/primops/tectonix.cc | 37 ++++--- tests/functional/tectonix/dirty-zones.sh | 31 ++++++ 4 files changed, 173 insertions(+), 31 deletions(-) diff --git a/src/libexpr/eval.cc b/src/libexpr/eval.cc index f8bb6e378a..697693e280 100644 --- a/src/libexpr/eval.cc +++ b/src/libexpr/eval.cc @@ -485,6 +485,44 @@ bool EvalState::isTectonixSourceAvailable() const return !settings.tectonixCheckoutPath.get().empty(); } +bool EvalState::isTectonixFullCheckout() const +{ + std::call_once(tectonixFullCheckoutFlag, [this]() { + tectonixFullCheckout = false; + if (!isTectonixSourceAvailable()) + return; + + StringMap gitEnvironment = getEnv(); + gitEnvironment.erase("GIT_DIR"); + gitEnvironment.erase("GIT_WORK_TREE"); + gitEnvironment.erase("GIT_COMMON_DIR"); + + auto checkoutPath = settings.tectonixCheckoutPath.get(); + auto [gitConfigCode, gitConfigOutput] = runProgram( + {.program = "git", + .args = {"-C", checkoutPath, "config", "--bool", "--get", "core.sparseCheckout"}, + .environment = gitEnvironment}); + + // If core.sparseCheckout is unset, git config exits non-zero. That is a + // normal full-checkout state. Other failures will be caught later by the + // git-status path and treated as clean. + if (!statusOk(gitConfigCode)) { + tectonixFullCheckout = true; + debug("source checkout at '%s' has no sparse-checkout config; treating as full checkout", checkoutPath); + return; + } + + auto value = trim(gitConfigOutput); + tectonixFullCheckout = value != "true"; + debug( + "source checkout at '%s' core.sparseCheckout=%s; fullCheckout=%s", + checkoutPath, + value, + tectonixFullCheckout ? "true" : "false"); + }); + return tectonixFullCheckout; +} + // Helper to normalize zone paths: strip leading // prefix // Zone paths in manifest have // prefix (e.g., //areas/tools/dev) // Filesystem operations need paths without // (e.g., areas/tools/dev) @@ -496,6 +534,22 @@ static std::string normalizeZonePath(std::string_view zonePath) return path; } +static std::optional findZonePathForRepoPath(const nlohmann::json & manifest, std::string_view repoPath) +{ + std::string candidate(repoPath); + while (!candidate.empty()) { + auto zonePath = "//" + candidate; + if (manifest.contains(zonePath)) + return zonePath; + + auto slash = candidate.rfind('/'); + if (slash == std::string::npos) + break; + candidate.resize(slash); + } + return std::nullopt; +} + // Helper to sanitize zone path for use in store path names. // Store paths only allow: a-zA-Z0-9 and +-._?= // Replaces / with - and any other invalid chars with _ @@ -627,10 +681,18 @@ const std::map & EvalState::getTectonixDi if (!isTectonixSourceAvailable()) return; - // Get sparse checkout roots (zone IDs) - auto & sparseRoots = getTectonixSparseCheckoutRoots(); - if (sparseRoots.empty()) - return; + bool fullCheckout = isTectonixFullCheckout(); + + // In sparse checkout mode, use the explicit root set. In full checkout + // mode, avoid expanding every zone eagerly; dirty files will be mapped + // to zones directly from their repo-relative paths. + const std::set * sparseRoots = nullptr; + if (!fullCheckout) { + auto & roots = getTectonixSparseCheckoutRoots(); + if (roots.empty()) + return; + sparseRoots = &roots; + } // Get manifest (uses cached parsed JSON) const nlohmann::json * manifest; @@ -644,21 +706,26 @@ const std::map & EvalState::getTectonixDi return; } - // Build map of zone ID -> zone path for sparse roots only + // Build map of zone ID -> zone path for sparse roots only. Full + // checkouts do not need this map because every zone is physically + // available and clean zones can stay implicit. std::map zoneIdToPath; - for (auto & [path, value] : manifest->items()) { - if (!value.contains("id") || !value.at("id").is_string()) { - warn("zone '%s' in manifest has missing or non-string 'id' field", path); - continue; + if (!fullCheckout) { + for (auto & [path, value] : manifest->items()) { + if (!value.contains("id") || !value.at("id").is_string()) { + warn("zone '%s' in manifest has missing or non-string 'id' field", path); + continue; + } + auto & id = value.at("id").get_ref(); + if (sparseRoots->count(id)) + zoneIdToPath[id] = path; } - auto & id = value.at("id").get_ref(); - if (sparseRoots.count(id)) - zoneIdToPath[id] = path; - } - // Initialize all sparse-checked-out zones as not dirty - for (auto & [zoneId, zonePath] : zoneIdToPath) { - tectonixDirtyZones[zonePath] = {}; + // Initialize all sparse-checked-out zones as not dirty. Full + // checkouts only materialize dirty entries. + for (auto & [zoneId, zonePath] : zoneIdToPath) { + tectonixDirtyZones[zonePath] = {}; + } } // Get dirty files via git status with -z for NUL-separated output @@ -710,11 +777,22 @@ const std::map & EvalState::getTectonixDi } for (const auto & filePath : pathsToCheck) { + auto repoPath = filePath.substr(1); + + if (fullCheckout) { + if (auto zonePath = findZonePathForRepoPath(*manifest, repoPath)) { + auto & info = tectonixDirtyZones[*zonePath]; + info.dirty = true; + info.dirtyFiles.insert(repoPath); + } + continue; + } + for (auto & [zonePath, info] : tectonixDirtyZones) { auto normalized = "/" + normalizeZonePath(zonePath); if (hasPrefix(filePath, normalized + "/") || filePath == normalized) { info.dirty = true; - info.dirtyFiles.insert(filePath.substr(1)); + info.dirtyFiles.insert(repoPath); break; } } @@ -724,11 +802,21 @@ const std::map & EvalState::getTectonixDi size_t dirtyCount = 0; for (const auto & [_, info] : tectonixDirtyZones) if (info.dirty) dirtyCount++; - debug("computed dirty zones: %d of %d zones are dirty", dirtyCount, tectonixDirtyZones.size()); + debug("computed dirty zones: %d of %d materialized zones are dirty", dirtyCount, tectonixDirtyZones.size()); }); return tectonixDirtyZones; } +bool EvalState::isTectonixZoneDirty(std::string_view zonePath) const +{ + if (!isTectonixSourceAvailable()) + return false; + + auto & dirtyZones = getTectonixDirtyZones(); + auto it = dirtyZones.find(std::string(zonePath)); + return it != dirtyZones.end() && it->second.dirty; +} + // Path to the tectonix manifest file within the world repository static constexpr std::string_view TECTONIX_MANIFEST_PATH = "/.meta/manifest.json"; diff --git a/src/libexpr/include/nix/expr/eval.hh b/src/libexpr/include/nix/expr/eval.hh index 4b9b6885f6..0a38f60b56 100644 --- a/src/libexpr/include/nix/expr/eval.hh +++ b/src/libexpr/include/nix/expr/eval.hh @@ -530,6 +530,10 @@ private: /** Cache: world path → tree SHA (lazy computed, cached at each path level) */ const ref> worldTreeShaCache; + /** Lazy-initialized full-checkout detection (thread-safe via once_flag) */ + mutable std::once_flag tectonixFullCheckoutFlag; + mutable bool tectonixFullCheckout = false; + /** Lazy-initialized set of zone IDs in sparse checkout (thread-safe via once_flag) */ mutable std::once_flag tectonixSparseCheckoutRootsFlag; mutable std::set tectonixSparseCheckoutRoots; @@ -635,12 +639,18 @@ public: /** Check if we're in source-available mode */ bool isTectonixSourceAvailable() const; + /** Check if source-available mode points at a non-sparse/full checkout */ + bool isTectonixFullCheckout() const; + /** Get set of zone IDs in sparse checkout (source-available mode only) */ const std::set & getTectonixSparseCheckoutRoots() const; - /** Get map of zone path → dirty status (only for sparse-checked-out zones) */ + /** Get map of zone path → dirty status */ const std::map & getTectonixDirtyZones() const; + /** Check whether a zone has uncommitted changes in source-available mode */ + bool isTectonixZoneDirty(std::string_view zonePath) const; + /** Get cached manifest content (thread-safe, lazy-loaded) */ const std::string & getManifestContent() const; diff --git a/src/libexpr/primops/tectonix.cc b/src/libexpr/primops/tectonix.cc index 78e286e9eb..38cb74b7ef 100644 --- a/src/libexpr/primops/tectonix.cc +++ b/src/libexpr/primops/tectonix.cc @@ -260,12 +260,12 @@ static RegisterPrimOp primop_unsafeTectonixInternalDirtyZones({ .name = "__unsafeTectonixInternalDirtyZones", .args = {}, .doc = R"( - Get the dirty status of zones in the sparse checkout. + Get the dirty status of zones in the checkout. Returns an attrset mapping zone paths to booleans indicating whether - the zone has uncommitted changes. - - Only includes zones that are in the sparse checkout. + the zone has uncommitted changes. Sparse checkouts include all sparse + roots with clean zones set to false. Full checkouts include dirty zones + only so the evaluator does not eagerly expand every zone. Example: `builtins.unsafeTectonixInternalDirtyZones."//areas/tools/dev"` returns `true` or `false`. @@ -274,6 +274,26 @@ static RegisterPrimOp primop_unsafeTectonixInternalDirtyZones({ .fun = prim_unsafeTectonixInternalDirtyZones, }); +// ============================================================================ +// builtins.__unsafeTectonixInternalFullCheckout +// Returns whether source-available mode points at a non-sparse/full checkout +// ============================================================================ +static void prim_unsafeTectonixInternalFullCheckout(EvalState & state, const PosIdx pos, Value ** args, Value & v) +{ + v.mkBool(state.isTectonixFullCheckout()); +} + +static RegisterPrimOp primop_unsafeTectonixInternalFullCheckout({ + .name = "__unsafeTectonixInternalFullCheckout", + .args = {}, + .doc = R"( + Return true when `--tectonix-checkout-path` points at a non-sparse/full + checkout (`core.sparseCheckout` unset or false). This lets Tectonix treat + zones as source-available without materializing a root entry for every zone. + )", + .fun = prim_unsafeTectonixInternalFullCheckout, +}); + // ============================================================================ // builtins.__unsafeTectonixInternalZoneIsDirty zonePath // Returns whether a given zone is dirty in the checkout @@ -285,14 +305,7 @@ static void prim_unsafeTectonixInternalZoneIsDirty(EvalState & state, const PosI validateZonePath(state, pos, zonePath); - bool isDirty = false; - if (state.isTectonixSourceAvailable()) { - auto & dirtyZones = state.getTectonixDirtyZones(); - auto it = dirtyZones.find(std::string(zonePath)); - isDirty = it != dirtyZones.end() && it->second.dirty; - } - - v.mkBool(isDirty); + v.mkBool(state.isTectonixZoneDirty(zonePath)); } static RegisterPrimOp primop_unsafeTectonixInternalZoneIsDirty({ diff --git a/tests/functional/tectonix/dirty-zones.sh b/tests/functional/tectonix/dirty-zones.sh index 0bd671a00a..dacda3bcdd 100644 --- a/tests/functional/tectonix/dirty-zones.sh +++ b/tests/functional/tectonix/dirty-zones.sh @@ -10,6 +10,16 @@ HEAD_SHA=$(get_head_sha "$TEST_WORLD") echo "Testing dirty zone detection..." +# Test worlds are regular/full checkouts unless sparse-checkout is explicitly enabled. +full_checkout=$(tectonix_eval_json "$TEST_WORLD/.git" "$HEAD_SHA" \ + 'builtins.unsafeTectonixInternalFullCheckout' \ + --option tectonix-checkout-path "$TEST_WORLD") +echo "Full checkout status: $full_checkout" + +if [[ "$full_checkout" != "true" ]]; then + fail "Checkout should be detected as full when core.sparseCheckout is unset" +fi + # First, verify zone is clean zone_is_dirty=$(tectonix_eval_json "$TEST_WORLD/.git" "$HEAD_SHA" \ 'builtins.unsafeTectonixInternalZoneIsDirty "//areas/tools/dev"' \ @@ -56,4 +66,25 @@ if [[ "$clean_zone_dirty" == "true" ]]; then fail "Unmodified zone should not be dirty" fi +# Enabling sparse checkout should flip the full-checkout detector while retaining +# dirty-zone behavior via sparse-checkout-roots. +git -C "$TEST_WORLD" config core.sparseCheckout true +sparse_full_checkout=$(tectonix_eval_json "$TEST_WORLD/.git" "$HEAD_SHA" \ + 'builtins.unsafeTectonixInternalFullCheckout' \ + --option tectonix-checkout-path "$TEST_WORLD") +echo "Sparse checkout full status: $sparse_full_checkout" + +if [[ "$sparse_full_checkout" != "false" ]]; then + fail "Checkout should not be detected as full when core.sparseCheckout=true" +fi + +sparse_zone_dirty=$(tectonix_eval_json "$TEST_WORLD/.git" "$HEAD_SHA" \ + 'builtins.unsafeTectonixInternalZoneIsDirty "//areas/tools/dev"' \ + --option tectonix-checkout-path "$TEST_WORLD") +echo "Sparse dirty zone status: $sparse_zone_dirty" + +if [[ "$sparse_zone_dirty" != "true" ]]; then + fail "Sparse dirty zone should still be dirty after enabling sparse checkout" +fi + echo "Dirty zone tests passed!" From f58eb317bb60b1df61f07f2eff8cbab226129276 Mon Sep 17 00:00:00 2001 From: Burke Libbey Date: Fri, 5 Jun 2026 08:39:33 -0400 Subject: [PATCH 2/4] build: use Shopify libgit2 sparse-index fix --- packaging/dependencies.nix | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packaging/dependencies.nix b/packaging/dependencies.nix index 49c448bca6..f0049d176b 100644 --- a/packaging/dependencies.nix +++ b/packaging/dependencies.nix @@ -42,6 +42,19 @@ scope: { (prevAttrs.postInstall or ""); }); + libgit2 = pkgs.libgit2.overrideAttrs (prevAttrs: { + version = "1.9.0-shopify-sdir"; + src = pkgs.fetchFromGitHub { + owner = "Shopify"; + repo = "libgit2"; + rev = "a9bf5b63e0380efddcd0b5070a69297fa74c8d63"; + hash = "sha256-rDr+WnFEKYCvkevViVfu5Fxqp5+xBds0lSwy3kw8uIA="; + }; + patches = lib.filter ( + patch: !(lib.hasSuffix "libgit2-darwin-case-sensitive-build.patch" (toString patch)) + ) (prevAttrs.patches or [ ]); + }); + # TODO: Remove this when https://github.com/NixOS/nixpkgs/pull/442682 is included in a stable release toml11 = if lib.versionAtLeast pkgs.toml11.version "4.4.0" then From 2abf030581dc15d706900f3e55c62ef48e7b35e7 Mon Sep 17 00:00:00 2001 From: Burke Libbey Date: Fri, 5 Jun 2026 09:43:04 -0400 Subject: [PATCH 3/4] tectonix: use libgit2 for dirty checkout paths --- packaging/dependencies.nix | 4 +- src/libexpr-tests/tectonix.cc | 20 +++++ src/libexpr/eval.cc | 80 ++++++------------- src/libexpr/include/nix/expr/eval.hh | 2 +- src/libfetchers-tests/git-utils.cc | 47 +++++++++++ src/libfetchers/git-utils.cc | 29 +++++++ .../include/nix/fetchers/git-utils.hh | 3 + 7 files changed, 125 insertions(+), 60 deletions(-) diff --git a/packaging/dependencies.nix b/packaging/dependencies.nix index f0049d176b..8ec4c361d0 100644 --- a/packaging/dependencies.nix +++ b/packaging/dependencies.nix @@ -47,8 +47,8 @@ scope: { src = pkgs.fetchFromGitHub { owner = "Shopify"; repo = "libgit2"; - rev = "a9bf5b63e0380efddcd0b5070a69297fa74c8d63"; - hash = "sha256-rDr+WnFEKYCvkevViVfu5Fxqp5+xBds0lSwy3kw8uIA="; + rev = "0def884ade4cb7c53ab62ae4c59be176606c851e"; + hash = "sha256-s0CeQZzz7FSux3wXQO6vePg3/yy2ivC44J01w8/Jxqs="; }; patches = lib.filter ( patch: !(lib.hasSuffix "libgit2-darwin-case-sensitive-build.patch" (toString patch)) diff --git a/src/libexpr-tests/tectonix.cc b/src/libexpr-tests/tectonix.cc index 39454a22eb..3b9fa2a25d 100644 --- a/src/libexpr-tests/tectonix.cc +++ b/src/libexpr-tests/tectonix.cc @@ -435,6 +435,26 @@ TEST_F(TectonixTest, dirtyZones_empty_without_checkout) ASSERT_THAT(v, IsAttrsOfSize(0)); } +TEST_F(TectonixTest, dirtyZones_marks_modified_tracked_file_zone_dirty) +{ + writeFile("areas/tools/dev/zone.nix", "{ dirty = true; }"); + + auto ctx = createTectonixContext(true); + auto v = ctx->eval(R"(builtins.unsafeTectonixInternalZoneIsDirty "//areas/tools/dev")"); + + ASSERT_THAT(v, IsTrue()); +} + +TEST_F(TectonixTest, dirtyZones_marks_deleted_tracked_file_zone_dirty) +{ + std::filesystem::remove(repoPath / "areas/tools/dev/zone.nix"); + + auto ctx = createTectonixContext(true); + auto v = ctx->eval(R"(builtins.unsafeTectonixInternalZoneIsDirty "//areas/tools/dev")"); + + ASSERT_THAT(v, IsTrue()); +} + // ============================================================================ // Phase 3: EvalState Method Tests - getWorldRepo // ============================================================================ diff --git a/src/libexpr/eval.cc b/src/libexpr/eval.cc index 697693e280..14cce32908 100644 --- a/src/libexpr/eval.cc +++ b/src/libexpr/eval.cc @@ -728,73 +728,39 @@ const std::map & EvalState::getTectonixDi } } - // Get dirty files via git status with -z for NUL-separated output - // This handles filenames with special characters correctly auto checkoutPath = settings.tectonixCheckoutPath.get(); - std::string gitStatusOutput; + GitRepo::WorkdirInfo workdirInfo; try { - gitStatusOutput = runProgram("git", true, {"-C", checkoutPath, "status", "--porcelain", "-z"}); - } catch (ExecError & e) { - // If git status fails, treat all zones as clean (fallback) + workdirInfo = GitRepo::openRepo(checkoutPath, {})->getDirtyWorkdirInfo(); + } catch (Error & e) { + // If status fails, treat all zones as clean (fallback) // This ensures call_once completes and we don't retry with partial state - warn("failed to get git status for dirty zone detection in '%s': %s; treating all zones as clean", checkoutPath, e.what()); + warn("failed to get working tree status for dirty zone detection in '%s': %s; treating all zones as clean", checkoutPath, e.what()); return; } - // Parse NUL-separated output - // Format with -z: XY SP path NUL [orig-path NUL for renames/copies] - size_t pos = 0; - while (pos < gitStatusOutput.size()) { - // Find the next NUL - auto nulPos = gitStatusOutput.find('\0', pos); - if (nulPos == std::string::npos) - break; - - auto entry = gitStatusOutput.substr(pos, nulPos - pos); - pos = nulPos + 1; - - // Git porcelain format: "XY PATH" where XY is 2-char status, then space, then path - // Minimum valid entry is "X P" (4 chars): status + space + 1-char path - if (entry.size() < 4) continue; - - // XY is first 2 chars, then space, then path - char xy0 = entry[0]; - std::string rawPath = entry.substr(3); - - // Collect paths to check - destination path is always included - std::vector pathsToCheck; - pathsToCheck.push_back("/" + rawPath); - - // For renames (R) and copies (C), also process the original path - // Both source and destination zones should be marked dirty - if (xy0 == 'R' || xy0 == 'C') { - auto nextNul = gitStatusOutput.find('\0', pos); - if (nextNul != std::string::npos) { - auto origPath = gitStatusOutput.substr(pos, nextNul - pos); - pathsToCheck.push_back("/" + origPath); - pos = nextNul + 1; - } - } + std::set dirtyPaths = workdirInfo.dirtyFiles; + dirtyPaths.insert(workdirInfo.deletedFiles.begin(), workdirInfo.deletedFiles.end()); - for (const auto & filePath : pathsToCheck) { - auto repoPath = filePath.substr(1); + for (const auto & dirtyPath : dirtyPaths) { + auto repoPath = std::string(dirtyPath.rel()); + auto filePath = "/" + repoPath; - if (fullCheckout) { - if (auto zonePath = findZonePathForRepoPath(*manifest, repoPath)) { - auto & info = tectonixDirtyZones[*zonePath]; - info.dirty = true; - info.dirtyFiles.insert(repoPath); - } - continue; + if (fullCheckout) { + if (auto zonePath = findZonePathForRepoPath(*manifest, repoPath)) { + auto & info = tectonixDirtyZones[*zonePath]; + info.dirty = true; + info.dirtyFiles.insert(repoPath); } + continue; + } - for (auto & [zonePath, info] : tectonixDirtyZones) { - auto normalized = "/" + normalizeZonePath(zonePath); - if (hasPrefix(filePath, normalized + "/") || filePath == normalized) { - info.dirty = true; - info.dirtyFiles.insert(repoPath); - break; - } + for (auto & [zonePath, info] : tectonixDirtyZones) { + auto normalized = "/" + normalizeZonePath(zonePath); + if (hasPrefix(filePath, normalized + "/") || filePath == normalized) { + info.dirty = true; + info.dirtyFiles.insert(repoPath); + break; } } } diff --git a/src/libexpr/include/nix/expr/eval.hh b/src/libexpr/include/nix/expr/eval.hh index 0a38f60b56..cea5544eb6 100644 --- a/src/libexpr/include/nix/expr/eval.hh +++ b/src/libexpr/include/nix/expr/eval.hh @@ -539,7 +539,7 @@ private: mutable std::set tectonixSparseCheckoutRoots; /** Per-zone dirty status: whether the zone is dirty, and if so, which - * repo-relative file paths are dirty (from git status). */ + * repo-relative tracked checkout paths differ from disk. */ struct ZoneDirtyInfo { bool dirty = false; boost::unordered_flat_set dirtyFiles; // repo-relative paths diff --git a/src/libfetchers-tests/git-utils.cc b/src/libfetchers-tests/git-utils.cc index 81a9862177..263e0b01b2 100644 --- a/src/libfetchers-tests/git-utils.cc +++ b/src/libfetchers-tests/git-utils.cc @@ -2,6 +2,7 @@ #include "nix/util/file-system.hh" #include #include +#include #include #include #include @@ -175,6 +176,52 @@ TEST_F(GitUtilsTest, peel_reference) git_repository_free(rawRepo); } +TEST_F(GitUtilsTest, getDirtyWorkdirInfo_reports_tracked_modifications_and_deletions) +{ + git_repository * rawRepo = nullptr; + ASSERT_EQ(git_repository_open(&rawRepo, tmpDir.string().c_str()), 0); + + writeFile((tmpDir / "modified.txt").string(), "clean\n"); + writeFile((tmpDir / "deleted.txt").string(), "clean\n"); + + git_index * index = nullptr; + ASSERT_EQ(git_repository_index(&index, rawRepo), 0); + ASSERT_EQ(git_index_add_bypath(index, "modified.txt"), 0); + ASSERT_EQ(git_index_add_bypath(index, "deleted.txt"), 0); + ASSERT_EQ(git_index_write(index), 0); + + git_oid treeOid; + ASSERT_EQ(git_index_write_tree(&treeOid, index), 0); + + git_tree * tree = nullptr; + ASSERT_EQ(git_tree_lookup(&tree, rawRepo, &treeOid), 0); + + git_signature * sig = nullptr; + ASSERT_EQ(git_signature_now(&sig, "nix", "nix@example.com"), 0); + + git_oid commitOid; + ASSERT_EQ(git_commit_create_v(&commitOid, rawRepo, "HEAD", sig, sig, nullptr, "initial commit", tree, 0), 0); + + git_signature_free(sig); + git_tree_free(tree); + git_index_free(index); + git_repository_free(rawRepo); + + writeFile((tmpDir / "modified.txt").string(), "dirty\n"); + std::filesystem::remove(tmpDir / "deleted.txt"); + writeFile((tmpDir / "untracked.txt").string(), "untracked\n"); + + auto workdirInfo = openRepo()->getDirtyWorkdirInfo(); + + ASSERT_TRUE(workdirInfo.isDirty); + ASSERT_TRUE(workdirInfo.dirtyFiles.contains(CanonPath("modified.txt"))); + ASSERT_TRUE(workdirInfo.deletedFiles.contains(CanonPath("deleted.txt"))); + ASSERT_FALSE(workdirInfo.dirtyFiles.contains(CanonPath("untracked.txt"))); + ASSERT_FALSE(workdirInfo.deletedFiles.contains(CanonPath("untracked.txt"))); + ASSERT_TRUE(workdirInfo.files.empty()); + ASSERT_FALSE(workdirInfo.headRev.has_value()); +} + // ============================================================================ // Tests for getSubtreeSha (Phase 2) // ============================================================================ diff --git a/src/libfetchers/git-utils.cc b/src/libfetchers/git-utils.cc index 9e79cdbff8..940b02614a 100644 --- a/src/libfetchers/git-utils.cc +++ b/src/libfetchers/git-utils.cc @@ -562,6 +562,35 @@ struct GitRepoImpl : GitRepo, std::enable_shared_from_this return info; } + WorkdirInfo getDirtyWorkdirInfo() override + { + WorkdirInfo info; + + std::function statusCallback = [&](const char * path, + unsigned int statusFlags) { + if (statusFlags == GIT_STATUS_CURRENT) + return 0; + + auto canonPath = CanonPath(path); + if (statusFlags & GIT_STATUS_WT_DELETED) + info.deletedFiles.insert(canonPath); + else + info.dirtyFiles.insert(canonPath); + + info.isDirty = true; + return 0; + }; + + git_status_options options = GIT_STATUS_OPTIONS_INIT; + options.show = GIT_STATUS_SHOW_WORKDIR_ONLY; + options.flags |= GIT_STATUS_OPT_EXCLUDE_SUBMODULES; + + if (git_status_foreach_ext(*this, &options, &statusCallbackTrampoline, &statusCallback)) + throw Error("getting dirty working directory files: %s", git_error_last()->message); + + return info; + } + std::optional getWorkdirRef() override { Reference ref; diff --git a/src/libfetchers/include/nix/fetchers/git-utils.hh b/src/libfetchers/include/nix/fetchers/git-utils.hh index fd14cab555..4cf2f5e8d7 100644 --- a/src/libfetchers/include/nix/fetchers/git-utils.hh +++ b/src/libfetchers/include/nix/fetchers/git-utils.hh @@ -89,6 +89,9 @@ struct GitRepo virtual WorkdirInfo getWorkdirInfo() = 0; + /** Return tracked checkout paths that differ from disk. */ + virtual WorkdirInfo getDirtyWorkdirInfo() = 0; + static WorkdirInfo getCachedWorkdirInfo(const std::filesystem::path & path); /* Get the ref that HEAD points to. */ From 037eb5127c20e001372a667d359d34948a25b5ea Mon Sep 17 00:00:00 2001 From: Burke Libbey Date: Fri, 5 Jun 2026 10:35:13 -0400 Subject: [PATCH 4/4] build: use reftables-based sparse libgit2 --- packaging/dependencies.nix | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packaging/dependencies.nix b/packaging/dependencies.nix index 8ec4c361d0..73dbfcb1db 100644 --- a/packaging/dependencies.nix +++ b/packaging/dependencies.nix @@ -43,12 +43,12 @@ scope: { }); libgit2 = pkgs.libgit2.overrideAttrs (prevAttrs: { - version = "1.9.0-shopify-sdir"; + version = "1.9.3-shopify-sdir"; src = pkgs.fetchFromGitHub { owner = "Shopify"; repo = "libgit2"; - rev = "0def884ade4cb7c53ab62ae4c59be176606c851e"; - hash = "sha256-s0CeQZzz7FSux3wXQO6vePg3/yy2ivC44J01w8/Jxqs="; + rev = "a9fcda0bb13e1a30bf0d9edee2ab273ab8c0b92d"; + hash = "sha256-haEZXPV3hNTUhbdEQ/38ZoV0c9qWV84B5VgJ2lq+3E8="; }; patches = lib.filter ( patch: !(lib.hasSuffix "libgit2-darwin-case-sensitive-build.patch" (toString patch))