lazysubmodules

module
v0.1.0-rc.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 17, 2026 License: GPL-3.0

README

LazySubmodules

CI Coverage Release Go version Go Reference Linted by golangci-lint Platform Made in Poland

LazySubmodules manages Git submodules that track branches, tags, tag patterns or fixed commits. It has a scriptable command line interface and an interactive terminal user interface (TUI). The binary is called lazysubmodules, and lsm is an optional short name.

  • Tracking modes: a submodule can follow a branch, a tag, the highest version tag matching a glob such as v2.*, or a fixed commit.
  • Lock file: .lsm.lock records what each submodule was resolved to, so tags that were moved on the remote are detected.
  • Predictable network use: only fetch, update --fetch and add touch the network; everything else works on local refs.
  • Native Git underneath: configuration lives in .gitmodules, and all work is done by the git command line tool. Plain git submodule update --remote keeps working for branch-tracking submodules.
  • Scripting: a stable, machine-readable status format, verify with optional signature checks, and distinct exit codes.

Contents

Quick demo

scripts/demo.sh builds a firmware superproject with fourteen submodules, covering every tracking mode and every state, and walks through LazySubmodules on it in twelve short chapters. It runs offline and touches nothing outside its own directory: Git runs without your configuration, may use only the file transport, and every submodule URL is rewritten to a local mirror.

The demo needs Bash, Git 2.39 or later and a lazysubmodules binary:

git clone https://github.com/FPGArtktic/lazysubmodules.git
cd lazysubmodules
scripts/build-in-container.sh build   # binary in bin/lazysubmodules
scripts/demo.sh                       # press Enter between the chapters

A host Go toolchain can build the binary instead of the container, and scripts/demo.sh --binary PATH runs another binary:

go build -o bin/lazysubmodules ./cmd/lazysubmodules

The demo shows every state in status, the porcelain format and a script that reads it, set on an unmanaged submodule, dry runs with and without pre-release tags, a refused update and how to fix it, update --commit with the generated commit message, a tag moved upstream that verify catches, a clone through the mirror, a signature check and foreach. Each lazysubmodules command is followed by its exit status, and the demo fails when a command exits with another status than the story expects.

To try the terminal interface on the demo superproject, keep it and stop before the story changes anything. env.sh sets HOME and the Git environment of the demo, so source it in a separate shell:

scripts/demo.sh --keep /tmp/lsm-demo --setup-only
. /tmp/lsm-demo/env.sh
cd /tmp/lsm-demo/firmware
lazysubmodules tui

examples/ contains the complete transcript of the demo and the .gitmodules and .lsm.lock it ends with; its README describes the options and every submodule of the demo.

The animated GIFs in this README are recorded from the same demo with VHS. scripts/record-demos.sh regenerates them in a container (Podman or Docker); see docs/demo/README.md.

Why LazySubmodules

Native Git can track only branches: set submodule.<name>.branch and run git submodule update --remote. There is no native way to say "this submodule follows tag v2.3.1" or "this submodule follows the newest v6.6.* release". Many projects pin dependencies to releases, so these updates are done by hand.

LazySubmodules fills this gap without breaking native Git behaviour. The extra configuration is stored in namespaced keys that Git ignores, the superproject still records ordinary gitlinks, and a clone works with plain git submodule update --init for anyone who does not use LazySubmodules.

It is not a replacement for repo, west, git subtree or monorepo tooling, and it does not manage credentials: Git uses its configured credential helpers.

Quick start

# Let the existing submodule "kernel" follow the newest stable v6.6.x tag.
lazysubmodules set kernel --tag-pattern 'v6.6.*'

# Fetch, resolve, check out, update .lsm.lock and commit the result.
lazysubmodules update kernel --fetch --commit

# Add a new submodule that follows the branch "main" (changes are staged).
lazysubmodules add https://git.example.org/u-boot.git u-boot --branch main

# Inspect and verify.
lazysubmodules status
lazysubmodules verify

# Or work interactively.
lazysubmodules tui

Quote glob patterns such as 'v6.6.*', so that the shell does not expand them.

Tracking modes

Mode Option Example ref Semantics
branch --branch B main Floating. update moves the submodule to the tip of the remote branch
tag --tag T v2.3.1 Pinned. update resolves the tag to its commit
tag-pattern --tag-pattern P v2.* update selects the highest version tag matching the glob
commit --commit SHA a1b2c3d… Pinned to a SHA. update only verifies the commit and checks it out

Resolution rules
  • Local only. Resolution uses refs that already exist locally, so run lazysubmodules fetch or update --fetch first. Refs are looked up in the submodule's working tree or, when it is not checked out, in its Git directory under .git/modules/.

  • Branch: resolves refs/remotes/origin/<branch> in the submodule. The remote is always origin.

  • Tag: resolves refs/tags/<tag>. Annotated tags are dereferenced to their commit, as with git rev-parse "<tag>^{commit}".

  • Tag pattern: the pattern is a glob as accepted by git tag --list. Candidates are sorted by version with

    git -c versionsort.suffix=- tag --list <pattern> --sort=-v:refname
    

    so v1.10.0 sorts above v1.9.0 and v1.0.0-rc.1 sorts below v1.0.0. The highest candidate wins.

  • Pre-release tags are excluded from tag-pattern unless update or add is given --include-prerelease. A tag counts as a pre-release when a - follows its first digit: v1.0.0-rc.1 and v6.6-rc3 are pre-releases, release-2.1 is not.

  • Never downgrade: in tag-pattern mode, the tag recorded in .lsm.lock stays a candidate as long as it still exists locally and matches the pattern, even when it is a pre-release. For example, after an update with --include-prerelease to v2.1.0-rc.1, a plain update keeps v2.1.0-rc.1 instead of going back to v2.0.3. It moves on to v2.1.0 once that tag exists.

  • Commit: the configured SHA must exist locally.

Data format

.gitmodules

Git ignores unknown keys, so the tracking configuration lives in .gitmodules under the lsm- prefix, which avoids collisions with future Git keys:

[submodule "kernel"]
	path = kernel
	url = https://git.example.org/linux.git
	lsm-mode = tag-pattern
	lsm-ref = v6.6.*

[submodule "u-boot"]
	path = u-boot
	url = https://git.example.org/u-boot.git
	branch = main
	lsm-mode = branch
	lsm-ref = main
Key Meaning
lsm-mode branch, tag, tag-pattern or commit
lsm-ref Branch name, tag name, glob pattern or full commit SHA
branch Native Git key. Written for lsm-mode = branch, removed for all other modes
  • The native branch key keeps git submodule update --remote working without LazySubmodules.
  • The file is read and written only with git config -f .gitmodules.
  • Submodules without lsm-mode are shown as unmanaged and never modified, until you opt in with lazysubmodules set.
  • An unknown lsm-mode value is reported as an error. An invalid lsm-ref makes the submodule show up as missing-ref.

examples/.gitmodules is a commented example with every tracking mode.

.lsm.lock

The lock file records the resolved ref and commit of each managed submodule. It uses the same git-config format and is read and written with git config -f .lsm.lock:

[submodule "kernel"]
	mode = tag-pattern
	ref = v6.6.8
	commit = a1b2c3d4e5f6a7b8c9d0a1b2c3d4e5f6a7b8c9d0

[submodule "u-boot"]
	mode = branch
	ref = main
	commit = d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3
Key Meaning
mode Tracking mode used for the resolution
ref Resolved ref: the tag for tag and tag-pattern, the branch for branch, the full SHA for commit
commit Full commit SHA: 40 hex digits, or 64 in SHA-256 repositories
  • update and add write the lock file and stage it together with the gitlink change, even when an ignore rule such as *.lock matches it. Commit it to the superproject.
  • .gitmodules and .lsm.lock must be regular files. A symbolic link in their place is refused, so that a cloned repository cannot redirect a write to a file outside of it.
  • Comparing the lock with the local tags reveals tags that were moved on the remote (force-pushed): status shows drift and verify fails.

Installation

LazySubmodules runs on Linux (amd64 and arm64) and needs Git 2.39 or later at run time.

The binary is statically linked and contains the Go standard library and a few Go modules under the MIT and BSD-3-Clause licenses. Their copyright notices and license texts come with every release archive and package, as described below.

Release archives

Each GitHub release has a lazysubmodules_<version>_linux_<arch>.tar.gz archive for amd64 and arm64. It contains the binary, LICENSE, README.md, and the license notices of the third-party code in the binary: THIRD_PARTY_NOTICES lists each module with its version and license, and licenses/ holds the license texts.

VERSION=1.2.3   # release version without the leading "v"
ARCH=amd64      # or arm64
BASE="https://github.com/FPGArtktic/lazysubmodules/releases/download/v${VERSION}"
curl -fLO "${BASE}/lazysubmodules_${VERSION}_linux_${ARCH}.tar.gz"
tar -xzf "lazysubmodules_${VERSION}_linux_${ARCH}.tar.gz"
sudo install -m 0755 lazysubmodules /usr/local/bin/lazysubmodules
sudo ln -s lazysubmodules /usr/local/bin/lsm   # optional short name

Keep LICENSE, THIRD_PARTY_NOTICES and licenses/ with the binary when you pass it on. See Verifying releases to check the download first.

Debian and RPM packages

Each release also has .deb and .rpm packages for amd64 and arm64, named lazysubmodules_<version>_linux_<arch>.deb and lazysubmodules_<version>_linux_<arch>.rpm. They depend on git, which the package manager installs when it is missing, and install:

  • /usr/bin/lazysubmodules and the short name /usr/bin/lsm (a symlink);
  • README.md under /usr/share/doc/lazysubmodules/;
  • .deb: LICENSE in the same directory, and the Debian copyright file /usr/share/doc/lazysubmodules/copyright with the notices and license texts of the third-party code;
  • .rpm: LICENSE, THIRD_PARTY_NOTICES and licenses/ under /usr/share/licenses/lazysubmodules/ (rpm -qL lazysubmodules lists the license files).
VERSION=1.2.3   # release version without the leading "v"
ARCH=amd64      # or arm64
BASE="https://github.com/FPGArtktic/lazysubmodules/releases/download/v${VERSION}"

# Debian, Ubuntu
curl -fLO "${BASE}/lazysubmodules_${VERSION}_linux_${ARCH}.deb"
sudo apt install "./lazysubmodules_${VERSION}_linux_${ARCH}.deb"

# Fedora and other RPM-based systems
curl -fLO "${BASE}/lazysubmodules_${VERSION}_linux_${ARCH}.rpm"
sudo dnf install "./lazysubmodules_${VERSION}_linux_${ARCH}.rpm"

The packages are not signed themselves; check them against the signed checksums.txt first (see Verifying releases). CI installs the amd64 packages built from every change in Debian and Fedora, runs them and checks the installed license notices.

Arch Linux (AUR)

An AUR package, lazysubmodules-git, is planned but not published yet. Until this section links to it, a package of that name in the AUR does not come from this project.

Its recipe is in this repository, packaging/aur/lazysubmodules-git/PKGBUILD, so you can already build and install the package locally with makepkg (from the base-devel group):

git clone https://github.com/FPGArtktic/lazysubmodules.git
cd lazysubmodules/packaging/aur/lazysubmodules-git
makepkg -si
  • Source: the recipe clones the default branch of the GitHub repository; it does not build your local checkout. It builds with the vendored Go modules, downloads none, and runs the test suite.
  • Dependencies: -s installs the build dependencies (go, go-licenses, and openssh for the tests) with pacman, and -i installs the package.
  • Contents: /usr/bin/lazysubmodules, the short name /usr/bin/lsm, the README under /usr/share/doc/lazysubmodules-git/, and LICENSE, THIRD_PARTY_NOTICES and licenses/ under /usr/share/licenses/lazysubmodules-git/. The recipe builds no lazysubmodules-git-debug package, whatever makepkg.conf says: it builds with -trimpath, so the binary contains no paths of the build directory, and without those paths a debug package could not carry the sources.
  • Version: derived from the Git history, for example 0.1.0.r3.g1234abc for the third commit after v0.1.0, or r54.3cf609f before the first release. lazysubmodules version prints it, with the commit and the commit date.

Meanwhile, you can also use a release archive or Go.

Go

With Go 1.27.1 or later:

go install github.com/FPGArtktic/lazysubmodules/cmd/lazysubmodules@latest

The binary is installed to $(go env GOPATH)/bin, or to GOBIN when it is set. Such a binary reports the module version with its leading v, but neither the commit nor the commit date: version prints commit: none and date: unknown (see version). It also comes without the third-party license notices; go version -m lists the modules it contains.

Command reference

lazysubmodules add <url> <path> (--branch B | --tag T | --tag-pattern P | --commit SHA)
lazysubmodules set <name> (--branch B | --tag T | --tag-pattern P | --commit SHA)
lazysubmodules update [<name>...] [--fetch] [--dry-run] [--commit] [--include-prerelease]
lazysubmodules status [<name>...] [--porcelain=v1]
lazysubmodules fetch [<name>...]
lazysubmodules verify [<name>...] [--signatures]
lazysubmodules foreach -- <command> [args...]
lazysubmodules tui
lazysubmodules version

General rules:

  • Help: lazysubmodules help [<command>], -h and --help print usage (exit code 0). Without a command, the usage goes to standard error (exit code 2).
  • Flags: flags may appear before or after the submodule names, and -- ends the flags. add and set take exactly one of --branch, --tag, --tag-pattern and --commit.
  • Usage errors: an unknown command or flag, a flag value that is not accepted, a missing or extra argument, and an invalid ref, pattern, path or URL exit with code 2.
  • Names: <name> is the submodule name from .gitmodules. Without names, a command works on all managed submodules (status lists all submodules, including unmanaged ones). Naming an unknown submodule is an error (exit code 1). Naming an unmanaged submodule is refused (exit code 3) by every command except status and set.
  • Output: submodules are processed and reported in .gitmodules order. Names and refs with unusual characters are shown quoted, so that they cannot send control sequences to the terminal.
  • Progress: the output of git itself, such as clone and fetch progress, goes to standard error.
add
lazysubmodules add <url> <path> (--branch B | --tag T | --tag-pattern P | --commit SHA) [--include-prerelease]

Adds a new managed submodule:

  1. Clones it with git submodule add; branch mode passes -b <branch>. The submodule name is its path, as with plain Git.
  2. Writes lsm-mode and lsm-ref to .gitmodules.
  3. Resolves the ref, checks out the commit and writes .lsm.lock.
  4. Stages .gitmodules, .lsm.lock and the new gitlink. It does not commit.

It prints one line, for example u-boot: added at main (8e9fc7a).

  • --include-prerelease: lets a --tag-pattern select a pre-release tag, as for update.
  • Path: <path> must be a relative path inside the superproject. A path outside it is a usage error (exit code 2). A path that already exists, belongs to another submodule, lies inside a submodule, or leads through a file or a symbolic link is refused (exit code 3).
  • Rollback: if a step after the clone fails, the new submodule is removed again and the original error is reported.
set
lazysubmodules set <name> (--branch B | --tag T | --tag-pattern P | --commit SHA)
  • Effect: changes the tracking configuration of an existing submodule in .gitmodules, and nothing else. It does not touch the submodule, the lock file or the index; run update afterwards. update then stages .gitmodules together with the lock file and the gitlink.
  • Unmanaged submodules: set is how you put an unmanaged submodule under LazySubmodules' control.
  • Validation: branch and tag names are checked with git check-ref-format.
  • Commit mode: an abbreviated SHA is expanded when the commit is available locally; otherwise a full SHA is required.
  • Output: one line, for example kernel: tracks tag-pattern v6.*.
update
lazysubmodules update [<name>...] [--fetch] [--dry-run] [--commit] [--include-prerelease]

For each selected managed submodule:

  1. Resolve the target commit according to the resolution rules.
  2. Check out the commit in the submodule. A detached HEAD is expected.
  3. Update .lsm.lock.
  4. Default: stage the gitlink, .gitmodules and .lsm.lock with git add.
  5. --commit: additionally create a commit (see below).
  6. --dry-run: print the planned changes and modify nothing.

Options:

Option Effect
--fetch Fetch from origin before resolving, and clone uninitialized submodules when needed
--dry-run Print the plan only. Never fetches, initializes or clones, even with --fetch
--commit Create one commit with the result
--include-prerelease Let tag-pattern select pre-release tags
  • Uninitialized submodules are initialized. When the submodule's Git directory still exists (for example after git submodule deinit), this happens offline. When a clone is needed, update refuses unless --fetch is given.
  • Dry run: --dry-run resolves against the refs that exist locally. A submodule that would need a clone is listed with the target unknown until fetched.
  • Native key: update also rewrites the native branch key in .gitmodules to match the tracking mode.

update refuses with exit code 3 when:

  • a submodule working tree has uncommitted changes (untracked files do not count, nor does a nested submodule that is only checked out at another commit);
  • the resolved ref does not exist locally (fetch first, or use --fetch);
  • a submodule is not initialized, its Git directory is missing and --fetch was not given;
  • --commit is given and the commit would include unrelated changes, or the index has unresolved merge conflicts (see update --commit);
  • an unmanaged submodule is named explicitly;
  • a submodule cannot be updated safely: its path leads through a symbolic link, the index records no submodule at its path, or it would be initialized but has no usable URL or something other than a Git repository in the place of its Git directory.

All checks run for every selected submodule before anything is modified, and every refusal is reported, one per line. If one submodule is refused, nothing changes.

update prints one line per submodule. The left side is what the superproject records (in the index, or in HEAD with --commit): the locked ref and commit, <commit> (unlocked) without a lock entry, or none without a gitlink. The right side is the target:

kernel: v6.6.9 (8106f61) -> v6.6.10 (08dcd0d)
u-boot: up to date
theme: v1.0.0 (05f49f3), initialized
sdk: tag-pattern v3.0.0-rc.2 (6308203) -> tag v2.9.0 (68a8743)
committed 613148ee5508e55f3702a142d5764a51d0d7bac4
  • Notes:

    • initialized or cloned for a submodule that was not checked out;
    • HEAD was <commit> when the submodule was checked out at a commit other than the recorded one;
    • recorded again when the superproject records the same commit again, for example after set changed the ref but not the commit;
    • restored .lsm.lock, restored .gitmodules or restored .gitmodules and .lsm.lock when the superproject records the target already, but the working tree copy of the file does not: the lock entry of the submodule differs or is missing, or its native branch key does not follow the tracking mode. The update rewrites that entry or key to what the superproject records;
    • discarded the staged change (only with --commit) when HEAD records the target already, but the index holds a different gitlink, lock entry or tracking keys for the submodule. The update stages what HEAD records in their place, so the staged change is lost (see update --commit).

    The mode is shown on both sides when it changes.

  • Dry run: each line starts with would update, and the notes read initialize, clone, HEAD is <commit>, record again, restore <files> and discard the staged change.

  • Commit: with --commit, the last line is committed <commit> or nothing to commit. A dry run prints would commit "<subject>", would commit the result when a target is not known before a clone, or nothing to commit.

  • Nothing selected: without managed submodules, update prints no managed submodules.

This output is not a stable interface; scripts should use status --porcelain=v1.

update --commit
  • Unrelated changes: the commit may contain only the update. update refuses (exit code 3) when other paths than .gitmodules, .lsm.lock and the selected submodules are staged, or when .gitmodules or .lsm.lock differ from HEAD outside the sections of the selected submodules, staged or not. It also refuses while the index has unresolved merge conflicts.
  • Commit: the commit is created with git commit -s, so the Signed-off-by line comes from user.name and user.email. Commit hooks run as configured.
  • What the commit records: one commit per invocation. The commit and its message cover only the submodules whose gitlink, lock entry or tracking keys (lsm-mode, lsm-ref, the native branch key) change compared with HEAD.
  • Left out: a submodule that is only initialized, cloned, or checked out at the commit that HEAD records is still updated, but it is neither in the commit nor in the message. The same applies when the update only rewrites its entries in .gitmodules or .lsm.lock to what HEAD records (restored …).
  • Nothing to commit: without such a change, no commit is made and update prints nothing to commit.
  • Staged changes of a selected submodule: the update stages the target of each selected submodule: its gitlink, its lock entry and its tracking keys, replacing whatever was staged for them. When HEAD records the target already, the submodule is left out of the commit, so a different staged value is discarded without being committed; the line then says discarded the staged change. Run update --dry-run --commit first to see it.

For example, when kernel moves to a new tag and theme is only initialized, update --commit theme kernel creates a commit for kernel alone. For one submodule the message looks like this:

manifest: update kernel to v6.6.10

Tracking mode: tag-pattern v6.6.*
Old: 8106f614767a (v6.6.9)
New: 08dcd0dc8f98 (v6.6.10)

Signed-off-by: Your Name <you@example.org>

For several submodules the subject is manifest: update N submodules, and the body lists each submodule:

manifest: update 2 submodules

Submodule "kernel":
  Tracking mode: tag-pattern v6.6.*
  Old: a1b2c3d4e5f6 (v6.6.8)
  New: e4f5a6b7c8d9 (v6.6.9)

Submodule "u-boot":
  Tracking mode: branch main
  Old: d4e5f6a7b8c9 (main)
  New: 3f2e1d0c9b8a (main)

Signed-off-by: Your Name <you@example.org>

The quoted Submodule "<name>": heading keeps Git from reading the last block as a trailer block (like Signed-off-by:), so git commit -s always separates the sign-off with a blank line.

Details:

  • SHAs: commits are shown with 12 characters.

  • Tracking mode: the configuration after the update. A previous configuration is not listed.

  • Old commit: the commit that HEAD of the superproject recorded, with the ref of the previous lock entry when that entry records the same commit, and without a ref otherwise. Without a previous lock entry, the line reads Old: <sha> (unlocked). Old: none means that the superproject recorded no gitlink for the submodule in HEAD, for example for a submodule that was added but not committed yet.

  • Commit mode: the subject shows the 12-character SHA, and the parenthesized ref is omitted.

  • Long subjects: a subject longer than 75 characters, or one that would break another subject rule (trailing punctuation or white space, the word "WIP" in any case), is shortened to manifest: update <name>, or to manifest: update 1 submodule.

  • Unusual characters: a name or ref that is not valid UTF-8, or that contains quotes, backslashes or characters that are not printable (such as tabs or line separators), is quoted, as in the other output. The heading always quotes the name, and writes a space that is followed by another space as \x20.

  • Line length: a body line longer than 75 columns continues on the next line, indented by two more spaces: the ref, or in a heading the quoted name, moves there. A ref or name that is still too long is split across several such lines, never after a space:

    Submodule
      "a-submodule-whose-name-is-far-too-long-to-fit-on-one-line-with-the-headi
      ng":
    

    With the subject rules above, the message passes the .gitlint rules whatever the names and refs are.

Network policy
Command Network
fetch Yes
update --fetch Yes, before resolution
add Yes (clone)
All other commands No
  • Dry run: update --dry-run --fetch does not use the network either.
  • TUI: in the TUI, f (fetch) is the only network action; its u and U never fetch.
  • Mirrors configured with url.<base>.insteadOf work transparently, because all network access goes through git.
  • Credentials: they come from Git's credential helpers. The TUI cannot answer interactive prompts (see Git without a terminal).
  • foreach: the command itself does not use the network, but the commands you run with it may.
fetch
lazysubmodules fetch [<name>...]

For each selected managed submodule, fetch first clones and initializes the submodule if it is not initialized. It then runs git fetch --tags --force --prune origin.

--force lets local tags follow tags that were moved on the remote, which is how status and verify detect moved tags. A local tag with the same name as a remote tag is therefore replaced.

status
lazysubmodules status [<name>...] [--porcelain=v1]

Without --porcelain, status prints a table with the columns NAME, PATH, MODE, REF, LOCK, HEAD and STATE:

NAME        PATH               MODE         REF      LOCK                   HEAD     STATE
kernel      kernel             tag-pattern  v6.6.*   8106f61 (v6.6.9)       8106f61  behind
legacy      vendor/legacy      -            -        -                      2f54e11  unmanaged
theme       docs/theme         tag          v1.0.0   05f49f3                -        uninitialized
sdk         sdk                tag-pattern  v*       6308203 (v3.0.0-rc.2)  6308203  ok
  • Cells: SHAs are abbreviated to 7 characters. LOCK adds the locked ref in parentheses when it differs from REF, as for tag patterns. An empty cell shows -.
  • Colors: the header and the states are colored only when standard output is a terminal, NO_COLOR is unset or empty, and TERM is not dumb.
  • Offline: status uses local refs only. For branch mode, behind compares against the remote-tracking branch as of the last fetch.
State Meaning
ok HEAD equals the lock commit; no newer ref is available locally
behind A newer commit or tag is available in local refs
drift HEAD differs from the lock commit, or the locked tag now resolves to a different commit
dirty The submodule working tree has uncommitted changes
uninitialized The submodule is not checked out
missing-ref The configured ref is not found locally
unmanaged The submodule has no lsm-mode key

The first matching state wins, in this order:

  1. unmanaged: no lsm-mode key.
  2. uninitialized: the submodule is not checked out.
  3. dirty: the working tree has uncommitted changes.
  4. missing-ref: the configured ref is invalid or does not resolve locally.
  5. drift: a lock entry exists, and either HEAD differs from the locked commit, or, in tag and tag-pattern mode, the locked tag now points to a different commit or no longer exists.
  6. behind: there is no lock entry yet, or update would select a different ref or commit than the lock records, or HEAD differs from that target.
  7. ok: otherwise.
status --porcelain=v1

The porcelain v1 format is a stable contract for scripts. Incompatible changes will only come as a new v2 format.

  • Option: --porcelain alone means --porcelain=v1. The value must be attached with =: in status --porcelain v1, v1 is a submodule name. Any other value, such as --porcelain=v2, is a usage error (exit code 2).
  • Header: the first line is # lsm-porcelain v1.
  • Records: then one line per submodule, in .gitmodules order, with eight fields separated by a single TAB. Lines end with LF, there is no trailing TAB, and there are no colors.
  • Empty fields: a field without a value is empty: fields 3 to 6 of an unmanaged submodule, fields 5 and 6 without a lock entry, and field 7 when the submodule is not checked out.
# Field Example
1 name kernel
2 path kernel
3 mode tag-pattern
4 ref (configured) v6.6.*
5 resolved ref (lock) v6.6.8
6 lock commit full SHA
7 HEAD commit full SHA
8 state ok, behind, drift, dirty, uninitialized, missing-ref, unmanaged

Quoting. A field that contains a TAB, LF, CR, double quote ("), backslash (\), any other control character (U+0000 to U+001F and U+007F to U+009F), the line or paragraph separator (U+2028, U+2029), or a byte that is not valid UTF-8 is written in double quotes with C-style escapes, the way Git quotes unusual path names (core.quotePath):

  • \a, \b, \t, \n, \v, \f, \r, \" and \\;
  • a three-digit octal escape for each byte of any other character to escape, for example \033 for ESC, \342\200\250 for U+2028 and \377 for an invalid byte.

All other characters, including spaces and non-ASCII letters, are written unchanged, also inside quotes, and a field without a character to escape is written as it is. A field that starts with " is therefore always quoted, and the output is always valid UTF-8. For example, a submodule named we"ird followed by U+2028 and a TAB is listed as "we\"ird\342\200\250\t".

Example (fields separated by TABs):

# lsm-porcelain v1
kernel	kernel	tag-pattern	v6.6.*	v6.6.8	a1b2c3d4e5f6a7b8c9d0a1b2c3d4e5f6a7b8c9d0	a1b2c3d4e5f6a7b8c9d0a1b2c3d4e5f6a7b8c9d0	ok
u-boot	u-boot	branch	main	main	d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3	d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3	behind
fpga-ip	ip/fpga	tag-pattern	v2.*	v2.1.0	0718ab2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a	9c8b7a6f5e4d3c2b1a0f9e8d7c6b5a4f3e2d1c0b	drift
theme	docs/theme	tag	v1.4.0	v1.4.0	5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b		uninitialized
legacy	vendor/legacy					2f54e11fe5a9c2cf222f3d113338e7e53ddc3620	unmanaged

Parsing. A script that reads the format should:

  1. Check that the first line is exactly # lsm-porcelain v1.
  2. Split the rest into lines at LF. No field contains a raw LF, CR or other character at which common functions split lines.
  3. Split each line at every TAB into exactly eight fields, keeping empty fields. Do not use a splitter that merges adjacent TABs: Bash read with IFS=$'\t' treats TAB as white space and drops empty fields. awk -F '\t', cut -f and the split function of most languages keep them.
  4. Decode a field that starts with ": remove the quotes and resolve the escapes, which gives bytes that are not necessarily valid UTF-8. Go's strconv.Unquote does this, and so does codecs.escape_decode(field[1:-1].encode())[0] in Python. Use other fields as they are.
  5. Expect full SHAs of 40 or 64 hex digits in fields 6 and 7.

To list every submodule that is neither ok nor unmanaged:

lazysubmodules status --porcelain=v1 |
	awk -F '\t' '
		NR == 1 { if ($0 != "# lsm-porcelain v1") exit 1; next }
		$8 != "ok" && $8 != "unmanaged" { print $1 ": " $8 }
	'
verify
lazysubmodules verify [<name>...] [--signatures]

verify compares the lock commit, the gitlink in the superproject's HEAD commit and the submodule HEAD. For tag and tag-pattern it also checks that the locked tag still resolves to the locked commit. It works offline and exits with code 4 when any check fails.

Check Passes when
lock-entry .lsm.lock has an entry for the submodule
lock-config The lock entry matches .gitmodules: same mode; for branch and tag the same ref; for tag-pattern the locked tag exists and matches the pattern; for commit the locked commit is the configured SHA
lock-commit The locked commit is a well-formed full SHA for the repository
gitlink The gitlink in the superproject's HEAD commit equals the locked commit
initialized The submodule is checked out
head The submodule HEAD equals the locked commit
tag tag and tag-pattern only: the locked tag still resolves to the locked commit
signature Only with --signatures: see below

It prints a summary line per submodule, followed by each failed check, and names the failed submodules on standard error:

kernel: ok (7 checks)
theme: failed (1 of 5 checks)
  initialized: submodule is not checked out
broken: failed (1 of 7 checks)
  lock-config: lock records tag v1.0.0, configuration has v9.9.9
lazysubmodules: verification failed: theme, broken
  • Committed state: the gitlink check reads the committed state of the superproject. An update that is staged but not yet committed therefore fails verify.
  • --signatures: runs git verify-tag on the locked tag in tag and tag-pattern mode, and git verify-commit on the locked commit in branch and commit mode. GPG and SSH signatures work as configured in Git (for example gpg.format and gpg.ssh.allowedSignersFile).
foreach
lazysubmodules foreach -- <command> [args...]
  • What runs: <command> runs in every managed, initialized submodule, in .gitmodules order, with the submodule as its working directory. Uninitialized submodules are skipped with a note on standard error.
  • No shell: the command is executed directly. Use sh -c when you need one.
  • --: may be left out when the command does not start with -.
  • Failure: foreach stops at the first command that fails and exits with code 1, naming the submodule.

The command gets these environment variables in addition to the inherited environment:

Variable Value
name Submodule name
sm_path Submodule path as recorded in .gitmodules
displaypath Submodule path for display, as in git submodule foreach
sha1 Commit checked out in the submodule
toplevel Absolute path of the superproject
LSM_MODE Tracking mode (lsm-mode)
LSM_REF Configured ref (lsm-ref)
lazysubmodules foreach -- git describe --tags
lazysubmodules foreach -- sh -c 'echo "$name: $LSM_MODE $LSM_REF at $sha1"'
tui

tui starts the terminal user interface. It needs a terminal that can move the cursor. Otherwise it exits with code 2 and one of these messages:

Situation Message
Standard input or output is not a terminal lazysubmodules: tui requires a terminal
TERM is not set lazysubmodules: tui requires a terminal (TERM is not set)
TERM is dumb lazysubmodules: tui requires a terminal with cursor movement (TERM is dumb)

Git runs without access to the terminal while the TUI is shown; see Git without a terminal.

version

version (or --version) prints the version, the source commit and the commit date, the date of that commit in UTC. It is not the build date, so a rebuild of the same commit prints the same:

lazysubmodules 1.2.3
commit: <commit SHA>
date: <commit date, such as 2026-09-17T11:14:07Z>

Where the information comes from depends on how the binary was built:

Build Output
Release archives and packages The version without v, the commit and the commit date, set at build time
AUR package lazysubmodules-git The package version, such as 0.1.0.r3.g1234abc, the commit and the commit date
go install …@v1.2.3 or @latest The module version with v (lazysubmodules v1.2.3), commit: none and date: unknown: Go records no commit for a downloaded module
go build or go install in a Git checkout The version that Go derives from Git: the tag at the checked-out commit, such as v0.1.0, or else a pseudo-version, such as v0.0.0-20260917111407-3cf609fddb33 before the first tag or v0.1.1-0.20260917111407-3cf609fddb33 after v0.1.0; with +dirty for uncommitted changes. Then the commit and the commit date that Go records
go build without Git information lazysubmodules dev, commit: none and date: unknown
Interrupting a command
  • Signals: SIGINT (Ctrl+C), SIGTERM and SIGHUP (sent when the terminal is closed) cancel the running command, and the git processes it started are terminated.
  • Rollback: an interrupted update puts back what it changed before staging: submodules it already checked out return to their previous commit, and nothing is staged. Once the result is staged, only the commit remains; when git commit is interrupted or fails, for example in a hook, the update stays staged and no commit is made.
  • Exit code: the command prints the error and exits with its code, usually 5, because a git command was killed.
  • Second signal: a second signal ends LazySubmodules at once, without waiting for the clean-up.
  • Ignored signals: SIGINT and SIGHUP stay ignored when they are ignored at start, as under nohup or for background jobs of a non-interactive shell.
  • TUI: there, Ctrl+C is a key; see Leaving the interface.
Exit codes
Code Meaning Examples
0 Success
1 Generic error Unknown submodule name, invalid configuration values (such as an unknown lsm-mode or a malformed lock entry), a symbolic link in place of .lsm.lock, a failing foreach command
2 Usage error Unknown flag, conflicting tracking options, invalid ref or path, tui without a usable terminal
3 Refused (unsafe state) Dirty submodule, missing ref, unrelated changes for --commit, existing path for add
4 Verification failed Moved tag, lock and gitlink disagree, bad signature
5 Git command failed A git invocation exited with an error or was interrupted, including a .gitmodules or .lsm.lock that Git cannot read or parse

Errors are printed to standard error as lazysubmodules: <message>, with one prefix per line when several errors are reported.

Terminal user interface

┌ Submodules ─────────────────────────────────────────┬ Preview ───────────────┐
│ NAME       MODE        REF     LOCK    STATE        │ kernel behind          │
│ kernel     tag-pattern v6.6.*  a1b2c3d behind       │ update would select    │
│ u-boot     branch      main    d4e5f6a behind       │ tag-pattern v6.6.9     │
│ fpga-ip    tag         v2.3.1  0718ab2 drift        │ instead of tag-pattern │
│ crypto lib commit      3f2a1b0 3f2a1b0 ok           │ v6.6.8                 │
│ legacy     -           -       -       unmanaged    │ HEAD   a1b2c3d         │
│ theme      tag         v1.0.0  5e5e000 uninitialized│ Lock   v6.6.8 a1b2c3d  │
│                                                     │ Target v6.6.9 e4f5a6b  │
│                                                     │                        │
│                                                     │ Update adds (1)        │
│                                                     │   e4f5a6b Linux v6.6.9 │
│                                                     │                        │
└─────────────────────────────────────────────────────┴────────────────────────┘
 6 submodules, 4 not ok
 u update  b branch  t tag  p pattern  f fetch  v verify  ? help  q quit
  • Table: the submodules with their mode, configured ref, locked commit and state. In narrow terminals the LOCK column is dropped first, then MODE.
  • Preview: for the selected submodule, why it is in its state, its HEAD, lock and update target, the commits an update would add (Update adds), the difference between the lock and HEAD, the recent log and the tags. It uses local refs only and is hidden in terminals narrower than 80 columns.
  • Bars: the status bar shows the result of the last action and errors; the key bar shows the main keys, and more of them in wide terminals.
Key Action
/ k j Navigate
PgUp PgDn, g G (Home End) Page up and down, first and last submodule
Enter Submodule details
u Update the selected submodule (stage)
U Update the selected submodule and commit
b / t / p Change the tracking: pick a branch, pick a tag, or enter a tag pattern
f Fetch the selected submodule, cloning it if needed (the only network action)
v Verify the selected submodule
d Show the gitlink diff in the superproject
r Reload
? Help
q Quit, or close the open dialog
Ctrl+C Quit at once

In dialogs:

Where Keys
Question y confirms; n, Esc or q cancels
Branch or tag list move, / filters, Enter chooses, Esc or q closes. While filtering, Esc clears the filter
Pattern dialog Type the pattern, Enter submits it, Esc closes
Details, diff, verify result, help , PgUp PgDn and g G scroll, Esc or q closes
  • Confirmation: actions that modify the superproject ask first.
    • u and U first run a dry run of the update and show its result, as update --dry-run would, for example Update kernel: v6.6.9 (8106f61) -> v6.6.10 (08dcd0d), then ask whether to stage or to commit it (git commit -s). y works only once the result is shown.
    • A refusal (such as uncommitted changes) or already up to date is shown in the status bar at once, without a question.
    • The question also says when the update changes nothing that the index (u) or HEAD (U) records, so that nothing is staged or committed: when it only initializes the submodule, checks out the recorded commit, or rewrites the working tree copy of .gitmodules or .lsm.lock to what is recorded. With U, it also says when a change staged for the submodule is discarded, as update --commit does, and when the update is staged already.
  • Outcome: the status bar says what happened, for example kernel: updated to v6.6.10 (08dcd0d), staged, theme: initialized at v1.0.0 (05f49f3), or sdk: .lsm.lock restored to v2.9.0 (68a8743). It adds staged change discarded when U discarded a staged change, and ends with staged, committed <commit> or nothing to commit only when that applies.
  • Tracking changes: b, t and p change only the tracking configuration, like lazysubmodules set, after a confirmation; press u or U afterwards to update.
  • Pattern entry: p counts the local tags that match the pattern while you type and lists the highest of them, including how many are pre-releases. An invalid pattern cannot be submitted.
  • No fetching in updates: u and U never fetch. A submodule that needs a clone is refused with a hint to press f first.
  • Background work: long-running operations run in the background with a spinner, so the interface never blocks. Only one modifying operation runs at a time; other modifying keys are ignored with a note meanwhile.
  • Errors are shown in the status bar, and in a dialog when they are too long for it; the TUI does not exit on errors.
  • Colors respect NO_COLOR: any non-empty value turns them off. Bold text and reverse video, which mark headings and the selection, remain.
Leaving the interface
  • q quits from the table. In a dialog, the details or the help, it closes that first. In text fields (the pattern dialog and the filter of a list), q is typed as text; Esc leaves them.
  • While an operation runs, q asks before quitting, because quitting interrupts the operation.
  • Ctrl+C quits at once, from anywhere, and interrupts a running operation, which puts back what it changed, as described in Interrupting a command.
  • Late results: lazysubmodules tui waits for an interrupted operation to finish and prints its outcome after the screen closed. A failure is reported as an error, and the exit code is that of the failure, for example 5 for an interrupted update.
  • Signals: SIGINT, SIGTERM and SIGHUP sent to the process end the interface the same way. The error names the signal, for example lazysubmodules: terminal interface: terminated signal received, and the exit code is 1 unless an interrupted operation failed.
Git without a terminal

While the interface is shown, Git runs in a session of its own, without access to the terminal, and with GIT_TERMINAL_PROMPT=0. It cannot ask anything there, so a prompt fails instead of drawing over the screen. For fetching and cloning with f, and for the hooks that run during u and U:

  • Credentials must come from a credential helper that does not prompt in the terminal, or from an SSH agent or a key without a passphrase.
  • SSH host keys must already be known; an unknown host fails with Host key verification failed.
  • Hooks must not read from the terminal.
  • Graphical prompts still work: an SSH_ASKPASS or GIT_ASKPASS program, or a graphical pinentry.

With commit.gpgSign set, a terminal pinentry such as pinentry-curses finds the terminal through GPG_TTY and can still draw over the interface; use a graphical pinentry or a cached passphrase, or commit with lazysubmodules update --commit.

Verifying releases

Release checksums are signed with cosign keyless signing from the release workflow. Each release has checksums.txt (SHA-256) for all archives, packages and SBOMs, its signature checksums.txt.sig, the signing certificate checksums.txt.pem, and the same signature as a Sigstore bundle, checksums.txt.sigstore.json. An SPDX SBOM generated by syft is published for each archive (<archive>.sbom.json).

VERSION=1.2.3
BASE="https://github.com/FPGArtktic/lazysubmodules/releases/download/v${VERSION}"
for f in checksums.txt checksums.txt.sig checksums.txt.pem; do
	curl -fLO "${BASE}/${f}"
done

cosign verify-blob \
	--certificate checksums.txt.pem \
	--signature checksums.txt.sig \
	--certificate-identity-regexp '^https://github\.com/FPGArtktic/lazysubmodules/\.github/workflows/release\.yml@refs/tags/v.+$' \
	--certificate-oidc-issuer https://token.actions.githubusercontent.com \
	checksums.txt

# Then check the downloaded archives and packages against the checksums.
sha256sum --ignore-missing -c checksums.txt
  • Result: cosign prints Verified OK on success.
  • Bundle: with a recent cosign, --bundle checksums.txt.sigstore.json can replace the --certificate and --signature options.
  • Network: verification looks up the signature in the public Sigstore transparency log, so it needs network access.
  • Deprecation warnings: recent cosign versions warn that --certificate and --signature are deprecated. The flags still work.
  • Release tags are signed as well (git tag -s), and the release workflow publishes nothing for a tag without a good signature from a maintainer key. Check a tag with git tag -v v<version>, given the maintainer's public key.

Building from source

The supported build runs in a container with a pinned toolchain, on an x86_64 (amd64) Linux host. It needs Podman (preferred) or Docker:

git clone https://github.com/FPGArtktic/lazysubmodules.git
cd lazysubmodules
scripts/build-in-container.sh build   # binary in bin/lazysubmodules

Build and test with a host Go toolchain (Go 1.27.1 or later) instead:

go build -trimpath -o lazysubmodules ./cmd/lazysubmodules
go test -race ./...

Dependencies are vendored in vendor/, so neither build downloads modules. CONTRIBUTING.md describes all build targets (test, lint, snapshot, ...). A binary you build yourself does not come with the third-party license notices; scripts/third-party-licenses.sh collects them (see Third-party notices).

Test coverage

The coverage badge shows the statement coverage of the test suite (go test -race ./...) on main, updated by every successful CI run of its newest commit. Each package counts only the statements that its own tests run, as go test -cover reports them in that run, and the total is the share of all statements. A few error paths only run when a test cancels work that runs in parallel, at a moment that differs from run to run, so the same code can measure a tenth or two of a point apart. CI measures the coverage with scripts/build-in-container.sh coverage and stores the badge value in the generated badges branch; no external coverage service is involved.

Every run of the CI workflow shows the coverage per package in its summary and keeps the full report, with an HTML view of the covered lines, as the artifact coverage for 14 days (downloads need a GitHub account). The same report locally:

scripts/build-in-container.sh coverage   # coverage/coverage.html, coverage/summary.md

See Test coverage for the details.

Known limitations

  • Nested submodules are not managed recursively in v1: submodules inside a managed submodule are neither initialized nor updated by LazySubmodules. After an update, a nested submodule may stay at a commit other than the one the new commit records. That alone does not make the managed submodule dirty; modified files inside the nested submodule do.
  • Linux only (amd64, arm64). Windows and macOS are not supported.
  • The remote is always origin. Branch tracking resolves refs/remotes/origin/<branch>, and fetch fetches from origin.
  • Local resolution. status, verify and update without --fetch see only what was fetched before.
  • Git 2.39 or later is required.
  • No prompts in the TUI. Git cannot ask for credentials or host keys there (see Git without a terminal).

Contributing

Contributions are welcome. Read CONTRIBUTING.md for the build and test workflow, the coding style and the commit message rules. Every commit needs a Signed-off-by line, which certifies the Developer Certificate of Origin. Bug reports and feature requests go to the issue tracker; its forms ask for the details that help, such as the output of lazysubmodules version and status --porcelain=v1.

Security

Do not report vulnerabilities in public issues. SECURITY.md describes how to report them privately, what to include and what to expect.

License

LazySubmodules is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License, version 3 only (GPL-3.0-only), as published by the Free Software Foundation. See LICENSE for the full text. The same license covers the documentation and the images in docs/, such as the demo recordings and the social preview.

The release binaries also contain the Go standard library and Go modules under the BSD-3-Clause and MIT licenses. Their notices and license texts ship with every archive and package (THIRD_PARTY_NOTICES and licenses/, or the Debian copyright file), as described in Installation.

Author

Mateusz Okulanis FPGArtktic@outlook.com

Directories

Path Synopsis
cmd
lazysubmodules command
Command lazysubmodules manages Git submodules that track branches, tags, tag patterns or commits.
Command lazysubmodules manages Git submodules that track branches, tags, tag patterns or commits.
internal
core
Package core holds the business logic of LazySubmodules.
Package core holds the business logic of LazySubmodules.
git
Package git runs the git command line client.
Package git runs the git command line client.
git/gittest
Package gittest creates isolated git repositories for tests.
Package gittest creates isolated git repositories for tests.
lock
Package lock reads and writes .lsm.lock, the lock file of a superproject.
Package lock reads and writes .lsm.lock, the lock file of a superproject.
manifest
Package manifest reads and writes the tracking configuration of submodules in the .gitmodules file of a superproject.
Package manifest reads and writes the tracking configuration of submodules in the .gitmodules file of a superproject.
porcelain
Package porcelain writes the machine-readable output of LazySubmodules.
Package porcelain writes the machine-readable output of LazySubmodules.
tui
Package tui implements the terminal user interface of LazySubmodules.
Package tui implements the terminal user interface of LazySubmodules.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL