README
¶
LazySubmodules

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.lockrecords what each submodule was resolved to, so tags that were moved on the remote are detected. - Predictable network use: only
fetch,update --fetchandaddtouch the network; everything else works on local refs. - Native Git underneath: configuration lives in
.gitmodules, and all work is done by thegitcommand line tool. Plaingit submodule update --remotekeeps working for branch-tracking submodules. - Scripting: a stable, machine-readable status format,
verifywith optional signature checks, and distinct exit codes.
Contents
- Quick demo
- Why LazySubmodules
- Quick start
- Tracking modes
- Data format
- Installation
- Command reference
- Terminal user interface
- Verifying releases
- Building from source
- Known limitations
- Contributing
- Security
- License
- Author
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 fetchorupdate --fetchfirst. 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 alwaysorigin. -
Tag: resolves
refs/tags/<tag>. Annotated tags are dereferenced to their commit, as withgit rev-parse "<tag>^{commit}". -
Tag pattern: the pattern is a glob as accepted by
git tag --list. Candidates are sorted by version withgit -c versionsort.suffix=- tag --list <pattern> --sort=-v:refnameso
v1.10.0sorts abovev1.9.0andv1.0.0-rc.1sorts belowv1.0.0. The highest candidate wins. -
Pre-release tags are excluded from
tag-patternunlessupdateoraddis given--include-prerelease. A tag counts as a pre-release when a-follows its first digit:v1.0.0-rc.1andv6.6-rc3are pre-releases,release-2.1is not. -
Never downgrade: in
tag-patternmode, the tag recorded in.lsm.lockstays 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-prereleasetov2.1.0-rc.1, a plainupdatekeepsv2.1.0-rc.1instead of going back tov2.0.3. It moves on tov2.1.0once 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
branchkey keepsgit submodule update --remoteworking without LazySubmodules. - The file is read and written only with
git config -f .gitmodules. - Submodules without
lsm-modeare shown asunmanagedand never modified, until you opt in withlazysubmodules set. - An unknown
lsm-modevalue is reported as an error. An invalidlsm-refmakes the submodule show up asmissing-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 |
updateandaddwrite the lock file and stage it together with the gitlink change, even when an ignore rule such as*.lockmatches it. Commit it to the superproject..gitmodulesand.lsm.lockmust 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):
statusshowsdriftandverifyfails.
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/lazysubmodulesand the short name/usr/bin/lsm(a symlink);README.mdunder/usr/share/doc/lazysubmodules/;.deb:LICENSEin the same directory, and the Debian copyright file/usr/share/doc/lazysubmodules/copyrightwith the notices and license texts of the third-party code;.rpm:LICENSE,THIRD_PARTY_NOTICESandlicenses/under/usr/share/licenses/lazysubmodules/(rpm -qL lazysubmoduleslists 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:
-sinstalls the build dependencies (go,go-licenses, andopensshfor the tests) with pacman, and-iinstalls the package. - Contents:
/usr/bin/lazysubmodules, the short name/usr/bin/lsm, the README under/usr/share/doc/lazysubmodules-git/, andLICENSE,THIRD_PARTY_NOTICESandlicenses/under/usr/share/licenses/lazysubmodules-git/. The recipe builds nolazysubmodules-git-debugpackage, whatevermakepkg.confsays: 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.g1234abcfor the third commit afterv0.1.0, orr54.3cf609fbefore the first release.lazysubmodules versionprints 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>],-hand--helpprint 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.addandsettake exactly one of--branch,--tag,--tag-patternand--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 (statuslists 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 exceptstatusandset. - Output: submodules are processed and reported in
.gitmodulesorder. Names and refs with unusual characters are shown quoted, so that they cannot send control sequences to the terminal. - Progress: the output of
gititself, 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:
- Clones it with
git submodule add; branch mode passes-b <branch>. The submodule name is its path, as with plain Git. - Writes
lsm-modeandlsm-refto.gitmodules. - Resolves the ref, checks out the commit and writes
.lsm.lock. - Stages
.gitmodules,.lsm.lockand the new gitlink. It does not commit.
It prints one line, for example u-boot: added at main (8e9fc7a).
--include-prerelease: lets a--tag-patternselect a pre-release tag, as forupdate.- 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; runupdateafterwards.updatethen stages.gitmodulestogether with the lock file and the gitlink. - Unmanaged submodules:
setis 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:
- Resolve the target commit according to the resolution rules.
- Check out the commit in the submodule. A detached HEAD is expected.
- Update
.lsm.lock. - Default: stage the gitlink,
.gitmodulesand.lsm.lockwithgit add. --commit: additionally create a commit (see below).--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,updaterefuses unless--fetchis given. - Dry run:
--dry-runresolves against the refs that exist locally. A submodule that would need a clone is listed with the targetunknown until fetched. - Native key:
updatealso rewrites the nativebranchkey in.gitmodulesto 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 (
fetchfirst, or use--fetch); - a submodule is not initialized, its Git directory is missing and
--fetchwas not given; --commitis given and the commit would include unrelated changes, or the index has unresolved merge conflicts (seeupdate --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:
initializedorclonedfor 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 againwhen the superproject records the same commit again, for example aftersetchanged the ref but not the commit;restored .lsm.lock,restored .gitmodulesorrestored .gitmodules and .lsm.lockwhen 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 nativebranchkey 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) whenHEADrecords the target already, but the index holds a different gitlink, lock entry or tracking keys for the submodule. The update stages whatHEADrecords in their place, so the staged change is lost (seeupdate --commit).
The mode is shown on both sides when it changes.
-
Dry run: each line starts with
would update, and the notes readinitialize,clone,HEAD is <commit>,record again,restore <files>anddiscard the staged change. -
Commit: with
--commit, the last line iscommitted <commit>ornothing to commit. A dry run printswould commit "<subject>",would commit the resultwhen a target is not known before a clone, ornothing to commit. -
Nothing selected: without managed submodules,
updateprintsno 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.
updaterefuses (exit code 3) when other paths than.gitmodules,.lsm.lockand the selected submodules are staged, or when.gitmodulesor.lsm.lockdiffer fromHEADoutside 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 theSigned-off-byline comes fromuser.nameanduser.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 nativebranchkey) change compared withHEAD. - Left out: a submodule that is only initialized, cloned, or checked
out at the commit that
HEADrecords 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.gitmodulesor.lsm.lockto whatHEADrecords (restored …). - Nothing to commit: without such a change, no commit is made and
updateprintsnothing 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
HEADrecords the target already, the submodule is left out of the commit, so a different staged value is discarded without being committed; the line then saysdiscarded the staged change. Runupdate --dry-run --commitfirst 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
HEADof 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 readsOld: <sha> (unlocked).Old: nonemeans that the superproject recorded no gitlink for the submodule inHEAD, 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 tomanifest: 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
.gitlintrules 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 --fetchdoes not use the network either. - TUI: in the TUI,
f(fetch) is the only network action; itsuandUnever fetch. - Mirrors configured with
url.<base>.insteadOfwork transparently, because all network access goes throughgit. - 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.
LOCKadds the locked ref in parentheses when it differs fromREF, as for tag patterns. An empty cell shows-. - Colors: the header and the states are colored only when standard
output is a terminal,
NO_COLORis unset or empty, andTERMis notdumb. - Offline:
statususes local refs only. Forbranchmode,behindcompares 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:
unmanaged: nolsm-modekey.uninitialized: the submodule is not checked out.dirty: the working tree has uncommitted changes.missing-ref: the configured ref is invalid or does not resolve locally.drift: a lock entry exists, and either HEAD differs from the locked commit, or, intagandtag-patternmode, the locked tag now points to a different commit or no longer exists.behind: there is no lock entry yet, orupdatewould select a different ref or commit than the lock records, or HEAD differs from that target.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:
--porcelainalone means--porcelain=v1. The value must be attached with=: instatus --porcelain v1,v1is 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
.gitmodulesorder, 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
\033for ESC,\342\200\250for U+2028 and\377for 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:
- Check that the first line is exactly
# lsm-porcelain v1. - Split the rest into lines at LF. No field contains a raw LF, CR or other character at which common functions split lines.
- Split each line at every TAB into exactly eight fields, keeping empty
fields. Do not use a splitter that merges adjacent TABs: Bash
readwithIFS=$'\t'treats TAB as white space and drops empty fields.awk -F '\t',cut -fand thesplitfunction of most languages keep them. - Decode a field that starts with
": remove the quotes and resolve the escapes, which gives bytes that are not necessarily valid UTF-8. Go'sstrconv.Unquotedoes this, and so doescodecs.escape_decode(field[1:-1].encode())[0]in Python. Use other fields as they are. - 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
gitlinkcheck reads the committed state of the superproject. An update that is staged but not yet committed therefore failsverify. --signatures: runsgit verify-tagon the locked tag intagandtag-patternmode, andgit verify-commiton the locked commit inbranchandcommitmode. GPG and SSH signatures work as configured in Git (for examplegpg.formatandgpg.ssh.allowedSignersFile).
foreach
lazysubmodules foreach -- <command> [args...]
- What runs:
<command>runs in every managed, initialized submodule, in.gitmodulesorder, 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 -cwhen you need one. --: may be left out when the command does not start with-.- Failure:
foreachstops 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),SIGTERMandSIGHUP(sent when the terminal is closed) cancel the running command, and thegitprocesses it started are terminated. - Rollback: an interrupted
updateputs 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; whengit commitis 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
gitcommand was killed. - Second signal: a second signal ends LazySubmodules at once, without waiting for the clean-up.
- Ignored signals:
SIGINTandSIGHUPstay ignored when they are ignored at start, as undernohupor 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
LOCKcolumn is dropped first, thenMODE. - 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.
uandUfirst run a dry run of the update and show its result, asupdate --dry-runwould, for exampleUpdate kernel: v6.6.9 (8106f61) -> v6.6.10 (08dcd0d), then ask whether to stage or to commit it (git commit -s).yworks only once the result is shown.- A refusal (such as uncommitted changes) or
already up to dateis shown in the status bar at once, without a question. - The question also says when the update changes nothing that the
index (
u) orHEAD(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.gitmodulesor.lsm.lockto what is recorded. WithU, it also says when a change staged for the submodule is discarded, asupdate --commitdoes, 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), orsdk: .lsm.lock restored to v2.9.0 (68a8743). It addsstaged change discardedwhenUdiscarded a staged change, and ends withstaged,committed <commit>ornothing to commitonly when that applies. - Tracking changes:
b,tandpchange only the tracking configuration, likelazysubmodules set, after a confirmation; pressuorUafterwards to update. - Pattern entry:
pcounts 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:
uandUnever fetch. A submodule that needs a clone is refused with a hint to pressffirst. - 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
qquits 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),qis typed as text;Escleaves them.- While an operation runs,
qasks 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 tuiwaits 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,SIGTERMandSIGHUPsent to the process end the interface the same way. The error names the signal, for examplelazysubmodules: 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_ASKPASSorGIT_ASKPASSprogram, 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 OKon success. - Bundle: with a recent cosign,
--bundle checksums.txt.sigstore.jsoncan replace the--certificateand--signatureoptions. - Network: verification looks up the signature in the public Sigstore transparency log, so it needs network access.
- Deprecation warnings: recent cosign versions warn that
--certificateand--signatureare 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 withgit 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 resolvesrefs/remotes/origin/<branch>, andfetchfetches fromorigin. - Local resolution.
status,verifyandupdatewithout--fetchsee 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. |