How twist.nix Generates and Manages flake.lock Files: A Complete Technical Guide

twist.nix does not write flake.lock files directly; instead it generates flake.nix and archive.lock artefacts, then invokes the Nix CLI to create or update the lock file while intelligently merging existing entries to preserve local packages.

The emacs-twist/twist.nix repository provides a deterministic, code-first approach to managing Emacs configurations through Nix flakes. Understanding how twist.nix handles flake.lock generation is essential for maintaining reproducible builds without manually editing lock file contents.

The Three-Stage Lock File Workflow

The twist.nix implementation splits flake.lock management into three distinct stages orchestrated through pkgs/emacs/lock/default.nix.

Stage 1: Generating Lock Artefacts

The generateLockFiles function (defined in pkgs/emacs/lock/default.nix) builds a minimal Nix derivation that stages the required input files. This process utilizes write-lock-1.nix to prepare flake.nix and archive.lock for placement in the user-specified lock directory. The actual file system operations are handled by the Bash script pkgs/emacs/lock/write-lock-2.bash, which copies these generated files into the target location before any locking occurs.

Stage 2: Invoking the Nix CLI

After the artefacts are in place, the system executes a post-command (configured in default.nix at lines 107‑108) that defaults to nix flake lock. The runNix function inside write-lock-2.bash executes this command, which creates the actual flake.lock file recording the exact revisions of all inputs declared in the generated flake.nix.

Stage 3: Merging with Existing Lock Files

When a lock directory already contains a flake.lock, twist.nix does not overwrite it blindly. The pkgs/emacs/lock/flake-lock.nix module reads the existing lock using prev = lib.importJSON flakeLockFile; (lines 10‑34). If the lock is version 7, it merges the newly generated nodes with the old ones while preserving any inputs that are not declared in the freshly generated flake.nix—specifically local packages that exist outside the twist.nix generation scope. Unsupported lock versions trigger an explicit error.

Core Implementation Files

High-Level Interface (pkgs/emacs/default.nix)

The primary entry points for lock directory generation reside here:

  • generateLockDir — A convenience attribute built from generateLockFiles with flakeNix = true and archiveLock = true, returning a writerScript that users can invoke directly.
  • makeApps { lockDirName } — Exposes two executable apps:
    • lock — Writes flake.nix and archive.lock, then runs nix flake lock.
    • update — Writes only archive.lock (intended for runs that later call nix flake update).

Both apps are constructed by the helper asAppWritingToRelativeDir, which wraps the writer script into a runnable Nix application.

Lock Writer Logic (pkgs/emacs/lock/default.nix)

This file implements the generateLockFiles function that coordinates the lock generation pipeline. It wires together write-lock-1.nix for file staging and write-lock-2.bash for execution, while configuring the postCommand that triggers the actual Nix locking mechanism.

The Bash Runner (pkgs/emacs/lock/write-lock-2.bash)

The runNix function in this script handles the execution of nix flake lock or nix flake update. It respects command-line flags such as --force and --no-update-flake-lock, providing fine-grained control over when the lock file gets refreshed.

The Merge Module (pkgs/emacs/lock/flake-lock.nix)

This module contains the logic for preserving existing lock state:

prev = lib.importJSON flakeLockFile;
...
if prev.version == 7
then version7
else throw "Unsupported flake.lock version ${prev.version}";

The version7 implementation merges prev.nodes with newNodeAttrs (the freshly generated package set), ensuring that nodes corresponding to local packages survive the merge even when those packages are intentionally omitted from the generated flake.nix.

Practical Usage Examples

Creating a New Lock Directory

Define a flake output that uses makeApps to expose the generation command:


# flake.nix

{
  description = "My twist-based Emacs configuration";

  outputs = { self, nixpkgs, ... }:
    let
      twist = import (builtins.fetchGit {
        url = "https://github.com/emacs-twist/twist.nix";
        rev = "master";
      }) { inherit nixpkgs; };
    in {
      apps.x86_64-linux.generate-twist-lock = twist.emacs.makeApps {
        lockDirName = "twist-lock";
      }.lock;
    };
}

Execute the generator:

nix run .#generate-twist-lock

This writes flake.nix and archive.lock into ./twist-lock, automatically runs nix flake lock there, and produces ./twist-lock/flake.lock.

Updating Existing Locks

Use the update app to regenerate only the archive.lock, then manually refresh the flake inputs:


# Regenerate archive.lock only

nix run .#generate-twist-lock.update

# Refresh flake.lock with newest inputs

nix flake update ./twist-lock

The lock app performs both steps in sequence (generating files and locking), while the update app prepares the directory for a separate nix flake update call.

How the Merge Strategy Preserves Local Packages

According to the emacs-twist/twist.nix source code, the merge logic in flake-lock.nix specifically addresses the scenario where a user has local packages that are not part of the twist.nix package set. These packages appear as inputs in the existing flake.lock but are absent from the generated flake.nix.

The merging algorithm:

  1. Loads the existing lock into prev
  2. Generates new node attributes (newNodeAttrs) for the current twist.nix package set
  3. Performs a version‑aware merge (only version 7 is supported)
  4. Retains nodes from prev that do not conflict with newNodeAttrs, ensuring local dependencies remain locked at their existing revisions

This prevents spurious changes to the lock file when the user has custom local inputs alongside twist.nix-managed Emacs packages.

Summary

  • twist.nix generates flake.nix and archive.lock artefacts rather than writing flake.lock directly.
  • The lock generation pipeline is implemented across pkgs/emacs/lock/default.nix, write-lock-1.nix, and write-lock-2.bash.
  • The Nix CLI (nix flake lock) creates the actual lock file after twist.nix stages the necessary inputs.
  • flake-lock.nix merges existing version 7 locks to preserve local package entries that are not declared in the generated configuration.
  • makeApps provides both lock and update apps for different maintenance workflows.

Frequently Asked Questions

Does twist.nix directly modify flake.lock files?

No. According to the emacs-twist/twist.nix source code, the system generates flake.nix and archive.lock files and then invokes the Nix CLI (via the postCommand mechanism) to create or update flake.lock. The twist.nix code never directly serializes or edits the JSON structure of the lock file itself.

How does twist.nix handle existing flake.lock entries?

When a flake.lock already exists in the target directory, the pkgs/emacs/lock/flake-lock.nix module imports the existing JSON and merges its nodes with the newly generated set. This preserves entries for local packages that are intentionally omitted from the twist.nix-generated flake.nix, ensuring those dependencies remain locked at their existing revisions while updating only the twist-managed inputs.

What is the difference between the lock and update apps?

The lock app (accessed via .lock on the result of makeApps) writes both flake.nix and archive.lock to the target directory and then immediately executes nix flake lock to generate a fresh lock file. The update app writes only archive.lock, preparing the directory for a subsequent manual or automated nix flake update call that refreshes input sources without regenerating the entire flake structure.

Which flake.lock versions does twist.nix support?

Only version 7 is supported. The flake-lock.nix module explicitly checks prev.version and throws an error if the existing lock file uses any other version format, ensuring predictable merge behavior and preventing corruption of lock files with incompatible schemas.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →