Skip to content

Commit c6aa973

Browse files
committed
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.
1 parent 8c45e44 commit c6aa973

4 files changed

Lines changed: 173 additions & 31 deletions

File tree

src/libexpr/eval.cc

Lines changed: 106 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,44 @@ bool EvalState::isTectonixSourceAvailable() const
485485
return !settings.tectonixCheckoutPath.get().empty();
486486
}
487487

488+
bool EvalState::isTectonixFullCheckout() const
489+
{
490+
std::call_once(tectonixFullCheckoutFlag, [this]() {
491+
tectonixFullCheckout = false;
492+
if (!isTectonixSourceAvailable())
493+
return;
494+
495+
StringMap gitEnvironment = getEnv();
496+
gitEnvironment.erase("GIT_DIR");
497+
gitEnvironment.erase("GIT_WORK_TREE");
498+
gitEnvironment.erase("GIT_COMMON_DIR");
499+
500+
auto checkoutPath = settings.tectonixCheckoutPath.get();
501+
auto [gitConfigCode, gitConfigOutput] = runProgram(
502+
{.program = "git",
503+
.args = {"-C", checkoutPath, "config", "--bool", "--get", "core.sparseCheckout"},
504+
.environment = gitEnvironment});
505+
506+
// If core.sparseCheckout is unset, git config exits non-zero. That is a
507+
// normal full-checkout state. Other failures will be caught later by the
508+
// git-status path and treated as clean.
509+
if (!statusOk(gitConfigCode)) {
510+
tectonixFullCheckout = true;
511+
debug("source checkout at '%s' has no sparse-checkout config; treating as full checkout", checkoutPath);
512+
return;
513+
}
514+
515+
auto value = trim(gitConfigOutput);
516+
tectonixFullCheckout = value != "true";
517+
debug(
518+
"source checkout at '%s' core.sparseCheckout=%s; fullCheckout=%s",
519+
checkoutPath,
520+
value,
521+
tectonixFullCheckout ? "true" : "false");
522+
});
523+
return tectonixFullCheckout;
524+
}
525+
488526
// Helper to normalize zone paths: strip leading // prefix
489527
// Zone paths in manifest have // prefix (e.g., //areas/tools/dev)
490528
// Filesystem operations need paths without // (e.g., areas/tools/dev)
@@ -496,6 +534,22 @@ static std::string normalizeZonePath(std::string_view zonePath)
496534
return path;
497535
}
498536

537+
static std::optional<std::string> findZonePathForRepoPath(const nlohmann::json & manifest, std::string_view repoPath)
538+
{
539+
std::string candidate(repoPath);
540+
while (!candidate.empty()) {
541+
auto zonePath = "//" + candidate;
542+
if (manifest.contains(zonePath))
543+
return zonePath;
544+
545+
auto slash = candidate.rfind('/');
546+
if (slash == std::string::npos)
547+
break;
548+
candidate.resize(slash);
549+
}
550+
return std::nullopt;
551+
}
552+
499553
// Helper to sanitize zone path for use in store path names.
500554
// Store paths only allow: a-zA-Z0-9 and +-._?=
501555
// Replaces / with - and any other invalid chars with _
@@ -627,10 +681,18 @@ const std::map<std::string, EvalState::ZoneDirtyInfo> & EvalState::getTectonixDi
627681
if (!isTectonixSourceAvailable())
628682
return;
629683

630-
// Get sparse checkout roots (zone IDs)
631-
auto & sparseRoots = getTectonixSparseCheckoutRoots();
632-
if (sparseRoots.empty())
633-
return;
684+
bool fullCheckout = isTectonixFullCheckout();
685+
686+
// In sparse checkout mode, use the explicit root set. In full checkout
687+
// mode, avoid expanding every zone eagerly; dirty files will be mapped
688+
// to zones directly from their repo-relative paths.
689+
const std::set<std::string> * sparseRoots = nullptr;
690+
if (!fullCheckout) {
691+
auto & roots = getTectonixSparseCheckoutRoots();
692+
if (roots.empty())
693+
return;
694+
sparseRoots = &roots;
695+
}
634696

635697
// Get manifest (uses cached parsed JSON)
636698
const nlohmann::json * manifest;
@@ -644,21 +706,26 @@ const std::map<std::string, EvalState::ZoneDirtyInfo> & EvalState::getTectonixDi
644706
return;
645707
}
646708

647-
// Build map of zone ID -> zone path for sparse roots only
709+
// Build map of zone ID -> zone path for sparse roots only. Full
710+
// checkouts do not need this map because every zone is physically
711+
// available and clean zones can stay implicit.
648712
std::map<std::string, std::string> zoneIdToPath;
649-
for (auto & [path, value] : manifest->items()) {
650-
if (!value.contains("id") || !value.at("id").is_string()) {
651-
warn("zone '%s' in manifest has missing or non-string 'id' field", path);
652-
continue;
713+
if (!fullCheckout) {
714+
for (auto & [path, value] : manifest->items()) {
715+
if (!value.contains("id") || !value.at("id").is_string()) {
716+
warn("zone '%s' in manifest has missing or non-string 'id' field", path);
717+
continue;
718+
}
719+
auto & id = value.at("id").get_ref<const std::string &>();
720+
if (sparseRoots->count(id))
721+
zoneIdToPath[id] = path;
653722
}
654-
auto & id = value.at("id").get_ref<const std::string &>();
655-
if (sparseRoots.count(id))
656-
zoneIdToPath[id] = path;
657-
}
658723

659-
// Initialize all sparse-checked-out zones as not dirty
660-
for (auto & [zoneId, zonePath] : zoneIdToPath) {
661-
tectonixDirtyZones[zonePath] = {};
724+
// Initialize all sparse-checked-out zones as not dirty. Full
725+
// checkouts only materialize dirty entries.
726+
for (auto & [zoneId, zonePath] : zoneIdToPath) {
727+
tectonixDirtyZones[zonePath] = {};
728+
}
662729
}
663730

664731
// Get dirty files via git status with -z for NUL-separated output
@@ -710,11 +777,22 @@ const std::map<std::string, EvalState::ZoneDirtyInfo> & EvalState::getTectonixDi
710777
}
711778

712779
for (const auto & filePath : pathsToCheck) {
780+
auto repoPath = filePath.substr(1);
781+
782+
if (fullCheckout) {
783+
if (auto zonePath = findZonePathForRepoPath(*manifest, repoPath)) {
784+
auto & info = tectonixDirtyZones[*zonePath];
785+
info.dirty = true;
786+
info.dirtyFiles.insert(repoPath);
787+
}
788+
continue;
789+
}
790+
713791
for (auto & [zonePath, info] : tectonixDirtyZones) {
714792
auto normalized = "/" + normalizeZonePath(zonePath);
715793
if (hasPrefix(filePath, normalized + "/") || filePath == normalized) {
716794
info.dirty = true;
717-
info.dirtyFiles.insert(filePath.substr(1));
795+
info.dirtyFiles.insert(repoPath);
718796
break;
719797
}
720798
}
@@ -724,11 +802,21 @@ const std::map<std::string, EvalState::ZoneDirtyInfo> & EvalState::getTectonixDi
724802
size_t dirtyCount = 0;
725803
for (const auto & [_, info] : tectonixDirtyZones)
726804
if (info.dirty) dirtyCount++;
727-
debug("computed dirty zones: %d of %d zones are dirty", dirtyCount, tectonixDirtyZones.size());
805+
debug("computed dirty zones: %d of %d materialized zones are dirty", dirtyCount, tectonixDirtyZones.size());
728806
});
729807
return tectonixDirtyZones;
730808
}
731809

810+
bool EvalState::isTectonixZoneDirty(std::string_view zonePath) const
811+
{
812+
if (!isTectonixSourceAvailable())
813+
return false;
814+
815+
auto & dirtyZones = getTectonixDirtyZones();
816+
auto it = dirtyZones.find(std::string(zonePath));
817+
return it != dirtyZones.end() && it->second.dirty;
818+
}
819+
732820
// Path to the tectonix manifest file within the world repository
733821
static constexpr std::string_view TECTONIX_MANIFEST_PATH = "/.meta/manifest.json";
734822

src/libexpr/include/nix/expr/eval.hh

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,10 @@ private:
530530
/** Cache: world path → tree SHA (lazy computed, cached at each path level) */
531531
const ref<boost::concurrent_flat_map<std::string, Hash>> worldTreeShaCache;
532532

533+
/** Lazy-initialized full-checkout detection (thread-safe via once_flag) */
534+
mutable std::once_flag tectonixFullCheckoutFlag;
535+
mutable bool tectonixFullCheckout = false;
536+
533537
/** Lazy-initialized set of zone IDs in sparse checkout (thread-safe via once_flag) */
534538
mutable std::once_flag tectonixSparseCheckoutRootsFlag;
535539
mutable std::set<std::string> tectonixSparseCheckoutRoots;
@@ -635,12 +639,18 @@ public:
635639
/** Check if we're in source-available mode */
636640
bool isTectonixSourceAvailable() const;
637641

642+
/** Check if source-available mode points at a non-sparse/full checkout */
643+
bool isTectonixFullCheckout() const;
644+
638645
/** Get set of zone IDs in sparse checkout (source-available mode only) */
639646
const std::set<std::string> & getTectonixSparseCheckoutRoots() const;
640647

641-
/** Get map of zone path → dirty status (only for sparse-checked-out zones) */
648+
/** Get map of zone path → dirty status */
642649
const std::map<std::string, ZoneDirtyInfo> & getTectonixDirtyZones() const;
643650

651+
/** Check whether a zone has uncommitted changes in source-available mode */
652+
bool isTectonixZoneDirty(std::string_view zonePath) const;
653+
644654
/** Get cached manifest content (thread-safe, lazy-loaded) */
645655
const std::string & getManifestContent() const;
646656

src/libexpr/primops/tectonix.cc

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -260,12 +260,12 @@ static RegisterPrimOp primop_unsafeTectonixInternalDirtyZones({
260260
.name = "__unsafeTectonixInternalDirtyZones",
261261
.args = {},
262262
.doc = R"(
263-
Get the dirty status of zones in the sparse checkout.
263+
Get the dirty status of zones in the checkout.
264264
265265
Returns an attrset mapping zone paths to booleans indicating whether
266-
the zone has uncommitted changes.
267-
268-
Only includes zones that are in the sparse checkout.
266+
the zone has uncommitted changes. Sparse checkouts include all sparse
267+
roots with clean zones set to false. Full checkouts include dirty zones
268+
only so the evaluator does not eagerly expand every zone.
269269
270270
Example: `builtins.unsafeTectonixInternalDirtyZones."//areas/tools/dev"` returns `true` or `false`.
271271
@@ -274,6 +274,26 @@ static RegisterPrimOp primop_unsafeTectonixInternalDirtyZones({
274274
.fun = prim_unsafeTectonixInternalDirtyZones,
275275
});
276276

277+
// ============================================================================
278+
// builtins.__unsafeTectonixInternalFullCheckout
279+
// Returns whether source-available mode points at a non-sparse/full checkout
280+
// ============================================================================
281+
static void prim_unsafeTectonixInternalFullCheckout(EvalState & state, const PosIdx pos, Value ** args, Value & v)
282+
{
283+
v.mkBool(state.isTectonixFullCheckout());
284+
}
285+
286+
static RegisterPrimOp primop_unsafeTectonixInternalFullCheckout({
287+
.name = "__unsafeTectonixInternalFullCheckout",
288+
.args = {},
289+
.doc = R"(
290+
Return true when `--tectonix-checkout-path` points at a non-sparse/full
291+
checkout (`core.sparseCheckout` unset or false). This lets Tectonix treat
292+
zones as source-available without materializing a root entry for every zone.
293+
)",
294+
.fun = prim_unsafeTectonixInternalFullCheckout,
295+
});
296+
277297
// ============================================================================
278298
// builtins.__unsafeTectonixInternalZoneIsDirty zonePath
279299
// Returns whether a given zone is dirty in the checkout
@@ -285,14 +305,7 @@ static void prim_unsafeTectonixInternalZoneIsDirty(EvalState & state, const PosI
285305

286306
validateZonePath(state, pos, zonePath);
287307

288-
bool isDirty = false;
289-
if (state.isTectonixSourceAvailable()) {
290-
auto & dirtyZones = state.getTectonixDirtyZones();
291-
auto it = dirtyZones.find(std::string(zonePath));
292-
isDirty = it != dirtyZones.end() && it->second.dirty;
293-
}
294-
295-
v.mkBool(isDirty);
308+
v.mkBool(state.isTectonixZoneDirty(zonePath));
296309
}
297310

298311
static RegisterPrimOp primop_unsafeTectonixInternalZoneIsDirty({

tests/functional/tectonix/dirty-zones.sh

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@ HEAD_SHA=$(get_head_sha "$TEST_WORLD")
1010

1111
echo "Testing dirty zone detection..."
1212

13+
# Test worlds are regular/full checkouts unless sparse-checkout is explicitly enabled.
14+
full_checkout=$(tectonix_eval_json "$TEST_WORLD/.git" "$HEAD_SHA" \
15+
'builtins.unsafeTectonixInternalFullCheckout' \
16+
--option tectonix-checkout-path "$TEST_WORLD")
17+
echo "Full checkout status: $full_checkout"
18+
19+
if [[ "$full_checkout" != "true" ]]; then
20+
fail "Checkout should be detected as full when core.sparseCheckout is unset"
21+
fi
22+
1323
# First, verify zone is clean
1424
zone_is_dirty=$(tectonix_eval_json "$TEST_WORLD/.git" "$HEAD_SHA" \
1525
'builtins.unsafeTectonixInternalZoneIsDirty "//areas/tools/dev"' \
@@ -56,4 +66,25 @@ if [[ "$clean_zone_dirty" == "true" ]]; then
5666
fail "Unmodified zone should not be dirty"
5767
fi
5868

69+
# Enabling sparse checkout should flip the full-checkout detector while retaining
70+
# dirty-zone behavior via sparse-checkout-roots.
71+
git -C "$TEST_WORLD" config core.sparseCheckout true
72+
sparse_full_checkout=$(tectonix_eval_json "$TEST_WORLD/.git" "$HEAD_SHA" \
73+
'builtins.unsafeTectonixInternalFullCheckout' \
74+
--option tectonix-checkout-path "$TEST_WORLD")
75+
echo "Sparse checkout full status: $sparse_full_checkout"
76+
77+
if [[ "$sparse_full_checkout" != "false" ]]; then
78+
fail "Checkout should not be detected as full when core.sparseCheckout=true"
79+
fi
80+
81+
sparse_zone_dirty=$(tectonix_eval_json "$TEST_WORLD/.git" "$HEAD_SHA" \
82+
'builtins.unsafeTectonixInternalZoneIsDirty "//areas/tools/dev"' \
83+
--option tectonix-checkout-path "$TEST_WORLD")
84+
echo "Sparse dirty zone status: $sparse_zone_dirty"
85+
86+
if [[ "$sparse_zone_dirty" != "true" ]]; then
87+
fail "Sparse dirty zone should still be dirty after enabling sparse checkout"
88+
fi
89+
5990
echo "Dirty zone tests passed!"

0 commit comments

Comments
 (0)