Skip to content

Send a change upstream

$ grim push Cart -m "fix(cart): correct totals" --dry-run

You found the bug while working on a client's project, and you fixed it where you stood: inside src/Cart/, a module grim install put there. The fix now exists in one project. Committed in the project it survives the next grim update, but every other project still has the bug.

Modules and themes are released from a monorepo, and the package repositories are written by its release process alone. So a change does not go back to the package. grim push sends it to the monorepo as a pull request, one unit at a time, and it returns to every project with the next train.

A project whose grim.json says "upstream": { "mode": "units" } is past that: its units are released from their own repositories, and grim push opens the pull request there, several units in one go. See If your project uses upstream.mode: units.

A project that says nothing follows the release-mode file in its root. Core carries it, grim update keeps it current, and the day the monorepo freezes its business units the file says units — from then on grim push goes to the unit's repository without a change to grim.json. An explicit upstream.mode wins over the file.

Usage

$ grim push Cart --dry-run                              # base and diffstat, nothing sent
$ grim push Cart -m "fix(cart): correct totals"         # open the pull request
$ grim push --theme frontend/shop-front -m "feat(shop-front): add print stylesheet"

Run it anywhere inside the project. The real push needs the GitHub CLI gh, logged in, and write access to the monorepo. A dry run needs only to be able to read it.

Arguments and options

Open a pull request with your change to a module or theme

Usage

grim push [--theme THEME] [--theme-type THEME-TYPE] [--core] [-m|--message MESSAGE] [--dry-run] [--patch] [--repo REPO] [--base BASE] [--] [<module>...]

Arguments

Argument Description
module Module(s) to push, by src/ directory name (e.g. Cart); several only with upstream.mode units (optional, repeatable)

Options

Option Description
--theme=THEME Theme to push, as / (e.g. frontend/hvpdoku); repeatable with upstream.mode units (repeatable)
--theme-type=THEME-TYPE Theme type when --theme carries only the slug (default: frontend)
--core Push your change to core as one PR against the monorepo
-m, --message=MESSAGE PR title, conventional-commit style (e.g. "fix(hvpdoku): correct footer link")
--dry-run Show the base, the title and the diff; push nothing
--patch With --dry-run, print the full patch instead of a diffstat
--repo=REPO Target repository (default: the monorepo from grim.json upstream.repo, or with upstream.mode units the unit's own repository)
--base=BASE Branch to target (default: master, or main with upstream.mode units)

What it actually does

  1. Checks the title. -m becomes both the commit message and the pull request title, and it is held to the conventional-commit rules the monorepo enforces: type(scope): subject with the scope optional, one line, at most 100 characters, a lowercase subject with no full stop. A bad title is rejected before anything is fetched.
  2. Fetches the monorepo into .grim/upstream/ inside the project. The clone is blobless and sparse on the one unit path, and later pushes reuse it.
  3. Finds the base commit. That is the newest commit on the upstream branch whose copy of the unit is identical to the release you have installed. Branching from there is what keeps the pull request down to your change, even when the project is several trains behind.
  4. Builds the branch. grim-push/module/cart, or grim-push/theme/frontend/shop-front, starts at the base. The unit is replaced with your working copy: what git add -A would take under the unit directory, so the project's .gitignore decides. Deleted files are deleted there too.
  5. Shows the change. The title and a diffstat, or the full patch with --patch. With --dry-run it ends here with Dry run — nothing pushed.
  6. Commits, pushes and opens the pull request with gh, and prints its URL. The body is three lines: the unit, the release it was installed at, the base commit.

If your project uses upstream.mode: units

"upstream": { "mode": "units" }

With this in grim.json there is no monorepo in the picture. Each installed unit is released from its own repository, so the pull request goes there, and the base is simply the release you have installed.

$ grim push Cart -m "fix(cart): correct totals"
$ grim push Cart Orders --theme frontend/shop-front -m "fix(cart): correct totals"

What changes against the description above:

  1. No monorepo is fetched. There is no search for a base commit. The base is the release grim.lock names for the unit.
  2. The pull request goes to the unit's repository, grimoiry/grim-cart for the module Cart, grimoiry/grim-theme-shop-front for the theme frontend/shop-front, against its main. --repo still redirects one unit for a rehearsal against a sacrificial repository; --base still names the branch.
  3. The branch is named after the title: grim-push/fix-cart-correct-totals. Pushing again with the same title rewrites the same pull request, so keep the title to answer a review. A different title opens a new one.
  4. Several units travel in one command. Name every module and repeat --theme as needed. Each unit gets its own pull request with the same title, and once all of them exist every body carries a Part of: line with the URLs of the others. Everything is checked before the first push leaves: a unit the lock does not name or with nothing to send stops the command while nothing has happened yet. When a push or a pull request fails halfway, the others are not undone; the summary at the end says which URLs exist and which unit was not pushed.
  5. The merge order is printed, not enforced. When one of the units requires another one in the same push, grim says which pull request to merge first. It cannot know whether the change needs that order, only that it touched two repositories.
  6. Core still goes to the monorepo. grim-core is written by the monorepo's release process in either mode, so grim push --core opens its pull request against upstream.repo (see below).

The project is not touched: no commit, no branch, nothing staged.

If your project is a plain repository (model A)

A project whose units are plain directories — no .git inside src/Cart/, no /* allowlist in .gitignore — is one git repository with all its code in it. grim push works the same from the outside, with a different idea of "your change":

  1. The base is the release in grim.lock. grim takes the files of the tag the lock pins for the unit, keeps them under .grim/upstream/, and diffs the unit directory against them. A dry run of a unit whose release is already there needs no network.
  2. Your change is the working tree, committed in the project or not. Which files count is the project's git to say: what git add -A would take under the unit directory, so the managed .gitignore block keeps node_modules/ and build output out. A file of the release you deleted is sent as a deletion.
  3. The project is not touched. No commit, no staged file, no branch. Commit your change to the project the way you commit anything else; once the unit's next release arrives with grim update, the unit's diff against it is empty again.
  4. The tag must be the one you installed. When the release's tag now names a different commit from the one in grim.lock, the push stops rather than open a pull request with somebody else's changes in it.
  5. Core can be pushed. grim push --core -m "fix(admin): …" sends every change inside the core boundary as one pull request to the monorepo: the files of the installed core release, and new files under core module directories such as src/Admin/. core.json and instance state (grim.json, .env, storage/, files grim generates) never travel. Anything else you changed and have not committed — src/Shop/, routes/shop.php — is listed as staying in the project; a business module or theme is left out of that list, because it is pushed on its own. The base is the newest monorepo commit that carries every file of your core release unchanged, and the branch is grim-push/core-<project>, named after name in grim.json (the directory when there is none), so two projects pushing core never overwrite each other's pull request. The dry run names the branch. Naming a core module (grim push Admin) points you at --core.

The project must be a git repository of its own. Without one, grim push says to run git init, or grim adopt for a project from before this model.

Pitfalls

No base commit

No commit on acme/platform master carries this exact module Cart. means the release grim.lock names matches no commit on the monorepo branch: the tag never reached it, or it is older than the search reaches. Commit your change in the project, grim update to a newer release, and push again.

A core module goes with --core

Users is a core module is a refusal that names the way: grim push --core -m "fix(users): …". Core travels as one pull request per project, with every change inside the core boundary since the installed release. Commit first and read grim push --core --dry-run before you send it.

A second push replaces the first

The branch name is fixed per unit, or per project for core, and the push is forced. Pushing Cart again, before the first pull request is merged, rewrites that pull request with the current state of src/Cart/. That is the way to answer a review. It also means two unrelated fixes to one module travel together or one after the other, not side by side. With upstream.mode: units the branch is fixed per title instead, so two fixes with two titles do travel side by side.

The core branch, grim-push/core-<project>, comes from name in grim.json. Two projects with the same name share it and overwrite each other's core pull request, so give each project its own name. Renaming a project starts a new branch on its next core push; close the pull request of the old one.

--base is a branch, not a commit

It names the upstream branch to target, master unless grim.json says otherwise. The base commit is always found by grim.

The pull request could not be opened

When the push succeeds and gh pr create fails, the branch is already upstream. grim prints a compare URL; open it in a browser and create the pull request by hand.

Your gh token is missing the scope(s) … is not a permissions problem in the monorepo. The token gh holds lacks repo or read:packages. Run grim auth:login, as the message says.

After it finishes

The pull request is reviewed and squash-merged in the monorepo. Minutes later the sync that follows the merge tags the unit with its next patch, and grim update brings it back into this project as a release; the train itself moves only for core changes. Change a module and send it back says what the checks on the pull request look at and how to see the tag arrive.

Questions

Where does it push when the project belongs to another organisation?

To the repository named in grim.json under upstream.repo, on the branch under upstream.branch. --repo and --base override both for one run. Set the block once and commit it, so everybody on the project pushes to the same place. With upstream.mode: units there is no upstream.repo: every unit names its own repository. Without an upstream.mode at all, the release-mode file in the project root decides between the two.

Can I push a module I created with grim make:module?

A local module, made with --no-repo, has nowhere to go: a commit in the project is the whole change. A unit of your own has no copy in the monorepo, so no base commit exists there; with upstream.mode: units the pull request goes to its repository, otherwise release it from a clone outside the project, as Create a module shows.

What happens to node_modules/ inside a theme?

It stays home. Only what git add -A would take under the unit directory is sent, and the managed block of the project's .gitignore keeps node_modules/ out.

Recipes that use it