delegate pin management to tack

This commit is contained in:
atagen 2026-05-29 00:25:36 +10:00
parent 070c3f0a7f
commit a93a51c997
8 changed files with 399 additions and 274 deletions

302
.tack/default.nix Normal file
View file

@ -0,0 +1,302 @@
# SPDX-License-Identifier: EUPL-1.2
# tack-managed resolver. delete this line to take ownership; tack will leave it alone afterwards.
let
inherit (builtins)
attrNames
attrValues
concatMap
elemAt
filter
foldl'
fromJSON
head
intersectAttrs
isList
isString
listToAttrs
mapAttrs
match
pathExists
readFile
substring
tail
trace
;
call =
{
overrides ? { },
}:
let
pins = fromTOML (readFile ./pins.toml);
lock = fromJSON (readFile ./pins.lock.json);
all_follow_raw = pins.all_follow or { };
# flatten `target = [aliases]` rows alongside `alias = "target"` rows
all_follow = foldl' (
acc: key:
let
val = all_follow_raw.${key};
in
if isList val then
acc
// {
${key} = key;
}
// listToAttrs (
map (a: {
name = a;
value = key;
}) val
)
else if isString val then
acc // { ${key} = val; }
else
acc
) { } (attrNames all_follow_raw);
# a path node's stored path is absolute, or relative to this resolver dir
fetchPin =
name:
let
node = lock.${name};
in
if (node.type or "") == "path" && substring 0 1 node.path != "/" then
fetchTree (node // { path = ./. + ("/" + node.path); })
else
fetchTree node;
fetchFixed =
name: entry:
let
raw = derivation {
inherit name;
inherit (entry) url;
builder = "builtin:fetchurl";
system = "builtin";
outputHash = entry.sha256;
outputHashAlgo = "sha256";
outputHashMode = "flat";
};
unpacked = derivation {
inherit name;
builder = "builtin:unpack-channel";
system = "builtin";
src = raw;
channelName = name;
};
in
if (entry.unpack or "file") == "tarball" then unpacked.outPath + "/" + name else raw.outPath;
resolveSpec = upLock: spec: if isList spec then walkPath upLock upLock.root spec else spec;
walkPath =
upLock: nodeName: path:
if path == [ ] then
nodeName
else
walkPath upLock (resolveSpec upLock upLock.nodes.${nodeName}.inputs.${head path}) (tail path);
followsFor =
pin:
let
rules = removeAttrs all_follow (pin.exclude_follow or [ ]);
in
{
level = (pin.follows or { }) // rules;
deep = rules;
};
resolveFollows = mapAttrs (
_: target: self.${target} or (throw "tack: follows target '${target}' is not a pin")
);
# follows key is `flake:name`, `tack:name`, or bare `name`
# project onto one side, rekeyed to bare names
followsForSide =
side: follows:
listToAttrs (
concatMap (
key:
let
m = match "(flake|tack):(.*)" key;
in
if m == null then
[
{
name = key;
value = follows.${key};
}
]
else if head m == side then
[
{
name = elemAt m 1;
value = follows.${key};
}
]
else
[ ]
) (attrNames follows)
);
mkCallerInputs =
upLock: nodeName: rawInputs: levelFollows: deepFollows:
let
resolved = resolveFollows levelFollows;
in
mapAttrs (
n: _decl:
resolved.${n} or (
if upLock != null then
let
ref =
(upLock.nodes.${nodeName}.inputs or { }).${n}
or (throw "tack: input '${n}' declared but not in flake.lock node '${nodeName}'");
childName = resolveSpec upLock ref;
childNode = upLock.nodes.${childName};
childSrc = fetchTree childNode.locked;
in
if childNode.flake or true then evalTransitive upLock childName childSrc deepFollows else childSrc
else
throw "tack: no flake.lock; cannot resolve input '${n}'"
)
) rawInputs;
mkFlakeResult =
sourceInfo: flakeDir: callerInputs: outputs:
outputs
// sourceInfo
// {
outPath = flakeDir;
inputs = callerInputs;
inherit outputs sourceInfo;
_type = "flake";
};
evalFlake =
sourceInfo: flakeDir: upLock: nodeName: levelFollows: deepFollows:
let
raw = import (flakeDir + "/flake.nix");
tackPinsPath = flakeDir + "/.tack/pins.toml";
hasTack = pathExists tackPinsPath;
upPins = if hasTack then fromTOML (readFile tackPinsPath) else { };
# project follows onto each side, keep only names that side has
# bare follow reaches both; `flake:`/`tack:` reaches just one
tackOverrides = resolveFollows (
intersectAttrs (upPins.inputs or { }) (followsForSide "tack" levelFollows)
);
flakeLevel = intersectAttrs (raw.inputs or { }) (followsForSide "flake" levelFollows);
# deep follows pass down raw, so each descendant re-projects per side
callerInputs = mkCallerInputs upLock nodeName (raw.inputs or { }) flakeLevel deepFollows;
# upstream declares its outputs forward tackOverrides; a closed `{ self }:`
# would throw on the extra kwarg, so forward only when declared
supportsOverrides = (upPins.tack or { }).recomposable or false;
extraArgs = if supportsOverrides && tackOverrides != { } then { inherit tackOverrides; } else { };
outputs = raw.outputs (callerInputs // extraArgs // { self = result; });
result =
let
base = mkFlakeResult sourceInfo flakeDir callerInputs outputs;
in
if hasTack && tackOverrides != { } && !supportsOverrides then
trace "tack: ${flakeDir}: not marked recomposable (set [tack] recomposable = true); overrides will not reach upstream" base
else
base;
in
result;
evalTransitive =
upLock: nodeName: sourceInfo: follows:
evalFlake sourceInfo sourceInfo.outPath upLock nodeName follows follows;
evalTopFlake =
sourceInfo: pin:
let
flakeDir = sourceInfo.outPath + (if pin ? dir then "/" + pin.dir else "");
upLockPath = flakeDir + "/flake.lock";
upLock = if pathExists upLockPath then fromJSON (readFile upLockPath) else null;
rootNode = if upLock != null then upLock.root else null;
f = followsFor pin;
in
evalFlake sourceInfo flakeDir upLock rootNode f.level f.deep;
evalFetch =
sourceInfo: pin: subdir:
let
path = sourceInfo.outPath + subdir;
tackPinsPath = path + "/.tack/pins.toml";
hasTack = pathExists tackPinsPath;
upPins = if hasTack then fromTOML (readFile tackPinsPath) else { };
f = followsFor pin;
# a fetch drill-in is tack-only
tackOverrides = resolveFollows (
intersectAttrs (upPins.inputs or { }) (followsForSide "tack" f.level)
);
in
# a fetch pin is a source tree (path); hand back resolved inputs only when
# there are overrides to push into the upstream's .tack
if hasTack && tackOverrides != { } then
let
upstream = import (path + "/.tack");
in
# old resolvers return a plain attrset, not a callable functor
if upstream ? __functor then
(upstream { overrides = tackOverrides; }) // { outPath = path; }
else
trace "tack: ${path}: upstream .tack predates override support; overrides will not reach it" path
else
path;
loadPin =
name: pin:
let
pinType = pin.type or (if pin.flake or true then "flake" else "fetch");
subdir = if pin ? dir then "/" + pin.dir else "";
in
if pinType == "fixed" then
fetchFixed name lock.${name}
else
let
sourceInfo = fetchPin name;
in
if pinType == "flake" then evalTopFlake sourceInfo pin else evalFetch sourceInfo pin subdir;
declared = pins.inputs or { };
# undeclared lock entries are auto-dedup synthetics only when referenced as
# [all_follow] targets; stale locks from hand-edits are ignored (tack rm to clean)
autoTargets = listToAttrs (
map (target: {
name = target;
value = true;
}) (attrValues all_follow)
);
autoNames = filter (n: !(declared ? ${n}) && autoTargets ? ${n}) (attrNames lock);
autoPin =
name:
let
sourceInfo = fetchPin name;
in
if pathExists (sourceInfo.outPath + "/flake.nix") then evalTopFlake sourceInfo { } else sourceInfo;
self =
(mapAttrs loadPin declared)
// listToAttrs (
map (name: {
inherit name;
value = autoPin name;
}) autoNames
)
// overrides;
in
self // { __functor = _: call; };
in
call { }

32
.tack/pins.lock.json Normal file
View file

@ -0,0 +1,32 @@
{
"nix-systems": {
"type": "github",
"owner": "nix-systems",
"repo": "default-linux",
"rev": "31732fcf5e8fea42e59c2488ad31a0e651500f68",
"narHash": "sha256-12tWmuL2zgBgZkdoB6qXZsgJEH9LR3oUgpaQq2RbI80=",
"lastModified": 1689347949
},
"nixpkgs": {
"type": "tarball",
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-26.11pre1011620.cbb5cf358f50/nixexprs.tar.xz",
"narHash": "sha256-d86UGSjwACWRttincQzeiAEZYVPtP3kODhr9hYZD4e8=",
"lastModified": 1780787167
},
"tack": {
"type": "github",
"owner": "manic-systems",
"repo": "tack",
"rev": "0bc8dcb07dbd85d5d8d889c89accc9b228a6e4cc",
"narHash": "sha256-/GrnjZK71UdLFtugaXkShE8dWpvGxJyOHpj073v+pXU=",
"lastModified": 1780970897
},
"unf": {
"type": "git",
"url": "https://git.atagen.co/atagen/unf",
"ref": "HEAD",
"rev": "4f5ab30ee524f09126b09f42f249c0aba756459d",
"narHash": "sha256-ztzeJVOQDhQtE0z9DwtSWL+OyoWKZrAD4gzRRvUP9ug=",
"lastModified": 1779596435
}
}

38
.tack/pins.toml Normal file
View file

@ -0,0 +1,38 @@
# tack pins
# edit by hand or with tack add / rm / alias
# nix reads pins.lock.json
# this file drives tack update
# shorturl schemes
# scheme rest expands rest into {path}
[shorturls]
# gh = "github:{path}"
# all_follow maps input names to the top-level pin they follow
# opt out per-input with exclude_follow
[all_follow]
# nixpkgs = "nixpkgs"
# nixpkgs = ["nixpkgs-stable", "nixpkgs-unstable"]
# inputs.<name> fields
# url required, shorturl ok, ?rev=<sha> pins an exact commit
# type flake (default), fetch, or fixed
# flake legacy false means type = "fetch"
# follows { child = "pin" } override map
# exclude_follow all_follow names to skip
# dir subdir holding flake.nix
# submodules fetch git submodules
# unpack fixed-only tarball or file
[inputs]
[inputs.nixpkgs]
url = "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.xz"
[inputs.nix-systems]
url = "github:nix-systems/default-linux"
[inputs.unf]
url = "git+https://git.atagen.co/atagen/unf"
[inputs.tack]
url = "github:manic-systems/tack/next"