Skip to content

Merge Determinate Nix v3.23.1 - #78

Closed
joshheinrichs-shopify wants to merge 1355 commits into
mainfrom
merge-v3.23.1
Closed

joshheinrichs-shopify wants to merge 1355 commits into
mainfrom
merge-v3.23.1

Conversation

@joshheinrichs-shopify

Copy link
Copy Markdown
Contributor

Motivation

Sync with Determinate Nix: v3.21.9 → v3.23.1 (Nix 2.34.8 → 2.35.2), 1352 upstream commits. Same shape as the previous syncs: a merge of the release tag with conflicts resolved in the merge commit, followed by fixups as their own commits.

Context

Three commits; read them in order.

  1. Merge tag 'v3.23.1' — 27 conflicted files. The merge message lists every resolution worth knowing about. The ones that are more than mechanical:
    • Upstream's new CachingSourceAccessor in rootFS didn't forward tracksEvalAccesses/recordEvalAccess and served readLink from its cache, so tracked dependency discovery recorded nothing after an untracked warm-up. Fixed in the wrapper; caught by tecnix/builtins.sh.
    • Boehm GC: upstream replaced the sp-corrector with a bdwgc fork that registers fiber/coroutine stacks; BoehmThreadStack (Fix GC deadlock by caching native thread stack bounds #64) had nothing left to fix and is dropped with its test.
    • git.cc: shallow-submodule optimism (Optimistically shallow clone submodules #39) and the legacy-export cache guard re-applied on top of upstream's getTree(legacy) restructuring.
    • git-lfs: upstream now unit-tests endpoint derivation; it's split out as lfsEndpointForRemote() so explicit lfs.url/remote.origin.lfsurl stay verbatim and getLfsApi() still only resolves auth. runProgram lost stdin redirection, so git credential fill is spawned directly. fixGitURL("") now throws, so a repo with no origin/lfs.url fails on an actual LFS fetch rather than on accessor creation.
    • AsyncPathWriter::writePaths: upstream's version replaces ours (same fix); addedPaths/wasAdded() kept for nix provenance.
    • -Werror=weak-vtables and the dropped per-library unity default: key functions and the includes the unity build used to supply.
  2. libexpr: keep Tecnix tracking state with the fiber that owns it — the parallel evaluator now interleaves work items as fibers on one thread; currentTecnixThreadState and the tecnixMemoize cycle guard were thread_local. Without this the bare merge segfaults in tecnix/builtins.sh at the --eval-cores 2 step.
  3. packaging: patch libgit2 … — upstream pins libgit2 2.0.0-rc.1 (built from source, not cached). Its GIT_RMDIR_EMPTY_PARENTS calls rmdir() on parent paths with a trailing slash, which macOS resolves through a symlink (deleting an unrelated empty dir, or EINVAL on macOS 27 for a link to an ancestor). Its own test suite trips over it on any case-insensitive volume on macOS 27, i.e. the default Nix store volume, so no dev shell and no macOS build. Small upstreamable patch; libgit2's full suite passes with it.

Not in this PR: withOtel is left at upstream's default (opt-in at runtime via OTEL_EXPORTER_OTLP_ENDPOINT; say if it should be off like Sentry). tecnix-read-gcs will need a rebase onto this (filetransfer.cc, gcs-creds.hh, libstore-tests/meson.build conflict).

Verified locally (aarch64-darwin): build, all unit-test suites, tecnix/tectonix functional suites 11/11, 18 targeted upstream functional tests (fetchGit*, lang, eval, gc, provenance, symlinks, …), maintainers/format.sh. The full functional suite and Linux are for CI.

domenkozar and others added 30 commits September 13, 2026 04:30
getDefaultExtraPlatforms() ran `arch -arch x86_64 /usr/bin/true` to decide
whether to add x86_64-darwin to extra-platforms. Because this is the default
value of the extra-platforms setting, it runs during static initialization of
the global `settings` object, so every libstore linked process on Apple
silicon paid roughly 13ms of subprocess startup, even ones that never read
extra-platforms (devenv version, hook-should-activate, nix --version).

Check for the Rosetta runtime file instead. It is present exactly when Rosetta
2 is installed, which is exactly when the exec probe succeeded, and a stat
costs about 0.01ms versus 12.5ms for the subprocess.

Assisted-by: Claude Code (Claude Opus 4.8)
(cherry picked from commit a8419ef8e770401fd6e6ce4bfeacdbe0d7392b4f)
[Backport 2.35-maintenance] libstore: detect Rosetta via runtime file instead of spawning arch
`processConnection()` cleared the interrupt flag in a `Finally` that
ran *before* the `MonitorFdHup` was destroyed. So if the monitor
thread noticed the client hanging up after that, it set the flag
again, and the daemon's subsequent OpenTelemetry export failed with
`Interrupted` (which is deliberately not reported). Whether the
monitor thread won that race is why the daemon's spans sometimes went
missing.

Clear the flag only once the monitor is gone, i.e. its thread joined.

Assisted-by: Claude Fable 5.1 <noreply@anthropic.com>
Pass the trace context to the build hook in the `TRACEPARENT`
environment variable, and have the root span of any Nix process be
parented to that, if set. So a remote build now shows up in the trace
of the `nix` invocation that requested it: `nix` -> `build-remote` ->
the remote daemon (the latter via the existing `ssh-ng://` handshake).

Two things stood in the way. `build-remote` replaces the global logger
with a JSON logger, discarding the OpenTelemetry logger that `main()`
had attached, so like the daemon it now sets up tracing itself. And
`HookInstance` sent the hook `SIGTERM` on teardown, which interrupted
its export; since the hook exits by itself on EOF, just give it a
chance to do so.

A root span parented to a remote context is no longer implicitly a
server span; the daemon says so explicitly.

Assisted-by: Claude Fable 5.1 <noreply@anthropic.com>
The build hook is started for every build, but usually ends up
declining (e.g. because there are no builders for the required
system). It set up tracing at startup regardless, so every build
uploaded a trace consisting of nothing but the hook's root span.
Instead, set up tracing when we're actually about to connect to a
remote builder.

That also makes the workaround in the OpenTelemetry test unnecessary,
which disabled the hook to keep its uploads out of the count.

Assisted-by: Claude Fable 5.1 <noreply@anthropic.com>
Rather than in the two places (the forked child of the socket daemon,
and the stdio daemon) that lead to it. It's a no-op unless configured,
and a connection is served once per process, so this is exactly the
point at which to set up tracing.

Assisted-by: Claude Fable 5.1 <noreply@anthropic.com>
Every caller of `initOtel()` immediately created the logger and added
it to the global logger, so do that in `initOtel()` itself, which now
takes the root span's name and parent. That leaves `initOtel()` as the
only thing in the header, so the `OpenTelemetryLogger` class there
(an empty base of the actual implementation) goes away too; the
implementation takes its name.

Assisted-by: Claude Fable 5.1 <noreply@anthropic.com>
Now that `initOtel()` creates the logger, there's exactly one tracer
provider per logger, so the logger can own it (and the root span's
debug trace ID) rather than keeping it in a global. The logger creates
the provider itself; `initOtel()` is left with resolving the
configuration and creating the exporter from it.

That also makes the fork callback unnecessary: it discarded the global
state in forked children, but `startProcess()` already gives them a
fresh global logger (leaking the parent's), so nothing in a child
references the inherited provider; a child that wants tracing calls
`initOtel()` and gets its own.

Assisted-by: Claude Fable 5.1 <noreply@anthropic.com>
The parent sends the settings of every registered `Config`, but the
hook applied them via `settings.set()`, which only knows `Settings`
and silently returns false for anything else. So settings living in
other `Config`s never reached the hook: the OpenTelemetry ones (so
`nix build --otlp ...` didn't trace the hook), but also e.g.
`experimental-features` and the file transfer settings.

Apply them via `globalConfig.set()` instead. Only apply those that
differ from our own values, though, since we've read the same
configuration files: setting a deprecated setting warns even when it's
set to its default, which showed up in the output of every build.

(Having the parent send only its overridden settings instead doesn't
work: things like `--store` set the setting without marking it as
overridden, so the hook would end up using the wrong store.)

Assisted-by: Claude Fable 5.1 <noreply@anthropic.com>
A collector's probabilistic sampler may drop a trace regardless of the
W3C "sampled" flag, which makes the trace ID printed under
NIX_DEBUG_OTEL useless if the trace never arrives. A non-zero
`sampling.priority` attribute is the documented override, so set it on
all spans when debugging.

Assisted-by: Claude Fable 5.1 <noreply@anthropic.com>
The build hook exports its own telemetry (parented to ours via
TRACEPARENT), so replaying its activities into our logger produced
duplicate spans. Mark them as coming from a remote log source, like
we do for messages forwarded by the daemon.

Assisted-by: Claude Fable 5.1 <noreply@anthropic.com>
Commands that exec() another program (nix run/shell/develop/fmt,
nix-shell, and `--help` running man) stopped the logger but never
flushed it, so their traces were lost along with the exporter thread.

Assisted-by: Claude Fable 5.1 <noreply@anthropic.com>
The exporter uploads periodically as well as on exit, so a slow build
(e.g. on a loaded CI machine) spreads its spans over several uploads,
which shifted the upload numbers the test relied on. Instead, delete
the uploads before each step and select spans by name across all the
uploads received since.

Assisted-by: Claude Fable 5.1 <noreply@anthropic.com>
To get rid of:

hint: Using 'master' as the name for the initial branch. This default branch name
hint: will change to "main" in Git 3.0.

Changing that from master breaks the tests.

Also dedups test to use createGitRepo

(cherry picked from commit 242d09e49546d0edb386d109bfbe070631ab9aaa)
A rev-pinned fetch left the rev un-refed, so a later fetch of the same
URL had nothing to negotiate from and re-downloaded shared history.
Fetch into refs/nix/tip-<2 hex chars of rev> instead. Bucketing (256
slots) means unrelated revs don't keep evicting each other's tip the
way a single shared ref would.

Verified against a real ~7.7M-object repo: a third fetch that would've
re-downloaded 67,656 objects with a single shared ref dropped to 7
with bucketing.

(cherry picked from commit 74d9f993be1ca84e056984a092eb2836b321645c)
Without a network (as in the Nix sandbox on CI), `nix` turns off
substitution unless it's requested explicitly, so the build never
started a substitution goal and the test couldn't find its span.

Assisted-by: Claude Fable 5.1 <noreply@anthropic.com>
Fetches an exact rev, checks its bucket ref was created, fetches a
second rev, and asserts via GIT_TRACE_PACKET that the second fetch
negotiated against the first rev's ref instead of re-downloading.

(cherry picked from commit 99b5af91148e0aa92e292ac38c50b48af552d950)
(cherry picked from commit e20887e6a62567bdf124ea2412ac68bf498cbf71)
fetchers/git: negotiate rev-pinned fetches from bucketed refs
Outputs that no flake schema recognises are recorded in outputs.json
as { "unknown": true } without an "output" attribute, which made
evaluation of every baked flake fail with "attribute 'output' missing".

Assisted-by: Claude Fable 5.1 <noreply@anthropic.com>
By default, only outputs for the current system are baked, matching
'nix flake show'. With --all-systems, outputs for all systems are
included.

Assisted-by: Claude Fable 5.1 <noreply@anthropic.com>
Bakes the simple test flake and checks that the baked flake shows the
same outputs, that the baked derivations are builtin:substitute
derivations with the original output paths, that building them fails
without a substituter and succeeds by substituting from a binary cache,
and that --all-systems and legacyPackages work.

Assisted-by: Claude Fable 5.1 <noreply@anthropic.com>
Previously it returned only the derivation path, and the baked flake
took the output paths from outputs.json as plain strings. As a result,
a derivation using a baked output as an input didn't record the fake
derivation in its inputDrvs, so the output was never substituted.

Now it returns an attribute set of the same shape as derivationStrict:
drvPath plus one string per output, with proper string context. The
baked flake uses those for drvPath and outPath.

Add a test for depending on a baked flake.

Assisted-by: Claude Fable 5.1 <noreply@anthropic.com>
Substitution is disabled automatically when there is no network access
(as in the CI sandbox), so the file:// substituter was ignored and the
test failed. Pass --substitute explicitly, like substitution.sh does.

Assisted-by: Claude Fable 5.1 <noreply@anthropic.com>
Assisted-by: Claude Fable 5.1 <noreply@anthropic.com>
Flake schemas such as nixosConfigurations declare that the derivation
lives at a sub-path of the output attribute (e.g.
config.system.build.toplevel). The inventory didn't record this, so the
baked flake put the derivation at nixosConfigurations.<name> instead of
nixosConfigurations.<name>.config.system.build.toplevel.

The inventory JSON now includes a 'derivationAttrPath' attribute on
leaves with a non-empty path, and the baked flake nests the derivation
accordingly. Also don't require 'shortDescription', which these
schemas don't set.

Assisted-by: Claude Fable 5.1 <noreply@anthropic.com>
edolstra and others added 29 commits October 5, 2026 14:31
`CmdFlakeCheck` called `logEvalError()`, and thereby
`CachedEvalError::force()`, from within a catch handler. `force()`
evaluates the original attribute, which may have to wait on a thunk
that is being evaluated by another fiber. A fiber must not suspend
while an exception is being handled (the C++ exception state is
thread-local), so this tripped the assertion in `suspendFiber()`:

  Assertion '!std::current_exception() && !std::uncaught_exceptions()' failed in void nix::suspendFiber(...)

Fix this by capturing the exception as a `std::exception_ptr` in the
catch handler, and forcing/logging it only after the handler has
exited.

Assisted-by: Claude Fable 5.1 <noreply@anthropic.com>
nix flake check: Don't force cached eval errors inside a catch handler
Build bdwgc with `-DNO_BLACK_LISTING`. Black-listing avoids allocating
objects on pages that false pointers refer to. Its page sets are hash
tables of 2^21 bits (one per 4 KiB block, i.e. 8 GiB of address space
with LARGE_CONFIG on 64-bit). Beyond that, pages alias, and since
`GC_add_to_black_list_normal()` re-sets a bit whenever the previous
cycle's table already had it, bits never clear once aliasing starts.
Eventually every page looks black-listed, `GC_allochblk()` fails even
after a successful heap expansion, and `GC_allocobj()` gives up with
"Too many retries in GC_allocobj" (SIGABRT). This is what the Sentry
reports from `nix flake show` on 3.23.0 show; upstream bdwgc issues
#691 and #726 describe the same loop and are unresolved.

Nix gets little out of black-listing because interior pointers are
disabled, while it pays for it on every mark: Values hold many
pointers to non-GC memory (`Expr *`, `PrimOp *`, ...) inside the
plausible heap range, each of which updates the black lists.

Before/after on nixpkgs with a 64 MiB initial heap (~200 GC cycles):
`nix search` 77 s -> 46 s, `nix flake show --legacy` 94 s -> 53 s,
with identical peak RSS, heap size and allocation volume. bdwgc's own
statistics attribute the gain entirely to world-stopped marking
(62.8 s -> 37.0 s). With the default 4 GiB initial heap there is no
measurable difference. Functional tests pass.

Assisted-by: Claude Fable 5.1 <noreply@anthropic.com>
A forked child (e.g. a builtin builder such as `builtin:fetchurl`, or
an in-process build sandbox running on a `clone()` stack) inherits the
parent thread's `GC_current_stack` and registered stacks, but doesn't
execute on the stack that `GC_current_stack` describes and never runs
the collector. So the stack bookkeeping done by the coroutine GC hooks
is useless there at best; at worst it trips the consistency check in
`gcSaveStackPointer()` (as happened in the `githubFlakes` VM test,
where a `builtin:fetchurl` derivation panicked with "stack pointer
... is not within the stack ... that is being switched away from"), or
takes the collector's lock, which another thread of the parent may
have been holding at the time of the fork.

Register a fork callback that resets the hooks (and
`GC_current_stack`) in the child, and make the "closing" half of each
hook pair tolerate the hooks having been reset since the "opening"
half ran.

Assisted-by: Claude Fable 5.1 <noreply@anthropic.com>
…abase

Closing a file descriptor drops all POSIX locks that the process holds
on that file. So opening and closing db.sqlite-shm while another
connection to the same database is open in this process dropped
SQLite's lock on that file. Another process could then truncate it,
causing a SIGBUS in the first process. This showed up with the tarball
cache, which opens multiple connections per process.

We hold the lock on `shmFilesSynced` until the descriptor has been
closed, so that a concurrent open of the same database in another
thread cannot create a SQLite connection (and thus acquire POSIX locks
on db.sqlite-shm) while the first thread still has the file open.

Assisted-by: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit cc1cfbb3e629d3bae8178236bdfaa83f9a97a185)
SQLite: Only apply the ZFS -shm workaround on the first open of a database
Previously, if a package was replaced by a different store path with
the same version (e.g. a rebuild due to a dependency or source change),
`nix profile history` reported "No changes" for that generation. Now
it prints a `changed` line.

Also add a `--show-source` flag that shows the (abbreviated) locked
flake reference of each added, removed, upgraded or changed package.
For upgraded/changed packages, the old and new references are shown,
which explains where the change came from.

Assisted-by: Claude Fable 5.1 <noreply@anthropic.com>
…ne-yield

Fix GC stack scanning when a coroutine yields from another coroutine's stack
nix profile history: Show store path changes with the same version
The periodic purge of expired entries from the narinfo disk cache
does

  delete from NARs where (present = 0 and timestamp < ?)
                      or (present = 1 and timestamp < ?)

which, with only the (cache, hashPart) primary key available, is a
full table scan. On busy builders the cache grows to millions of rows
and several GiB, and the scan was measured to hold the SQLite write
lock for over two minutes on a cold file cache (NIX-491). Every
concurrent `nix copy` blocks on that lock, and uploader timeouts that
kill the purging process just cause the next process to start the
scan over.

Add an index on NARs(present, timestamp). SQLite then executes the
purge as two covering-index range searches. On a synthetic 1.5M-row,
3.1 GiB cache with 5000 expired entries, the warm-cache purge drops
from ~9 s and ~3 GiB of page reads to ~1 s and <100 MiB of page
reads for the delete itself (~3.5 s including the commit), and the
`nix` process startup that triggers it goes from 9.8 s to 4.4 s.

Bump the database filename to v4 so the index is created as part of
a fresh, empty database. Building the index in place on an existing
multi-GiB cache would itself hold the write lock for a full table
read, which is exactly the stall this is meant to remove.

Assisted-by: Claude Fable 5.1 <noreply@anthropic.com>
Assisted-by: Claude Fable 5.1 <noreply@anthropic.com>
The purge ran as a single DELETE in whichever process happened to
open the cache, holding the SQLite write lock for its entire
duration. Concurrent processes (e.g. parallel `nix copy` uploads)
blocked on it for the whole time, and if the purging process was
killed, e.g. by a caller's timeout, all work was rolled back and the
next process started over from scratch.

Delete in chunks of 1000 rows instead, each in its own autocommit
transaction:

* The write lock is released after every chunk, so concurrent
  writers wait at most for one chunk (a few hundred ms warm, under a
  second cold on NVMe) instead of the whole purge.

* Committed chunks survive if the process is killed. LastPurge is
  only updated once a chunk comes back short, so the next process to
  open the cache resumes where the killed one left off, and
  concurrent processes that both find the purge due simply delete
  chunks cooperatively.

The chunk subquery is served by the (present, timestamp) index, with
the outer delete going by rowid. Total purge cost is unchanged: on
tmpfs, deleting 100k expired rows from a 1.5M-row cache takes ~3 s
either way. In a contention test with a concurrent writer, the
longest wait for the write lock dropped from 33 s to 4.5 s (on ZFS,
which inflates both numbers). Killing the purge after 6 s left the
committed chunks in place with LastPurge unchanged, and the next run
finished the job.

Assisted-by: Claude Fable 5.1 <noreply@anthropic.com>
Processes that open the cache at around the same time all see that
the purge is due, since LastPurge is only updated when a purge
completes. With chunked deletion they would interleave harmlessly,
but they'd all contend for the SQLite write lock and do redundant
work.

Take a non-blocking exclusive lock on a lock file next to the
database before purging. Processes that fail to get it skip the
purge. The lock is released when its holder exits or is killed, and
since LastPurge is still only updated on completion, the next process
to open the cache then takes over.

Tested by starting eight processes at once with the purge due: one of
them did the entire purge, the other seven skipped it and started up
in under a second. Killing the lock holder half-way left LastPurge
unchanged and the next process picked up the remaining rows.

Assisted-by: Claude Fable 5.1 <noreply@anthropic.com>
Make purging the narinfo disk cache cheap and non-blocking
…manual

Add derivation metadata documentation to the Determinate Nix manual
Assisted-by: Claude Fable 5.1 <noreply@anthropic.com>
This skill describes how to edit the auto-generated release notes
(v<version>.md and changes.md) into shape during the release process.

Assisted-by: Claude Fable 5.1 <noreply@anthropic.com>
…f1b-a099-48b4-8864-e0cd3ab2be0b

Release v3.23.1
Determinate Nix 3.23.1 (Nix 2.35.2), from 3.21.9. Conflict resolutions
and adaptations of Tecnix code to upstream changes worth knowing about:

* CLAUDE.md -> AGENTS.md follows the upstream rename; our build line is
  kept, the DeterminateSystems-only PR/branch rules are not.

* .github/workflows/ci.yml and build.yml stay ours (upstream only
  bumped action hashes in code we had replaced).

* Boehm GC: upstream replaced the stack-pointer corrector
  (GC_set_sp_corrector) with a bdwgc fork that registers fiber and
  coroutine stacks explicitly. BoehmThreadStack (6fd6483) fixed the
  corrector and has nothing left to fix, so it and its test are dropped.

* AllowListSourceAccessor::isAllowed: the knownAllowed memo (c7c1e8e)
  is kept, as a boost::concurrent_flat_set like upstream's allowedPaths.

* filetransfer: the GCS bearer-token header moves into the per-attempt
  header setup upstream introduced.

* AsyncPathWriter::writePaths: upstream's version (temp roots, then a
  validity query, then skip valid paths) replaces ours, which did the
  same; addedPaths/wasAdded() stay for `nix provenance`.

* git.cc getAccessorFromCommit: our shallow-submodule optimism (#39) and
  the legacy-export cache guard are re-applied on top of upstream's
  getTree(legacy) restructuring.

* git-lfs: upstream now tests endpoint derivation; it is split out as
  lfsEndpointForRemote() and used by getLfsEndpointUrl(), so explicit
  lfs.url / remote.origin.lfsurl are still used verbatim and getLfsApi()
  still only resolves auth (git-lfs-authenticate / git credential fill).
  runProgram() lost stdin redirection upstream, so `git credential fill`
  is spawned directly. fixGitURL("") now throws, so a repository without
  an origin remote or lfs.url no longer fails merely by having LFS
  smudging enabled; it fails on an actual LFS fetch instead.

* Upstream's new CachingSourceAccessor in rootFS forwards
  tracksEvalAccesses()/recordEvalAccess() and does not answer readLink()
  from its cache for tracked accessors, which record the read themselves.
  Without this, tracked evaluation used the untracked import caches and
  recorded nothing after an untracked warm-up.

* -Werror=weak-vtables (nix-util/-store/-expr) and the dropped per-library
  unity default: key functions for our error/accessor/provider classes,
  and the includes the unity build used to supply.

* Derivations are now validated when written, so the eval-cache race test
  builds its fixture with Derivation::fillInOutputPaths().
Since Determinate Nix 3.22 the parallel evaluator runs work items on
fibers: when one blocks on a thunk another fiber owns, it is suspended
and its worker thread starts or resumes another fiber. Upstream parks
its own per-evaluation thread-locals (evalContext, the call depth, the
eval thread id) in the fiber record and swaps them on every switch.

currentTecnixThreadState got no such treatment, so a suspended tracked
work item left its trackingContext and source-deps frames -- pointers
into its own stack -- visible to whichever fiber ran next on that
thread, and saw that fiber's state when it resumed. Give the fiber
record a TecnixThreadState and swap it alongside evalContext.

The tecnixMemoize cycle guard was a thread-local stack with LIFO
push/pop, which interleaved fibers would both corrupt and trip (two
fibers legitimately missing on the same key looked like a cycle). Make
it a chain of frames on the evaluating stack, linked through the same
thread state, so it travels with the fiber too.
libgit2's `GIT_RMDIR_EMPTY_PARENTS` walks up a removed path's parents and
calls rmdir() on each with a trailing slash. On macOS a trailing slash
makes rmdir() resolve a symbolic link in that position and act on its
target: for a link to an unrelated empty directory it deletes that
directory; for a link to an ancestor it fails, with ENOTEMPTY on earlier
macOS and EINVAL on macOS 27. Linux returns ENOTDIR, which is what
libgit2's error handling expects.

Checkout reaches this on a case-insensitive filesystem whenever a file is
removed from a directory whose name collides with a symlink, which is
exactly libgit2's checkout::icase::overwrites_links_for_folders_when_forced
test. On macOS 27 it fails with "invalid path for filesystem '.../a/':
Invalid argument", so the libgit2 that upstream now pins (built from
source, unlike nixpkgs' cached one) could not be built on a default,
case-insensitive Nix store volume: no dev shell, no macos CI.

The patch strips the trailing slash before rmdir(), so the link itself is
named and every platform reports ENOTDIR. libgit2's full test suite passes
with it on macOS 27, inside the Nix sandbox. To be submitted upstream.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.