Having performed anti-unification to simplify the config files based on commonalities, we now have concise views of subtrees of interest.
The obvious next step is to action these. Besides enumerating we want to make changes, commonly known as a 'codemod'.
To talk about applying codemods means overwriting files, and up to now we have purely been doing read-only operations. To do any write would mean modifying the sparse checkout clones we have in the cache and then either committing or PR'ing from them against the default branch (the one we checked out).
For simplicity I would just commit directly to the default branch, but at the same time that means we cannot get any benefits of running automated tests in CI etc. Which of these is appropriate will depend on what you're doing. There are also some codemods that would imply a need to have the full code checked out (e.g. if we change dependencies en masse we'd need to update the lockfile or else it'd be stale), and a lockfile isn't a config file (it's more of a build metadata artifact).
So the design choices here will limit what kinds of codemod we can do:
- Direct git commits limit the ability for codemods to operate on things that need PR checks to run
- Modifying our sparse checkout will restrict us from codemods that need to access things outside our sparse checkout "cone" (i.e. the set of tracked config file types: workflows and TOML files)
The latter of these to me suggests that the appropriate way is to clone down again, and indeed this is intuitively in line with the idea of what a PR is: it's a new branch, and since our cache exists to mirror the default branch, we should avoid dirtying it with draft changes at any point.
So this leads to the conclusion that we will create new clones of the repos, which would be collected by codemod.
Codemod In Progress
The more we can characterise what kind of objects these are going to be, the clearer our sense of how to treat them will become.
The first consideration that springs to mind is whether these are cached objects (behind the scenes) or 'user space' objects.
When we scan for repos on the remote host, it makes sense to treat the results as cached, because the remote might do things unbeknownst to us that we'd need to update on.
However when we are performing our own codemods, we know for a fact that nothing should be happening to them. When we make a branch, that branch can be expected to be left alone, we are in a closed environment. In other words, it is pure and deterministic, without side effects.
- (PRs are different, they are 'active concerns': PR bots might apply changes remotely after we push, such as the pre-commit.ci bot)
A codemod doesn't need the repo history, so the clone can be shallow, just not sparse.
A cache of repos is a snapshot, or a materialised index, which is eventually consistent, whereas a repo in which you carry out edits is an epehemeral transactional workspace: a working copy.
We are talking about creating a single-purpose, version-pinned execution environment. It is fully materialised, isolated per transformation run, and disposable after commit/push. Crucially, it is not a cached object at all but a transaction scope.
The PR branch we create from this is an externalised and mutable control surface. We have talked about this being a "fleet control plane", and this is its surface. A PR is a shared mutable coordination surface (open to external actors both human and bot). Its state is non-local, and may diverge from original intent: by design we are intending to use it to produce remote signals from CI automations (which may not necessarily affect the branch's files but will be used to coordinate the completion or halting of the branch merge).
In other words, the PR is a boundary at which to hand off, not part of execution. When we talk about codemods, we are firstly talking about the isolated branch that precedes that boundary.
Pens: isolated, ephemeral, durable transaction primitives
So we think about this as a 3 step process:
- The read-only cache/index,
- From which we produce branches (and these are fine to push to the remote too but I'd probably only do so upon PR),
- Lastly the hand off at PR time, at which point we lose the isolation.
The 2nd stage, the branch on which we have full isolated control, is the plane at which we perform our codemod.
In this framing the 'object' we are talking about is a kind of sandbox,
so the intuitive place for it to live is a temporary directory
(idiomatically these live in ~/tmp or /tmp).
This would be something I'd let the user configure (i.e. part of the config we put in
~/.config/nave.toml at nave init time).
It is ephemeral transactional state but not in-memory. That said, it is ephemeral yet durable for the duration of a transaction, so perhaps a tmp dir is not the right place for it to live. In particular, we would want to recover during execution (i.e. making it crash-safe mid-operation).
Yes we want it to be cleaned up after,
but I think what I'm leaning towards here is a workspace staging root directory manager,
and leaning towards ~/.local rather than ~/tmp,
the user-local application data directory,
tentatively at ~/.local/share/nave/pens/.
This then implies a whole new primitive, so a new command nave pen which might have subcommands
like:
-
nave pen create [<pen>]Create a new pen from a filter query; proposes a name and initialises local state.- Clones the repos and creates branches in them but does not run the codemod nor push branch to remote.
- Leaves the repos with fresh status, and not yet run state (which implies not yet pushed too).
-n/--nameflag to override automatically generated name.
-
nave pen statusShow a concise overview of all pens, their current status, and run state- Status is 3 things: working tree state (clean/dirty), fleet membership freshness (fresh/stale), and git divergence (up to date/ahead/behind)
- No effect on status or run state
--jsonflag for machine reading.
-
nave pen listList all local pens with summary info- Summary info: name, repo count, status label [if homogeneous] or counts (# clean/dirty and fresh/stale), run state
- No effect on status or run state
--filter <regex>flag--jsonflag for machine reading.
-
nave pen show <pen>Show detailed info about a pen (repos, branch name, status, run state)- No effect on status or run state
--filter <regex>flag (that must match only 1 pen)--jsonflag for machine reading.
-
nave pen syncReconcile local against remote state (freshen if stale).- Re-evaluate the filter on a fresh scan of the remote host, add/remove repos and notifies if any drifted.
- Potentially leaves the repos with freshened status, no effect on run state.
- Has a
--dry-runflag to notify what syncing would do (without effect on run state). --pruneflag to remove repos that no longer match the filter
-
nave pen run <pen>Runs the codemod: materialise, execute, and persist results to remote.- Ensure repos are all cloned, cancel run if pen is stale
--freshento allow to pull in this case, but cancels run if dirty.
- Applies codemod changes, pushes to remote.
- Pre-condition is that all pen repos have fresh status.
- Leaves the repos in run state
- Has a
--diffflag to show what running would do (no effect on run state) - Has a
--only <repo>flag for partial execution on one particular repo in the pen - Has a
--allow-dirtyflag to allow running (or dry running) from a dirty state (such as you get if you already usednave pen exec)
- Ensure repos are all cloned, cancel run if pen is stale
-
nave pen pruneRemoves pens that have run but refer to something that doesn't exist- Such as: repo no longer exists, no such remote branch (dangling local branch), remote branch with pen's name that doesn't exist in the pen
-
nave pen exec <pen> -- <cmd>- execute an arbitrary command on all repos in a pen (without redefining the codemod)- Does not push, leaves pen in dirty state
- No effect on run state (may be run or not yet run).
- If the run state is not yet run, implies the codemod may end up running on dirty state.
- Has
--push-changesflag to leave pen in clean state --commitflag to commit the changes (required to not be in a dirty state) and--push(required to not be in anaheaddivergence state)
-
nave pen clean <pen>Discards uncommitted working tree changes (cleans dirty state). -
nave pen revert <pen>Reset a pen's local execution state.- Restore the pen's local working directory to the last successful sync snapshot, discarding any local commits introduced after sync.
- Resets any local branch/repository changes (created by exec/run), returning each repo to the synced baseline state. In other words, the synced state is the source of truth.
--allow-dirtyflag allows revert if the working tree is dirty or has uncommitted changes (that otherwise block the operation)- Does not affect remote state or the pen configuration.
-
nave pen reinit <pen>Reinitialise a pen from remote source.- Restore the pen's local working directory to initial state (up to date with default branch).
--allow-dirtyflag allows revert if the working tree is dirty or has uncommitted changes (that otherwise block the operation)- Does not affect remote state or the pen configuration.
-
nave pen rm <pen>Remove the pen as a managed object.- Delete a pen's local definition (filter, name, config) and local workspace state, with safeguards if work is unfinished. By default it is a local-only cleanup operation.
- An
--allow-dirtyflag lets you delete local definitions with uncommitted changes - A
--purgeflag also deletes the corresponding branches on the remote, with confirmation for each that can be overridden by passing--no-interactive).
A pen is the combination of:
- a filter over repos (its selection rule)
- a set of intended transformations (the codemod)
- a definition of applicability conditions (a 'contract', mainly freshness of the remote)
...so far so declarative!
I also want to include operational steps which are inherently non-deterministic and stateful,
such as uv lock, and this means we cannot reduce a pen to merely declarative intent
(like a simple "find and replace in this file glob" type code transformation).
I would perhaps call the changes "actions" then, partly codemods, but partly things we are going to run after the codemods (like lockfile updates).
Pens as a function of the remote host
There are 2 important ways such a 'pen' might evolve:
- Repos might enter the pen by a repo being newly created or modified such that it meets the filter conditions (such as if a config file is added to a repo)
- Repos might leave the pen by a repo's pen branch being merged, thereby making its default branch no longer meet the filter condition (and yet potentially the pen branch is left lying around, causing repo branch bloat)
Both of these are because the remote is no longer in the same state as when nave scan was last
run, i.e. the cached fleet of repos became stale.
It makes sense to be aware of this because it implies that before we take any fleet-level action
to execute the codemod (we might say "execute the pen"), we would need to ensure that nave scan
shows the host state is still fresh.
Here we are essentially answering the question of whether the cached projection of a live GitHub fleet is still valid to act on, which we might call a contract.
Unique identifiers for pens
Having established that a "pen" is what we'll call the subset of the fleet we are interested in, we need to name each instance, each pen, with a stable identifier.
The pen's name must have certain properties:
-
It must double as a branch name, so must not be an existing branch name (certainly not locally on any of the pen's repos, ideally nor globally on any of the fleet's repos outside of the pen), and be safe (lower-case, alphanumeric with hyphens allowed, i.e. 'slugged' or 'kebab case').
-
Ideally it should be short and human-legible
-
Ideally it would come from the filter query itself, and could be automatically created, semantically tied to the operation
-
Ideally namespace-prefixed for ease of identification (
nave/)- Users might opt out of this at
nave inittime - This prefix will help avoid naming clashes
- Users might opt out of this at
If there are clashes we can just... I don't know add the date after it?
So there are going to be two names:
-
An automatically generated name
nave/<derived-slug>[-<n>]produced from the filter query (e.g.nave/maturin-action)- Given multiple query parts e.g.
--whereand--matchwe would use the first (withwherebeing primary,matchsecondary) - Strip query syntax to get the search term (e.g.
workflow:a-b⇒nave/a-b) - Truncate at a sensible number of chars like 20 (
overly-long-demo-string⇒overly-long-demo-str) - Check for uniqueness in the fleet and increment with a suffix if needed ⇒
overly-long-demo-str-2)
- Given multiple query parts e.g.
-
A manual name if the user rejects the automatically generated one (e.g.
nave/deprecate-maturin-action). Note the user's chosen name would retain the namespace, and the manual entry would be something they would be reminded they can opt out of in the nave config.- If it already exists, notify but silently increment by adding
-<n>rather than force the user to think about global uniqueness.
- If it already exists, notify but silently increment by adding
So this starts to suggest how the pen will be created: interactively, with the user being able to
accept or reject an automatically generated name, and this would occur after the user has provided
the filter query (the same syntax that was used for the nave build command, --where and --match).
The part of the name after the nave/ prefix would then become the directory name like
nave/deprecate-maturin ⇒ ~/.local/share/nave/pens/deprecate-maturin.
Opening the pen: pen-wise pull requests
So a pen is a subset of a fleet of repos, and we've covered how to push to them in an orderly manner, but we haven't mentioned the most important part: how to open PRs. We won't be just merging the branch directly, we will always go via a PR.
This implies a "pen request" (or a set of pull requests for an entire pen of repos).
We already covered that we'll give them all a shared branch name, which means we can use GitHub's
GraphQL API to find all the PRs for a given pen, so any repos in the pen but not in that set
must either lack branches (which is trivially checked by git fetch) or can be assumed to
lack a PR.
In this example I am searching for PRs authored by me, and whose branch name is prefixed by
"template-" (to match the known branch name template-where-filter).
Obviously this could be set to "nave/" to instead match all the nave branches.
This would be useful for listing all PRs for known branches (and finding any dangling PRs).
gh api graphql -f query='
{
search(query: "is:pr author:lmmx head:template-", type: ISSUE, first: 100) {
nodes {
... on PullRequest {
title
number
headRefName
mergeable
mergeStateStatus
state
}
}
}
}'
{
"data": {
"search": {
"nodes": [
{
"title": "feat: nave build --where flag",
"number": 11,
"headRefName": "template-where-filter",
"mergeable": "MERGEABLE",
"mergeStateStatus": "CLEAN",
"state": "OPEN"
}
]
}
}
}
The commands for handling PRs then would be:
-
nave pen opento open a PR for each pen repo (wrappinggh pr create)- Relevant flags:
--base <branch>,--body <string>/--body-file <file>,--fill/--fill-first,--title <string>
- Relevant flags:
-
nave pen mergeto merge all pen PRs (wrappinggh pr merge)- Relevant flags:
--auto(watch the CI checks until they complete before merging),-s/--squash,-d/--delete-branch
- Relevant flags:
-
nave pen closeto cancel any/all open pen PRs (wrappinggh pr close)
Among the existing commands there are a few that we would add PRs to:
-
nave pen listcan also show (by the GraphQL call shown above) PR count and their status counts, in terms of merged/open and CI checks. -
nave pen statuscan also show a status overall for PRs (all merged, all open, or mixed counts)
We do not consider PRs as a component of the pen model (they are a projection of it),
so we do not add PR-related functionality to the pen state operators rm, prune, etc.
An example workflow
Putting this all together then, we end up at this sequence of commands:
nave pen create <pen>to clone repos into~/.local/share/nave/pens/<pen>nave pen run <pen>to run (an as-yet unspecified) codemod on each repo, and push the branch to origin (marking each pen repo's state asrun: completedandpr: none)nave pen open <pen>to open PRs per repo from the head pen branch to the base default branch (passing either--title/--bodyor--fillto provide the PR metadata)- Optionally, at this point we might run
nave pen status <pen>to observe the PR check status etc. nave pen merge <pen> -sdto merge (via squash commit) and delete the remote copies of all the pen branches. Possibly also passing--autodepending on the CI maturity.- Optionally, at this point we might run
nave pen status <pen>to observe the PRs are no longer found, indicating they were cleaned up as expected. nave pen rm <pen>to clean up local state (and passing--purgewould be fine too since we would already assume the remote branches have all been deleted when merging)
If anything goes wrong we can nave pen close.
Next we will consider what exactly a codemod should be, how to author one and how to execute it, without resorting to poorly typed approaches like just find and replace, which is straightforward but quickly limiting in real world repos.