Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packaging/dependencies.nix
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,19 @@ scope: {
(prevAttrs.postInstall or "");
});

libgit2 = pkgs.libgit2.overrideAttrs (prevAttrs: {
version = "1.9.3-shopify-sdir";
src = pkgs.fetchFromGitHub {
owner = "Shopify";
repo = "libgit2";
rev = "a9fcda0bb13e1a30bf0d9edee2ab273ab8c0b92d";
hash = "sha256-haEZXPV3hNTUhbdEQ/38ZoV0c9qWV84B5VgJ2lq+3E8=";
};
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
Expand Down
20 changes: 20 additions & 0 deletions src/libexpr-tests/tectonix.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ============================================================================
Expand Down
182 changes: 118 additions & 64 deletions src/libexpr/eval.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -496,6 +534,22 @@ static std::string normalizeZonePath(std::string_view zonePath)
return path;
}

static std::optional<std::string> 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 _
Expand Down Expand Up @@ -627,10 +681,18 @@ const std::map<std::string, EvalState::ZoneDirtyInfo> & 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<std::string> * sparseRoots = nullptr;
if (!fullCheckout) {
auto & roots = getTectonixSparseCheckoutRoots();
if (roots.empty())
return;
sparseRoots = &roots;
}

// Get manifest (uses cached parsed JSON)
const nlohmann::json * manifest;
Expand All @@ -644,91 +706,83 @@ const std::map<std::string, EvalState::ZoneDirtyInfo> & 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<std::string, std::string> 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<const std::string &>();
if (sparseRoots->count(id))
zoneIdToPath[id] = path;
}
auto & id = value.at("id").get_ref<const std::string &>();
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
// 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<std::string> 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<CanonPath> dirtyPaths = workdirInfo.dirtyFiles;
dirtyPaths.insert(workdirInfo.deletedFiles.begin(), workdirInfo.deletedFiles.end());

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;
}

for (const auto & filePath : pathsToCheck) {
for (auto & [zonePath, info] : tectonixDirtyZones) {
auto normalized = "/" + normalizeZonePath(zonePath);
if (hasPrefix(filePath, normalized + "/") || filePath == normalized) {
info.dirty = true;
info.dirtyFiles.insert(filePath.substr(1));
break;
}
for (auto & [zonePath, info] : tectonixDirtyZones) {
auto normalized = "/" + normalizeZonePath(zonePath);
if (hasPrefix(filePath, normalized + "/") || filePath == normalized) {
info.dirty = true;
info.dirtyFiles.insert(repoPath);
break;
}
}
}

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";

Expand Down
14 changes: 12 additions & 2 deletions src/libexpr/include/nix/expr/eval.hh
Original file line number Diff line number Diff line change
Expand Up @@ -530,12 +530,16 @@ private:
/** Cache: world path → tree SHA (lazy computed, cached at each path level) */
const ref<boost::concurrent_flat_map<std::string, Hash>> 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<std::string> 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<std::string> dirtyFiles; // repo-relative paths
Expand Down Expand Up @@ -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<std::string> & 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<std::string, ZoneDirtyInfo> & 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;

Expand Down
37 changes: 25 additions & 12 deletions src/libexpr/primops/tectonix.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand All @@ -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
Expand All @@ -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({
Expand Down
Loading