mikser-io-git 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +225 -0
- package/index.js +197 -0
- package/lib/bootstrap.js +146 -0
- package/lib/config.js +82 -0
- package/lib/debounce.js +26 -0
- package/lib/duration.js +14 -0
- package/lib/forge/gitea.js +71 -0
- package/lib/forge/github.js +64 -0
- package/lib/git.js +191 -0
- package/lib/inbound.js +43 -0
- package/lib/repo-url.js +28 -0
- package/lib/sync.js +123 -0
- package/package.json +23 -0
- package/test/bootstrap.test.js +59 -0
- package/test/config.test.js +101 -0
- package/test/debounce.test.js +57 -0
- package/test/duration.test.js +32 -0
- package/test/forge/gitea.test.js +91 -0
- package/test/forge/github.test.js +70 -0
- package/test/git.test.js +220 -0
- package/test/repo-url.test.js +35 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Almero Digital Marketing
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# mikser-io-git
|
|
2
|
+
|
|
3
|
+
Two-way git sync for [mikser-io](https://github.com/almero-digital-marketing/mikser-io). The **working folder itself is the checkout** — one repo, one dedicated write branch, shared across however many of its collections (`documents`, `layouts`, `files`, ...) you want auto-committed. Every green cycle (no render/postprocess failure) commits and pushes whatever changed inside those folders to that branch — the durable log of every API/MCP/agent edit — then tries to promote it into your target branch via a pull request. A red cycle holds the promotion; a merge conflict just leaves the PR open. Nothing is ever silently lost, and nothing broken ever reaches your default branch on its own.
|
|
4
|
+
|
|
5
|
+
Forge-portable: GitHub and Gitea adapters ship together, plus a no-forge fast-forward-only floor that works against any bare remote.
|
|
6
|
+
|
|
7
|
+
## Why this exists
|
|
8
|
+
|
|
9
|
+
mikser's API and MCP endpoints can write to **any registered collection**, not just documents — `PUT .../entities` and the MCP update/delete tools take `collection` as a plain field on the request, and `useCollection(runtime, collection).write(...)` resolves the folder generically via `runtime.options[`${collection}Folder`]`. So an agent editing a layout template goes through the exact same code as an agent editing a blog post. Those writes are ordinary file changes; the engine's file watcher picks them up like any local edit. Nothing in mikser records *who* changed a file — by design, files-as-source-of-truth doesn't care who wrote to them. So there's no way to distinguish an agent's edit from a human's after the fact, and this plugin doesn't try to. It versions **everything that changed and didn't break anything**, in whichever collections you tell it to watch, regardless of where the write came from.
|
|
10
|
+
|
|
11
|
+
## The model
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
API / MCP / agent writes to human, via a PR review
|
|
15
|
+
documents/, layouts/, ... directly ▲
|
|
16
|
+
│ │
|
|
17
|
+
▼ │
|
|
18
|
+
<working folder> (the checkout itself, on the "mikser" branch)
|
|
19
|
+
├── documents/ ◀── in `paths`, auto-committed
|
|
20
|
+
├── layouts/ ◀── in `paths`, auto-committed
|
|
21
|
+
├── mikser.config.js, node_modules/, runtime/, out/, .env
|
|
22
|
+
│ ◀── NOT in `paths` — never touched, ever
|
|
23
|
+
│
|
|
24
|
+
│ every green cycle: commit + push, scoped to `paths` (always — durability)
|
|
25
|
+
▼
|
|
26
|
+
origin/mikser ── PR / fast-forward ──▶ origin/main
|
|
27
|
+
▲ │
|
|
28
|
+
│ │
|
|
29
|
+
└──────────── poll: pull inbound ─────────┘
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- **The working folder is the checkout.** One `git()` instance manages one repo, one write branch — not a separate nested checkout per collection. Multiple collections (`paths: ['documents', 'layouts']`) share the same commit, the same branch, the same promotion. This also means you don't need a separate repo (or even a separate branch name) per collection the way managing each folder as its own independent checkout would — that shape existed in an earlier version of this plugin and needed real care to avoid two unrelated checkouts fighting over one branch ref; sharing one checkout removes the problem outright.
|
|
33
|
+
- **`paths`, when set, is a hard scope, not a convenience.** Every git operation (`status`, `add`, `commit`) is pathspec-scoped to exactly the folders you list. `mikser.config.js`, `node_modules/`, `runtime/`, `out/`, `.env` can sit in the exact same checkout and are never staged, added, or committed by this plugin — regardless of whether they're dirty, regardless of `.gitignore`. Verified directly against a real repo (see [Verified end-to-end](#verified-end-to-end)): an edit to `mikser.config.js` alone produces zero commits; an edit inside a `paths` folder commits normally, and only the files inside `paths` show up in that commit. **Omit `paths` and there's no scope at all** — the whole working folder is fair game, and `.gitignore` becomes the only thing keeping the noise out. See [Configure](#configure) for the trade-off.
|
|
34
|
+
- **`mikser` branch** — this plugin's own branch. It commits and pushes here on *every* green cycle, unconditionally. A red cycle never blocks this — the write branch is the durable log; it must never lose work to a later failure.
|
|
35
|
+
- **`main` (or whatever you name your target)** — promoted to only when the cycle that produced the change was green. Promotion is a pull request (GitHub/Gitea) that gets merged automatically on success, or a direct fast-forward push (`forge: 'none'`). A conflict just leaves the PR open — a human resolves it in the forge's own UI, in their own time. This plugin never picks a winner.
|
|
36
|
+
- **Inbound** — remote changes (a human pushed to `main`, or someone pushed directly to `mikser`) are pulled in on a timer and merged into the local working copy. Since `git merge` writes real files, mikser's own file watcher sees them exactly like a local edit — no special wake-up code needed.
|
|
37
|
+
|
|
38
|
+
**This is meant for a deployment target this plugin (and mikser) exclusively manages — not a developer's actively-edited local checkout.** Bootstrap checks out and holds the write branch for the ENTIRE working folder, not just the collections in `paths`. Point this at a developer's own local clone of the project and it will switch their currently-checked-out branch out from under them — same risk that existed before, just now at the scope of the whole project directory instead of one subfolder. A server deployment where nobody manually runs `git` in that checkout is the intended shape.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npm install mikser-io-git
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Configure
|
|
47
|
+
|
|
48
|
+
```js
|
|
49
|
+
import { git } from 'mikser-io-git'
|
|
50
|
+
|
|
51
|
+
export default {
|
|
52
|
+
plugins: [
|
|
53
|
+
git({
|
|
54
|
+
url: 'https://github.com/your-org/your-content.git',
|
|
55
|
+
paths: ['documents'], // scope auto-commits to just this folder
|
|
56
|
+
forge: 'github', // 'github' | 'gitea' | 'none' (default 'none')
|
|
57
|
+
token: process.env.GITHUB_TOKEN,
|
|
58
|
+
}),
|
|
59
|
+
// ...your other plugins
|
|
60
|
+
],
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Versioning more than one collection (they'll share the same commit, the same branch, the same promotion — no extra config needed per folder):
|
|
65
|
+
|
|
66
|
+
```js
|
|
67
|
+
git({
|
|
68
|
+
url: 'https://github.com/your-org/your-content.git',
|
|
69
|
+
paths: ['documents', 'layouts', 'files'],
|
|
70
|
+
forge: 'github',
|
|
71
|
+
token: process.env.GITHUB_TOKEN,
|
|
72
|
+
})
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**Omitting `paths` entirely** commits the whole working folder — no scoping at all:
|
|
76
|
+
|
|
77
|
+
```js
|
|
78
|
+
git({ url: 'https://github.com/your-org/your-content.git', forge: 'github', token: process.env.GITHUB_TOKEN })
|
|
79
|
+
// no `paths` — everything under the working folder is fair game, subject to .gitignore
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
This is the honest zero-config default, matching how the plugin actually works ("the working folder is the checkout") rather than silently narrowing to one collection nobody asked for. But **it's a weaker guarantee than `paths`**: when you list specific paths, they're a hard pathspec scope — `mikser.config.js`/`node_modules/`/`.env` are never touched, full stop, regardless of `.gitignore`. Omit `paths` and there's no pathspec at all; **`.gitignore` becomes the only thing standing between `node_modules/`, `.env`, `runtime/`, `out/` and your content repo.** Give the working folder a normal `.gitignore` if you're relying on the default — this plugin doesn't add one for you, and doesn't need to: `git add -A` already respects whatever's there. If you'd rather not depend on remembering that, list `paths` explicitly and get the hard scope instead.
|
|
83
|
+
|
|
84
|
+
### Gitea
|
|
85
|
+
|
|
86
|
+
```js
|
|
87
|
+
git({
|
|
88
|
+
url: 'https://git.almero.bg/your-org/your-content.git',
|
|
89
|
+
forge: 'gitea',
|
|
90
|
+
token: process.env.GITEA_TOKEN,
|
|
91
|
+
})
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`owner`, `repo`, and the forge API's base URL are all derived from `url` — you don't repeat them. Override any of them explicitly (`owner`, `repo`, `apiBase`) if your setup needs it — e.g. GitHub Enterprise Server, whose API lives at `<host>/api/v3` rather than `api.github.com`.
|
|
95
|
+
|
|
96
|
+
### No forge (any bare remote, self-hosted or otherwise)
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
git({ url: 'https://git.internal.example/content.git', forge: 'none' })
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Promotion becomes a direct `git push mikser:main` (fast-forward only). No API calls, no token scopes beyond git's own auth — works against literally any remote a `git push` would work against. If `main` has diverged (someone pushed to it directly since the last promotion), the push is rejected; everything stays queued on `mikser` until you configure a forge adapter or merge by hand.
|
|
103
|
+
|
|
104
|
+
## Options reference
|
|
105
|
+
|
|
106
|
+
```js
|
|
107
|
+
git({
|
|
108
|
+
url: 'https://github.com/org/repo.git', // REQUIRED
|
|
109
|
+
paths: undefined, // string or array — collection folder(s) to auto-commit,
|
|
110
|
+
// relative to the working folder. Default: unset, meaning
|
|
111
|
+
// the WHOLE working folder (subject to .gitignore) — see
|
|
112
|
+
// "Omitting paths entirely" above for the trade-off.
|
|
113
|
+
forge: 'none', // 'github' | 'gitea' | 'none'
|
|
114
|
+
branch: 'main', // target branch — promoted to only when green
|
|
115
|
+
writeBranch: 'mikser', // this plugin's own durable branch
|
|
116
|
+
token: undefined, // auth token. Omit to fall back to git's own credential
|
|
117
|
+
// resolution (SSH agent, credential helper, deploy key)
|
|
118
|
+
owner: undefined, // derived from `url` when forge != 'none'
|
|
119
|
+
repo: undefined, // derived from `url` when forge != 'none'
|
|
120
|
+
apiBase: undefined, // derived from `url`; override for GHES etc.
|
|
121
|
+
after: '1m', // debounce after a green cycle before syncing
|
|
122
|
+
maxWait: '10m', // ceiling — sync fires by this deadline even under
|
|
123
|
+
// a steady stream of green cycles that keep resetting `after`
|
|
124
|
+
pollInterval: '5m', // how often to check the remote for inbound changes
|
|
125
|
+
message: ({ fileCount }) => `content: ${fileCount} file(s) via mikser`,
|
|
126
|
+
author: { name: 'mikser', email: 'bot@yourdomain.com' },
|
|
127
|
+
})
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## First connect — and why it can refuse to guess
|
|
131
|
+
|
|
132
|
+
The checkout root is **the working folder itself** — the same directory `mikser.config.js` lives in. The one genuinely destructive moment in this plugin is attaching git to that folder for the first time. If it already has files but isn't a checkout, there is no safe default: "local wins" silently discards whatever's already in the remote repo the first time it syncs; "remote wins" silently discards your existing files. Whichever you didn't pick is gone on the next push or pull — there's no undo.
|
|
133
|
+
|
|
134
|
+
So the plugin does the only honest thing: **it refuses**, and tells you exactly what it found:
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
git: Working folder exists, has files, and is not a git repository. Refusing to guess whether
|
|
138
|
+
local files or the remote should win — whichever loses gets silently overwritten on the next
|
|
139
|
+
sync. Attach it by hand ONCE, from the working folder itself (this does not touch your files,
|
|
140
|
+
only history):
|
|
141
|
+
git init
|
|
142
|
+
git remote add origin https://github.com/org/repo.git
|
|
143
|
+
git fetch origin
|
|
144
|
+
git reset --mixed origin/main
|
|
145
|
+
Then run `git status` — anything it reports as an untracked/modified file is the real
|
|
146
|
+
divergence to resolve by hand before the plugin takes over.
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
That recipe attaches git history to the folder without touching a single file — `reset --mixed` only moves what HEAD and the index point at, never the working tree. `git status` afterward tells you the true story: files present locally but not in the remote show as untracked; files different between the two show as modified. You only need to actually resolve the divergence in the folders you listed in `paths` — everything else (`mikser.config.js`, `node_modules/`, `.env`, ...) can sit there showing as untracked forever; this plugin never touches it, so it never needs reconciling. After the one-time attach, the folder is an ordinary checkout and this code path never runs again.
|
|
150
|
+
|
|
151
|
+
**In practice this is now the common first-run path, not the rare one.** Because the checkout root is the whole working folder, and a working folder almost always already has `mikser.config.js`, `node_modules/`, etc. in it by the time this plugin loads, the "clone into an empty folder" case barely comes up — it's really only for a bare, freshly-provisioned directory that hasn't even been given a config yet. Every real project you point this at will hit `refuse` on its very first connect, and that's by design: the one-time manual step is where a human with actual context makes the one decision this plugin can't make safely.
|
|
152
|
+
|
|
153
|
+
If the folder is already a checkout of the configured repo, bootstrap just verifies and moves on, every restart, at no cost — the common case after the first connect.
|
|
154
|
+
|
|
155
|
+
Add a `.gitignore` in that same folder regardless of whether you set `paths` — it keeps `git status` itself readable for a human poking around either way. But its role differs by config: with `paths` set, the pathspec is the real enforcement and `.gitignore` is just hygiene on top; with `paths` omitted, `.gitignore` **is** the enforcement — there's no pathspec backing it up in that case. Use:
|
|
156
|
+
|
|
157
|
+
```gitignore
|
|
158
|
+
node_modules
|
|
159
|
+
runtime/
|
|
160
|
+
out/
|
|
161
|
+
.env
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
**Note the missing trailing slash on `node_modules`** — deliberately, not an oversight. A trailing-slash pattern (`node_modules/`) means "directories only" in git's own matching rules, and a *symlinked* `node_modules` (common under npm/pnpm/yarn workspaces) is not a directory by that definition even though it points at one — `node_modules/` silently fails to ignore it, `node_modules` (no slash) matches either shape. Confirmed directly: `git check-ignore -v node_modules` reported "not ignored" against a symlink with the trailing-slash form, and a live no-`paths` test genuinely leaked a workspace-symlinked `node_modules` into a real commit before this was caught. This is exactly the class of subtle miss that makes `paths` the stronger guarantee over `.gitignore` alone — the pathspec doesn't care what shape the excluded thing is.
|
|
165
|
+
|
|
166
|
+
## What counts as "green"
|
|
167
|
+
|
|
168
|
+
A cycle is green when no render or postprocess produced a failure (`output.success === false` on the journal entry — the same signal mikser's own manifest uses to decide whether to record a snapshot). It is **not** a correctness or schema check:
|
|
169
|
+
|
|
170
|
+
- A `warn`-mode schema violation still renders successfully — it commits. Warnings are deliberately not errors (see mikser's own `warnings-not-errors` posture); this plugin follows the same line.
|
|
171
|
+
- An entity with no matching layout produces no render task at all, hence no failure signal — it commits.
|
|
172
|
+
- A validation rejection in `fail` mode means the entity never entered the catalog in the first place (mikser's `runtime.validate` gates `createEntity`/`updateEntity` before the journal), so there's nothing on disk to commit for it either way.
|
|
173
|
+
|
|
174
|
+
The guarantee this plugin actually gives you is narrower and more honest than "only correct content ships": **nothing that failed to render or postprocess is ever committed.** A held-back change isn't lost — it's still sitting on disk, and it commits the moment a later cycle turns green, along with everything else that accumulated in the meantime. If your content graph has cross-references (a broken author page can break every post that references it), a single stale failure can hold back everything indefinitely — which is the intended behavior, not a bug: the plugin has no way to tell "the thing that broke" from "the thing that happens to be blocked by it," so it holds the whole batch until the build is clean again, and logs loudly the entire time it's held.
|
|
175
|
+
|
|
176
|
+
## Promotion mechanics
|
|
177
|
+
|
|
178
|
+
**GitHub / Gitea**: one open pull request from `writeBranch` into `branch`, reused across cycles (never spammed) — the plugin looks for an existing open PR with the right head/base before creating a new one. On a green cycle it attempts to merge that PR. Success: the write branch is fast-forwarded (well, hard-reset) onto the new target tip and re-pushed, so it never drifts arbitrarily far from `main` between promotions. Failure (conflict, or anything else): the PR stays open, nothing is discarded, and a warning names the reason and links the PR — re-logged at most every 30 minutes so a long-stuck conflict doesn't spam your logs on every debounce fire.
|
|
179
|
+
|
|
180
|
+
**`forge: 'none'`**: `git push mikser:main`, fast-forward only. No PR, no API. If it's rejected (main has diverged), the same holding behavior applies — everything queues on `mikser`, logged, until you merge by hand or switch on a forge adapter.
|
|
181
|
+
|
|
182
|
+
## Inbound sync
|
|
183
|
+
|
|
184
|
+
On a poll timer (default every 5 minutes, watch mode only — a one-shot build has no "later" to pull into), the plugin fetches and merges `origin/<writeBranch>` then `origin/<branch>` into the local working copy, in that order — note this fetch+merge is NOT scoped to `paths` (a merge operates on whole commits; there's no such thing as "merge just some files"). `git merge` writes files via ordinary filesystem writes, so mikser's own file watcher (chokidar, already watching whichever collection folders their own source plugins registered) sees the change exactly like a human editing a file — no explicit wake-up call needed. A merge could in principle also touch something outside `paths` (say, `main` gained a `mikser.config.js` change from a developer) — mikser doesn't hot-reload its own config, so that lands on disk but has no effect until the process restarts; nothing this plugin needs to solve.
|
|
185
|
+
|
|
186
|
+
**On any merge conflict, the merge is aborted immediately** — the working folder is mikser's live render source; leaving conflict-marker text (`<<<<<<< HEAD`) in a file would mean that text gets rendered as page content on the very next cycle. A conflict is logged with the branch it came from and the raw git error; resolve it by hand in the folder, the same way you'd resolve any git conflict.
|
|
187
|
+
|
|
188
|
+
**Webhook delivery is not implemented.** Polling is the only supported inbound trigger in this version. A webhook would mean verifying HMAC signatures correctly for two different forges (`X-Hub-Signature-256` for GitHub, a different scheme for Gitea) without a live instance of either to test against while building this — shipping that unverified would be a worse trade than an honest 5-minute default poll interval. If you need faster inbound turnaround, lower `pollInterval`; a real webhook is a plausible follow-up once it can be tested against a live forge.
|
|
189
|
+
|
|
190
|
+
## Why not the GitHub `/merges` / Gitea `merge-upstream` endpoints?
|
|
191
|
+
|
|
192
|
+
Both forges have *some* direct-merge concept, but they're not the same feature and not portable: GitHub's `/repos/:owner/:repo/merges` merges one branch into another with no review step; Gitea's `/repos/:owner/:repo/merge-upstream` syncs a fork from its own upstream — a different operation entirely, not a general branch merge. Building a "direct merge" abstraction over both would mean papering over a real semantic mismatch between forges.
|
|
193
|
+
|
|
194
|
+
Pull requests, by contrast, are nearly identical between the two — `POST .../pulls { title, head, base }` creates one on both GitHub and Gitea. And a PR gives you something the direct-merge endpoints don't: a real conflict surface. A `/merges` 409 is a status code with nowhere to look; a conflicted PR is a page that names the exact files in conflict and offers to resolve them in the forge's own UI. That's a better fit for "leave it open, let a human resolve it" than either forge's direct-merge shortcut.
|
|
195
|
+
|
|
196
|
+
## Security
|
|
197
|
+
|
|
198
|
+
- **The auth token is never written to disk.** It's passed as a one-off `http.extraheader` on the specific git command that needs it (`clone`/`fetch`/`push`), never embedded in the remote URL — an embedded `https://token@host/...` remote persists into `.git/config` in plaintext and leaks into `git remote -v` output and any log line that happens to echo the remote.
|
|
199
|
+
- **Every git invocation goes through `execFile` with an argv array — never a shell string.** Commit messages are built from a file count, not raw content, but nothing here ever risks passing arbitrary content through a shell regardless.
|
|
200
|
+
- **The write branch is force-pushed only after a successful promotion**, using `--force-with-lease` (refuses if the remote moved unexpectedly since the last fetch) rather than a bare `--force`. This is safe specifically because `mikser`/`writeBranch` is a branch this plugin owns exclusively — nothing else's history is ever at risk on it.
|
|
201
|
+
|
|
202
|
+
## Verified end-to-end
|
|
203
|
+
|
|
204
|
+
The unit suite (59 tests) covers every pure module directly, the forge adapters via an injected `fetchImpl` mock, and — critically for the working-folder-as-checkout model — the pathspec scope itself against a real temp git repo (`test/git.test.js`'s "pathspec scoping" suite: a config-file edit and a whole new out-of-scope directory are both proven invisible to a `paths`-scoped add, while an in-scope file commits normally). Beyond the unit suite, this has been run against **real GitHub repos and mikser's own example blog** — not just mocks:
|
|
205
|
+
|
|
206
|
+
- **The "adopt an existing non-empty folder" recipe** (see [First connect](#first-connect--and-why-it-can-refuse-to-guess)), run by hand exactly as documented, against the real blog's working folder — `mikser.config.js`, `node_modules/`, `layouts/`, `documents/`, everything — and a fresh throwaway GitHub repo. `refuse` fired correctly on the very first connect attempt (confirming that's now the expected first-run path, not an edge case); the manual recipe attached history without touching a file; `git status` afterward showed exactly the expected divergence.
|
|
207
|
+
- **The `paths` scope boundary, live, not just unit-tested.** With `paths: ['documents', 'layouts']` on that same checkout: a build with only a `mikser.config.js`/`LICENSE` edit produced **zero commits** — no `git: committed + pushed` line at all; a build with a real `layouts/` edit committed, pushed, and promoted normally. A fresh clone of the repo afterward showed the tree contained **only** `documents/`, `layouts/`, and the seed file — no `mikser.config.js`, no `node_modules`, nothing leaked from outside `paths`.
|
|
208
|
+
- **The full real render pipeline** — the actual `mikser-io-example-blog` config: 13 lifecycle plugins, real layouts, a real CSV fetch from a live Google Sheet, real OpenAI vector embeddings, 30 renders, zero warnings or failures. A genuinely green cycle, not an empty test harness.
|
|
209
|
+
- **Real GitHub pull requests, created and merged by the actual API** — not a mocked response, across two separate throwaway repos (one per major design iteration). Multiple full sync cycles each produced their own PR (`Promote mikser → main`), all auto-merged; `gh api` confirmed `main` and `mikser` converged on the identical commit SHA after each merge, and repeated cycles proved the "reuse an open PR, don't spam a new one" / "open a fresh PR once the last one closed" logic works correctly across repeated promotions — including with multiple collections landing in the same commit.
|
|
210
|
+
- **One real bug found and fixed this way** (v1.0.1): `git status --porcelain` collapses a brand-new untracked directory into a single line instead of one per file, so the commit message's file count silently undercounted whenever a change arrived as a new directory (a new author's folder, a new content category). Confirmed directly — a real 2-file new directory produced `"content: 1 file(s)"` before the fix — and fixed with `--untracked-files=all`. `git add -A` itself was never affected; only the message text was wrong.
|
|
211
|
+
- **The zero-config default (`paths` omitted), live.** The same checkout, reconfigured with no `paths` at all, correctly committed the whole working folder — `mikser.config.js`, `package.json`, `authors.csv`, everything not gitignored — on the very next sync, exactly matching the documented behavior. It also surfaced a real, honest-to-document gotcha: the scratch test's `node_modules` was a workspace symlink, and `.gitignore`'s trailing-slash `node_modules/` pattern does **not** match a symlink even when it points at a real directory (confirmed with `git check-ignore -v`) — so it leaked into that commit. Not a plugin bug (`git add -A` correctly respected `.gitignore`'s actual, documented semantics); the README's recommended `.gitignore` now drops the trailing slash for exactly this reason, and this is precisely the class of miss `paths` (a hard pathspec, indifferent to symlink-vs-directory) doesn't have.
|
|
212
|
+
|
|
213
|
+
**What this has NOT been run against**, stated plainly rather than assumed: a genuine render/postprocess *failure* mid-flow (the red-cycle hold-back path is covered by the debounce reducer's unit tests and direct tracing against mikser's `output.success` signal, not a live failing build), Gitea (the adapter is unit-tested against mocked responses shaped from Gitea's own route source, not a live instance), and a real merge *conflict* (every live test cycle was a clean fast-forward on the forge side — no divergent `main` to force a genuine PR conflict).
|
|
214
|
+
|
|
215
|
+
## What this plugin does NOT do
|
|
216
|
+
|
|
217
|
+
- **Resolve conflicts.** Ever. A promotion conflict leaves an open PR; an inbound conflict aborts and logs. A human resolves both, always.
|
|
218
|
+
- **Distinguish API/MCP writes from human edits.** It can't — by the time a write reaches the journal, it's an ordinary file change indistinguishable from a local save, and this holds for any collection you list in `paths`, not just documents. Disable this plugin in your dev config if you don't want your own WIP edits auto-committed; it's designed to be enabled only where every write to the paths you configured is already programmatic (a deployed CMS instance), not where a human is also editing files directly.
|
|
219
|
+
- **Webhook-triggered inbound sync.** See above — polling only, for now.
|
|
220
|
+
- **Validate content quality.** "Green" means "rendered without failing," not "correct." Schema warnings, missing-layout entities, and anything else that doesn't produce a render/postprocess failure all commit normally.
|
|
221
|
+
- **Squash or rebase history.** Every promotion is a plain merge (or fast-forward); the write branch's commit history is preserved as-is in the merge commit's ancestry.
|
|
222
|
+
|
|
223
|
+
## License
|
|
224
|
+
|
|
225
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// mikser-io-git — two-way git sync for mikser-io.
|
|
2
|
+
//
|
|
3
|
+
// Model: the WORKING FOLDER itself is the checkout, on a dedicated
|
|
4
|
+
// write branch (default `mikser`) that this plugin owns exclusively.
|
|
5
|
+
// `paths` (relative to the working folder) names which collections'
|
|
6
|
+
// folders — documents, layouts, files, whatever an API/MCP call can
|
|
7
|
+
// reach — this plugin is allowed to commit; everything else in the
|
|
8
|
+
// working folder (mikser.config.js, node_modules/, runtime/, out/,
|
|
9
|
+
// .env) sits in the SAME checkout untouched, because every git
|
|
10
|
+
// operation is pathspec-scoped to exactly those folders (see
|
|
11
|
+
// lib/git.js's pathspecArgs / lib/sync.js). One checkout, one branch,
|
|
12
|
+
// any number of auto-committed collections sharing both — no more
|
|
13
|
+
// juggling a separate repo (or at least a separate branch name) per
|
|
14
|
+
// folder the way an earlier, per-folder-checkout design would have
|
|
15
|
+
// required.
|
|
16
|
+
//
|
|
17
|
+
// Every green cycle (no render/postprocess failure) commits + pushes
|
|
18
|
+
// whatever changed inside `paths` to the write branch — unconditionally,
|
|
19
|
+
// so an agent's work is never lost to a crash or a later red cycle —
|
|
20
|
+
// then tries to promote it into the target branch (default `main`) via
|
|
21
|
+
// a pull request (GitHub/Gitea) or a direct fast-forward push
|
|
22
|
+
// (`forge: 'none'`, any bare remote). A red cycle holds the promotion
|
|
23
|
+
// but the write-branch commit still happens on the next green cycle
|
|
24
|
+
// once the queued changes accumulate on disk — nothing here tracks a
|
|
25
|
+
// pending set separately from the filesystem itself. A promotion
|
|
26
|
+
// conflict just leaves the PR open; this plugin never resolves a
|
|
27
|
+
// conflict or picks a winner.
|
|
28
|
+
//
|
|
29
|
+
// Inbound: remote changes on either branch are pulled in on a poll
|
|
30
|
+
// timer (webhook delivery is NOT implemented — see README) and merged
|
|
31
|
+
// into the local write branch; a merge conflict aborts immediately so
|
|
32
|
+
// the working folder — mikser's live render source — never holds
|
|
33
|
+
// conflict-marker text as page content.
|
|
34
|
+
//
|
|
35
|
+
// First-connect safety: if the working folder already has files but
|
|
36
|
+
// isn't a git checkout, the plugin refuses to guess whether local or
|
|
37
|
+
// remote content should win (either default silently discards the
|
|
38
|
+
// other side on the very next sync) and logs the one-time manual
|
|
39
|
+
// recipe to attach history without touching a single file. In
|
|
40
|
+
// practice this is the common first-run path — the working folder
|
|
41
|
+
// almost always already has mikser.config.js, node_modules/, etc. in
|
|
42
|
+
// it by the time this plugin loads, so "clone into an empty folder"
|
|
43
|
+
// is the rare case, not the default one.
|
|
44
|
+
//
|
|
45
|
+
// This is meant for a deployment target this plugin (and mikser)
|
|
46
|
+
// exclusively manages — a server checkout, not a developer's actively-
|
|
47
|
+
// edited local clone. It checks out and holds the write branch in the
|
|
48
|
+
// ENTIRE working folder; running it against someone's local dev
|
|
49
|
+
// checkout would switch their active branch out from under them.
|
|
50
|
+
|
|
51
|
+
import { resolveConfig } from './lib/config.js'
|
|
52
|
+
import { gatherFolderState, decideBootstrap, performClone, performVerify } from './lib/bootstrap.js'
|
|
53
|
+
import { commitAndPushWriteBranch, promote } from './lib/sync.js'
|
|
54
|
+
import { pullInbound } from './lib/inbound.js'
|
|
55
|
+
import { reduceDebounce, IDLE_DEBOUNCE_STATE } from './lib/debounce.js'
|
|
56
|
+
|
|
57
|
+
const REANNOUNCE_MS = 30 * 60 * 1000 // re-log a still-open conflict at most every 30 min
|
|
58
|
+
|
|
59
|
+
export function git(options = {}) {
|
|
60
|
+
const {
|
|
61
|
+
url, paths, forge, targetBranch, writeBranch,
|
|
62
|
+
token, message, author, afterMs, maxWaitMs, pollIntervalMs,
|
|
63
|
+
owner, repo, apiBase,
|
|
64
|
+
} = resolveConfig(options)
|
|
65
|
+
|
|
66
|
+
return ({ runtime, onLoaded, onFinalize, useLogger, useJournal, constants: { OPERATION } }) => {
|
|
67
|
+
// The checkout root is ALWAYS the working folder — there's no
|
|
68
|
+
// per-instance subfolder checkout anymore. `paths` is what
|
|
69
|
+
// scopes this instance's reach within it.
|
|
70
|
+
const folder = runtime.options.workingFolder
|
|
71
|
+
|
|
72
|
+
let debounceState = IDLE_DEBOUNCE_STATE
|
|
73
|
+
let timer = null
|
|
74
|
+
let pollTimer = null
|
|
75
|
+
let lastPromoteFailureReason = null
|
|
76
|
+
let lastPromoteFailureLoggedAt = 0
|
|
77
|
+
let inert = false // set true on a bootstrap refusal; the plugin stops touching the folder for the rest of this process
|
|
78
|
+
|
|
79
|
+
async function runSyncPass(logger) {
|
|
80
|
+
debounceState = IDLE_DEBOUNCE_STATE
|
|
81
|
+
try {
|
|
82
|
+
const { committed } = await commitAndPushWriteBranch(folder, { paths, writeBranch, message, author, token })
|
|
83
|
+
if (!committed) return
|
|
84
|
+
logger.info('git: committed + pushed to %s', writeBranch)
|
|
85
|
+
|
|
86
|
+
const result = await promote(folder, {
|
|
87
|
+
forge, targetBranch, writeBranch, token, owner, repo, apiBase,
|
|
88
|
+
prTitle: `Promote ${writeBranch} → ${targetBranch}`,
|
|
89
|
+
})
|
|
90
|
+
if (result.promoted) {
|
|
91
|
+
logger.info('git: promoted %s → %s', writeBranch, targetBranch)
|
|
92
|
+
lastPromoteFailureReason = null
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
const changed = result.reason !== lastPromoteFailureReason
|
|
96
|
+
const dueToReannounce = Date.now() - lastPromoteFailureLoggedAt > REANNOUNCE_MS
|
|
97
|
+
if (changed || dueToReannounce) {
|
|
98
|
+
logger.warn(
|
|
99
|
+
'git: could not promote %s → %s — %s%s',
|
|
100
|
+
writeBranch, targetBranch, result.reason,
|
|
101
|
+
result.prUrl ? ` (${result.prUrl})` : '',
|
|
102
|
+
)
|
|
103
|
+
lastPromoteFailureReason = result.reason
|
|
104
|
+
lastPromoteFailureLoggedAt = Date.now()
|
|
105
|
+
}
|
|
106
|
+
} catch (err) {
|
|
107
|
+
logger.error('git: sync pass failed — %s', err.stderr || err.message)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function scheduleFire(logger) {
|
|
112
|
+
if (timer) clearTimeout(timer)
|
|
113
|
+
const delay = Math.max(0, debounceState.fireAt - Date.now())
|
|
114
|
+
timer = setTimeout(() => runSyncPass(logger), delay)
|
|
115
|
+
timer.unref?.()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
onLoaded(async () => {
|
|
119
|
+
const logger = useLogger()
|
|
120
|
+
const state = await gatherFolderState(folder, url)
|
|
121
|
+
const decision = decideBootstrap({ ...state, expectedUrl: url })
|
|
122
|
+
|
|
123
|
+
if (decision.action === 'refuse') {
|
|
124
|
+
logger.error('git: %s', decision.reason)
|
|
125
|
+
inert = true
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
if (decision.action === 'clone') {
|
|
130
|
+
logger.info('git: %s Cloning %s into %s.', decision.reason, url, folder)
|
|
131
|
+
await performClone(folder, { url, token, targetBranch, writeBranch })
|
|
132
|
+
} else {
|
|
133
|
+
await performVerify(folder, { token, targetBranch, writeBranch })
|
|
134
|
+
}
|
|
135
|
+
} catch (err) {
|
|
136
|
+
logger.error('git: bootstrap failed — %s', err.stderr || err.message)
|
|
137
|
+
inert = true
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
logger.info(
|
|
141
|
+
'git: working folder ready — %s on branch %s (auto-committing %s, promotes to %s via %s)',
|
|
142
|
+
folder, writeBranch,
|
|
143
|
+
paths ? `[${paths.join(', ')}]` : 'the WHOLE working folder (no `paths` set — relying on .gitignore)',
|
|
144
|
+
targetBranch, forge,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
// Inbound polling — watch mode only; a one-shot build has no
|
|
148
|
+
// "later" to pull into. Webhook delivery is not implemented
|
|
149
|
+
// (see README); poll is the only supported inbound trigger.
|
|
150
|
+
if (runtime.options.watch && pollIntervalMs > 0) {
|
|
151
|
+
pollTimer = setInterval(async () => {
|
|
152
|
+
if (inert) return
|
|
153
|
+
await pullInbound(folder, { writeBranch, targetBranch, token, logger })
|
|
154
|
+
}, pollIntervalMs)
|
|
155
|
+
pollTimer.unref?.()
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
onFinalize(async (signal) => {
|
|
160
|
+
if (inert) return
|
|
161
|
+
const logger = useLogger()
|
|
162
|
+
|
|
163
|
+
const culprits = []
|
|
164
|
+
for await (const { entity, output } of useJournal(
|
|
165
|
+
'Git green-check',
|
|
166
|
+
[OPERATION.RENDER, OPERATION.POSTPROCESS],
|
|
167
|
+
signal,
|
|
168
|
+
)) {
|
|
169
|
+
if (signal.aborted) return
|
|
170
|
+
if (output?.success === false) culprits.push(entity?.id ?? '(unknown)')
|
|
171
|
+
}
|
|
172
|
+
const red = culprits.length > 0
|
|
173
|
+
const now = Date.now()
|
|
174
|
+
|
|
175
|
+
if (!runtime.options.watch) {
|
|
176
|
+
// One-shot build: there is no "next cycle" to debounce
|
|
177
|
+
// against, so a green build syncs immediately and a red
|
|
178
|
+
// one just logs and exits without touching git.
|
|
179
|
+
if (red) {
|
|
180
|
+
logger.warn('git: build had failures (%s) — not syncing', culprits.join(', '))
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
await runSyncPass(logger)
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (red) {
|
|
188
|
+
logger.warn('git: cycle had failures (%s) — holding sync this round', culprits.join(', '))
|
|
189
|
+
debounceState = reduceDebounce(debounceState, { type: 'red', now }, { afterMs, maxWaitMs })
|
|
190
|
+
if (timer) { clearTimeout(timer); timer = null }
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
debounceState = reduceDebounce(debounceState, { type: 'green', now }, { afterMs, maxWaitMs })
|
|
194
|
+
scheduleFire(logger)
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
}
|
package/lib/bootstrap.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// First-connect decision logic for the working folder — the checkout
|
|
2
|
+
// root this plugin manages (see index.js: `folder` is always
|
|
3
|
+
// `runtime.options.workingFolder` now, not a per-collection subfolder).
|
|
4
|
+
//
|
|
5
|
+
// The one genuinely destructive moment in this plugin: attaching git to
|
|
6
|
+
// a folder that already has files but isn't a checkout. Neither "local
|
|
7
|
+
// wins" nor "remote wins" is a safe default — either could silently
|
|
8
|
+
// discard real content (whichever side loses gets overwritten on the
|
|
9
|
+
// very next sync). So the default is REFUSE: report what was found,
|
|
10
|
+
// change nothing, and point at the one-time manual recipe that attaches
|
|
11
|
+
// history without touching a single file. After that one-time step the
|
|
12
|
+
// folder is an ordinary checkout and this code never runs again.
|
|
13
|
+
//
|
|
14
|
+
// Because `folder` is the working folder, it will almost always
|
|
15
|
+
// already be non-empty (mikser.config.js, node_modules/, .env, ...) —
|
|
16
|
+
// so this REFUSE path is the common first-run case for a project
|
|
17
|
+
// that's never been under git before, not a rare edge case. The
|
|
18
|
+
// 'clone' path (folder absent or empty) only fires for something like
|
|
19
|
+
// a bare, freshly-provisioned directory that hasn't even been given a
|
|
20
|
+
// mikser.config.js yet.
|
|
21
|
+
//
|
|
22
|
+
// decideBootstrap is pure — plain booleans/strings in, a decision out —
|
|
23
|
+
// so the "what happens in every folder state" matrix is unit-testable
|
|
24
|
+
// without a filesystem or git binary. gatherFolderState/performBootstrap
|
|
25
|
+
// are the impure edges that feed it real values and act on its decision.
|
|
26
|
+
|
|
27
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
28
|
+
import * as git from './git.js'
|
|
29
|
+
|
|
30
|
+
// Normalize a git remote URL for comparison: strip protocol, trailing
|
|
31
|
+
// `.git`, and trailing slash, lowercase. Good enough to catch
|
|
32
|
+
// `https://github.com/org/repo` vs `https://github.com/org/repo.git`
|
|
33
|
+
// vs a trailing-slash variant — NOT a full SSH-vs-HTTPS equivalence
|
|
34
|
+
// check (e.g. `git@github.com:org/repo` vs `https://github.com/org/repo`
|
|
35
|
+
// won't match). Exact remote-string agreement in config is the
|
|
36
|
+
// supported path; this only smooths the most common cosmetic variants.
|
|
37
|
+
function normalizeRemote(url) {
|
|
38
|
+
if (!url) return url
|
|
39
|
+
return url
|
|
40
|
+
.trim()
|
|
41
|
+
.toLowerCase()
|
|
42
|
+
.replace(/\.git\/?$/, '')
|
|
43
|
+
.replace(/\/$/, '')
|
|
44
|
+
.replace(/^[a-z]+:\/\//, '')
|
|
45
|
+
.replace(/^[^@]+@/, '') // strip a userinfo@ prefix if present
|
|
46
|
+
.replace(':', '/') // git@host:org/repo → host/org/repo
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function urlsEquivalent(a, b) {
|
|
50
|
+
return normalizeRemote(a) === normalizeRemote(b)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Pure decision. Inputs describe the folder + repo state as already-
|
|
54
|
+
// observed facts; output is one of:
|
|
55
|
+
// { action: 'clone', reason } — folder absent or empty; safe to clone into
|
|
56
|
+
// { action: 'verify', reason } — already a checkout of the right remote
|
|
57
|
+
// { action: 'refuse', reason } — anything ambiguous; do nothing
|
|
58
|
+
export function decideBootstrap({ folderExists, folderEmpty, isRepo, remoteUrl, expectedUrl }) {
|
|
59
|
+
if (isRepo) {
|
|
60
|
+
if (!remoteUrl) {
|
|
61
|
+
return {
|
|
62
|
+
action: 'refuse',
|
|
63
|
+
reason: 'Working folder is already a git repository but has no "origin" remote configured. ' +
|
|
64
|
+
'Wire it by hand (`git remote add origin <url>`) so it matches the configured `url`.',
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (!urlsEquivalent(remoteUrl, expectedUrl)) {
|
|
68
|
+
return {
|
|
69
|
+
action: 'refuse',
|
|
70
|
+
reason: `Working folder's origin remote (${remoteUrl}) does not match the configured repo ` +
|
|
71
|
+
`(${expectedUrl}). Refusing to touch it — update the config or the remote so they agree.`,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { action: 'verify', reason: 'Working folder is already a checkout of the configured repo.' }
|
|
75
|
+
}
|
|
76
|
+
if (!folderExists || folderEmpty) {
|
|
77
|
+
return {
|
|
78
|
+
action: 'clone',
|
|
79
|
+
reason: folderExists ? 'Working folder exists and is empty.' : 'Working folder does not exist yet.',
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
action: 'refuse',
|
|
84
|
+
reason: 'Working folder exists, has files, and is not a git repository. Refusing to guess whether ' +
|
|
85
|
+
'local files or the remote should win — whichever loses gets silently overwritten on the next ' +
|
|
86
|
+
'sync. Attach it by hand ONCE, from the working folder itself (this does not touch your files, ' +
|
|
87
|
+
'only history):\n' +
|
|
88
|
+
' git init\n' +
|
|
89
|
+
' git remote add origin <url>\n' +
|
|
90
|
+
' git fetch origin\n' +
|
|
91
|
+
' git reset --mixed origin/<branch>\n' +
|
|
92
|
+
'Then run `git status` — anything it reports as an untracked/modified file is the real ' +
|
|
93
|
+
'divergence to resolve by hand before the plugin takes over.',
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Gather the observed facts for decideBootstrap from the real folder.
|
|
98
|
+
export async function gatherFolderState(folder, expectedUrl) {
|
|
99
|
+
const folderExists = existsSync(folder)
|
|
100
|
+
const folderEmpty = folderExists ? readdirSync(folder).length === 0 : true
|
|
101
|
+
const isRepo = folderExists && !folderEmpty && await git.isInsideWorkTree(folder)
|
|
102
|
+
const remoteUrl = isRepo ? await git.remoteUrl(folder) : null
|
|
103
|
+
return { folderExists, folderEmpty, isRepo, remoteUrl, expectedUrl }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Act on a 'clone' decision: clone the repo, then ensure `writeBranch`
|
|
107
|
+
// exists locally (creating it from the freshly-cloned default branch
|
|
108
|
+
// and pushing it upstream if the remote doesn't have it yet either —
|
|
109
|
+
// the very first run on a brand-new content repo).
|
|
110
|
+
export async function performClone(folder, { url, token, targetBranch, writeBranch }) {
|
|
111
|
+
await git.clone(url, folder, { token })
|
|
112
|
+
await ensureWriteBranch(folder, { token, targetBranch, writeBranch })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Act on a 'verify' decision: fetch, then ensure writeBranch exists and
|
|
116
|
+
// is checked out. Cheap and idempotent — safe to call on every restart.
|
|
117
|
+
export async function performVerify(folder, { token, targetBranch, writeBranch }) {
|
|
118
|
+
await git.fetch(folder, { token })
|
|
119
|
+
await ensureWriteBranch(folder, { token, targetBranch, writeBranch })
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Ensure the write branch exists (locally and on the remote) and is
|
|
123
|
+
// the currently checked-out branch. Three cases:
|
|
124
|
+
// - already checked out locally → nothing to do
|
|
125
|
+
// - exists locally, not checked out → check it out
|
|
126
|
+
// - exists on the remote only → check it out tracking origin/<branch>
|
|
127
|
+
// - exists nowhere → branch it from the current HEAD (the target
|
|
128
|
+
// branch's tip on a fresh clone) and push it upstream immediately,
|
|
129
|
+
// so `origin/<writeBranch>` exists from the very first cycle.
|
|
130
|
+
async function ensureWriteBranch(folder, { token, targetBranch, writeBranch }) {
|
|
131
|
+
const current = await git.currentBranch(folder).catch(() => null)
|
|
132
|
+
if (current === writeBranch) return
|
|
133
|
+
|
|
134
|
+
if (await git.branchExistsLocal(folder, writeBranch)) {
|
|
135
|
+
await git.checkoutBranch(folder, writeBranch)
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
if (await git.branchExistsRemote(folder, writeBranch, { token })) {
|
|
139
|
+
await git.checkoutBranch(folder, writeBranch, { create: true, startPoint: `origin/${writeBranch}` })
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
// Brand new: branch from wherever HEAD is (the target branch on a
|
|
143
|
+
// fresh clone) and publish it so the remote has it too.
|
|
144
|
+
await git.checkoutBranch(folder, writeBranch, { create: true })
|
|
145
|
+
await git.push(folder, `HEAD:${writeBranch}`, { token })
|
|
146
|
+
}
|