Orchestrating codemods

Rebuilding the fleet in transit

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:

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.

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:

  1. The read-only cache/index,
  2. From which we produce branches (and these are fine to push to the remote too but I'd probably only do so upon PR),
  3. 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:

A pen is the combination of:

...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:

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:

If there are clashes we can just... I don't know add the date after it?

So there are going to be two names:

  1. An automatically generated name nave/<derived-slug>[-<n>] produced from the filter query (e.g. nave/maturin-action)

    • Given multiple query parts e.g. --where and --match we would use the first (with where being primary, match secondary)
    • Strip query syntax to get the search term (e.g. workflow:a-bnave/a-b)
    • Truncate at a sensible number of chars like 20 (overly-long-demo-stringoverly-long-demo-str)
    • Check for uniqueness in the fleet and increment with a suffix if needed ⇒ overly-long-demo-str-2)
  2. 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.

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:

Among the existing commands there are a few that we would add PRs to:

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:

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.