mikser-io-git 2.1.0 → 2.3.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/README.md +68 -2
- package/index.js +54 -5
- package/lib/changeset-commit.js +59 -0
- package/lib/git.js +102 -15
- package/lib/inbound.js +36 -2
- package/lib/mcp.js +141 -0
- package/lib/queue.js +24 -0
- package/lib/sync.js +60 -5
- package/lib/undo.js +147 -0
- package/package.json +9 -2
- package/test/bootstrap.test.js +0 -59
- package/test/config.test.js +0 -101
- package/test/debounce.test.js +0 -57
- package/test/duration.test.js +0 -32
- package/test/forge/gitea.test.js +0 -91
- package/test/forge/github.test.js +0 -70
- package/test/git.test.js +0 -220
- package/test/repo-url.test.js +0 -35
package/README.md
CHANGED
|
@@ -37,6 +37,59 @@ mikser's API and MCP endpoints can write to **any registered collection**, not j
|
|
|
37
37
|
|
|
38
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
39
|
|
|
40
|
+
## Undo (MCP only)
|
|
41
|
+
|
|
42
|
+
An agent's request is one **change set**: however many files it wrote, committed
|
|
43
|
+
together, stamped with a `Mikser-Change-Set` trailer and the agent's own summary
|
|
44
|
+
as the subject. `mikser_changes` lists them; `mikser_undo` takes one back.
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
mikser_changes → recent change sets, newest first
|
|
48
|
+
mikser_undo({ id, dryRun: true }) → what it would do, touching nothing
|
|
49
|
+
mikser_undo({ id, dryRun: false }) → apply it
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**Only agent writes are undoable.** API writes and human edits are committed
|
|
53
|
+
too — the durable log never drops anything — but into unattributed sweep
|
|
54
|
+
commits with no trailer, and the trailer is the permission boundary. Those
|
|
55
|
+
callers have git; an undo they could reach would be an undo able to remove work
|
|
56
|
+
it never made.
|
|
57
|
+
|
|
58
|
+
**It removes a contribution, it does not restore a snapshot.** Documents added
|
|
59
|
+
through the API since, and edits made by hand, are kept. The undo lands as an
|
|
60
|
+
ordinary forward commit, so history is never rewritten, the deploy branch is
|
|
61
|
+
never force-pushed, and the undo is itself an undoable change set.
|
|
62
|
+
|
|
63
|
+
Two independent things stop an undo, and they need different answers:
|
|
64
|
+
|
|
65
|
+
| | means |
|
|
66
|
+
| --- | --- |
|
|
67
|
+
| `conflict` | a later change edited the same content — it cannot be applied automatically, and no `force` overrides it |
|
|
68
|
+
| `dangling` | something added since references what this undo would REMOVE |
|
|
69
|
+
|
|
70
|
+
The second is the dangerous one. Git applies it perfectly cleanly and the site
|
|
71
|
+
breaks anyway, because the new document points at a layout that no longer
|
|
72
|
+
exists — so the check runs against the engine's reference index rather than
|
|
73
|
+
against the patch. `force: true` proceeds regardless; nothing bypasses a
|
|
74
|
+
conflict.
|
|
75
|
+
|
|
76
|
+
A dry run and a refusal never touch the working folder. The patch is tested
|
|
77
|
+
with `git apply --check` against a copy in the system temp dir, because a
|
|
78
|
+
half-applied revert in a deployed checkout means the build stops — an undo that
|
|
79
|
+
takes the site down is worse than the change it was undoing.
|
|
80
|
+
|
|
81
|
+
### Why the commit scope matters
|
|
82
|
+
|
|
83
|
+
Staging by folder is what the durable-log sweep does, and it is wrong for a
|
|
84
|
+
change set. If an agent edits one document while a second is created through
|
|
85
|
+
the API a moment later, a folder-scoped `git add` puts both in the agent's
|
|
86
|
+
commit — and undoing the agent then deletes the API's document. Change-set
|
|
87
|
+
commits stage exactly the paths that set wrote, so the commit's contents match
|
|
88
|
+
its label.
|
|
89
|
+
|
|
90
|
+
Requires the `mcp` plugin. Without it the tools are not registered and the
|
|
91
|
+
plugin behaves exactly as before.
|
|
92
|
+
|
|
40
93
|
## Install
|
|
41
94
|
|
|
42
95
|
```bash
|
|
@@ -193,15 +246,28 @@ Both forges have *some* direct-merge concept, but they're not the same feature a
|
|
|
193
246
|
|
|
194
247
|
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
248
|
|
|
249
|
+
## Requirements
|
|
250
|
+
|
|
251
|
+
- **git 2.31 or newer** on the machine running mikser. The auth token is
|
|
252
|
+
delivered to git through `GIT_CONFIG_COUNT` / `GIT_CONFIG_KEY_n` /
|
|
253
|
+
`GIT_CONFIG_VALUE_n`, which older git ignores — it would fall back to
|
|
254
|
+
unauthenticated access and fail against a private repo. 2.31 shipped in
|
|
255
|
+
March 2021; `git --version` if unsure.
|
|
256
|
+
- No runtime npm dependencies. Everything goes through the `git` binary.
|
|
257
|
+
|
|
196
258
|
## Security
|
|
197
259
|
|
|
198
|
-
- **The auth token is never written to disk.** It's passed as a one-off `http.extraheader`
|
|
260
|
+
- **The auth token is never written to disk, and never appears in the process's arguments.** It's passed as a one-off `http.extraheader` for the specific git command that needs it (`clone`/`fetch`/`push`/`ls-remote`), delivered through the environment (`GIT_CONFIG_COUNT` / `GIT_CONFIG_KEY_0` / `GIT_CONFIG_VALUE_0`, git 2.31+) rather than as a `-c` argument, and never embedded in the remote URL.
|
|
261
|
+
|
|
262
|
+
Three places a credential can leak, and what each avoids: an embedded `https://token@host/...` remote persists into `.git/config` in plaintext and shows up in `git remote -v` and any log line echoing the remote; a `-c http.extraheader=...` argument lands in the process's argument list, which is world-readable on Linux (`/proc/<pid>/cmdline` is `-r--r--r--`, while `/proc/<pid>/environ` is `-r--------`) and can surface in an `err.stderr` a caller then logs; the environment form has neither property and the same one-command lifetime. Note that base64 here is encoding, not encryption — it is the HTTP Basic wire format, so keeping it out of world-readable places is the whole protection.
|
|
199
263
|
- **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.
|
|
264
|
+
- **A timer body can never end the process.** Both schedulers here — the debounced sync pass and the inbound poll — run for the life of a watch server, and Node has treated an unhandled rejection as fatal since v15 while mikser installs no process-level handler. `pullInbound` returns a result shape for every outcome including a failed `fetch`, and both callbacks are wrapped besides: a transient remote failure must not take down the build and the site, least of all in a supervisor restart loop.
|
|
265
|
+
- **Git operations are serialised per instance.** The sync pass and the inbound poll share one checkout; without a queue they overlap and lose to `index.lock`, or worse, commit while an inbound merge is in progress.
|
|
200
266
|
- **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
267
|
|
|
202
268
|
## Verified end-to-end
|
|
203
269
|
|
|
204
|
-
The unit suite (
|
|
270
|
+
The unit suite (73 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
271
|
|
|
206
272
|
- **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
273
|
- **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`.
|
package/index.js
CHANGED
|
@@ -53,9 +53,27 @@ import { gatherFolderState, decideBootstrap, performClone, performVerify } from
|
|
|
53
53
|
import { commitAndPushWriteBranch, promote } from './lib/sync.js'
|
|
54
54
|
import { pullInbound } from './lib/inbound.js'
|
|
55
55
|
import { reduceDebounce, IDLE_DEBOUNCE_STATE } from './lib/debounce.js'
|
|
56
|
+
import { createGitQueue } from './lib/queue.js'
|
|
56
57
|
|
|
57
58
|
const REANNOUNCE_MS = 30 * 60 * 1000 // re-log a still-open conflict at most every 30 min
|
|
58
59
|
|
|
60
|
+
// Run a timer body so that nothing it does can end the process.
|
|
61
|
+
//
|
|
62
|
+
// Both schedulers here fire for the life of a watch server, and an async
|
|
63
|
+
// callback that rejects is fatal — Node has treated an unhandled rejection
|
|
64
|
+
// as process-ending since v15, and mikser core installs no handler. The
|
|
65
|
+
// failure mode this prevents is a transient remote error taking down the
|
|
66
|
+
// build AND the site, and looping under a supervisor.
|
|
67
|
+
function withGuard(logger, what, fn) {
|
|
68
|
+
Promise.resolve()
|
|
69
|
+
.then(fn)
|
|
70
|
+
.catch(err => logger?.error('git: %s failed — %s', what, err?.stderr || err?.message || err))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
import { pendingChangeSets, clearChangeSets } from 'mikser-io'
|
|
75
|
+
import { registerUndoTools } from './lib/mcp.js'
|
|
76
|
+
|
|
59
77
|
export function git(options = {}) {
|
|
60
78
|
const {
|
|
61
79
|
url, paths, forge, targetBranch, writeBranch,
|
|
@@ -69,6 +87,7 @@ export function git(options = {}) {
|
|
|
69
87
|
// scopes this instance's reach within it.
|
|
70
88
|
const folder = runtime.options.workingFolder
|
|
71
89
|
|
|
90
|
+
const enqueueGit = createGitQueue()
|
|
72
91
|
let debounceState = IDLE_DEBOUNCE_STATE
|
|
73
92
|
let timer = null
|
|
74
93
|
let pollTimer = null
|
|
@@ -79,7 +98,17 @@ export function git(options = {}) {
|
|
|
79
98
|
async function runSyncPass(logger) {
|
|
80
99
|
debounceState = IDLE_DEBOUNCE_STATE
|
|
81
100
|
try {
|
|
82
|
-
|
|
101
|
+
// Claimed sets are drained only once their paths are actually
|
|
102
|
+
// committed. A set whose commit throws stays pending and is
|
|
103
|
+
// retried next pass rather than silently losing attribution.
|
|
104
|
+
const claimed = pendingChangeSets()
|
|
105
|
+
const consumed = []
|
|
106
|
+
const { committed } = await commitAndPushWriteBranch(folder, {
|
|
107
|
+
paths, writeBranch, message, author, token,
|
|
108
|
+
changeSets: claimed,
|
|
109
|
+
onCommitted: (id) => consumed.push(id),
|
|
110
|
+
})
|
|
111
|
+
clearChangeSets(consumed)
|
|
83
112
|
if (!committed) return
|
|
84
113
|
logger.info('git: committed + pushed to %s', writeBranch)
|
|
85
114
|
|
|
@@ -111,7 +140,7 @@ export function git(options = {}) {
|
|
|
111
140
|
function scheduleFire(logger) {
|
|
112
141
|
if (timer) clearTimeout(timer)
|
|
113
142
|
const delay = Math.max(0, debounceState.fireAt - Date.now())
|
|
114
|
-
timer = setTimeout(() => runSyncPass(logger), delay)
|
|
143
|
+
timer = setTimeout(() => withGuard(logger, 'sync pass', () => enqueueGit(() => runSyncPass(logger))), delay)
|
|
115
144
|
timer.unref?.()
|
|
116
145
|
}
|
|
117
146
|
|
|
@@ -144,13 +173,33 @@ export function git(options = {}) {
|
|
|
144
173
|
targetBranch, forge,
|
|
145
174
|
)
|
|
146
175
|
|
|
176
|
+
// Undo is an MCP-only surface. API and human writes are not
|
|
177
|
+
// attributed and are deliberately not undoable — those callers
|
|
178
|
+
// have git, and an undo they could reach would be an undo that
|
|
179
|
+
// could remove someone else's work.
|
|
180
|
+
if (runtime.options.mcp) {
|
|
181
|
+
registerUndoTools(runtime.options.mcp, {
|
|
182
|
+
folder, writeBranch, runtime, useLogger,
|
|
183
|
+
isInert: () => inert,
|
|
184
|
+
sync: () => enqueueGit(() => runSyncPass(useLogger())),
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
147
188
|
// Inbound polling — watch mode only; a one-shot build has no
|
|
148
189
|
// "later" to pull into. Webhook delivery is not implemented
|
|
149
190
|
// (see README); poll is the only supported inbound trigger.
|
|
150
191
|
if (runtime.options.watch && pollIntervalMs > 0) {
|
|
151
|
-
pollTimer = setInterval(
|
|
192
|
+
pollTimer = setInterval(() => {
|
|
152
193
|
if (inert) return
|
|
153
|
-
|
|
194
|
+
// Belt AND braces. pullInbound is written not to throw,
|
|
195
|
+
// but a timer callback is the one place where being
|
|
196
|
+
// wrong about that is fatal rather than noisy: Node has
|
|
197
|
+
// treated an unhandled rejection as process-ending since
|
|
198
|
+
// v15, and mikser installs no handler. Anything reaching
|
|
199
|
+
// here is a bug worth logging, not worth killing the
|
|
200
|
+
// build server for.
|
|
201
|
+
withGuard(logger, 'inbound poll', () => enqueueGit(() =>
|
|
202
|
+
pullInbound(folder, { writeBranch, targetBranch, token, logger })))
|
|
154
203
|
}, pollIntervalMs)
|
|
155
204
|
pollTimer.unref?.()
|
|
156
205
|
}
|
|
@@ -180,7 +229,7 @@ export function git(options = {}) {
|
|
|
180
229
|
logger.warn('git: build had failures (%s) — not syncing', culprits.join(', '))
|
|
181
230
|
return
|
|
182
231
|
}
|
|
183
|
-
await runSyncPass(logger)
|
|
232
|
+
await enqueueGit(() => runSyncPass(logger))
|
|
184
233
|
return
|
|
185
234
|
}
|
|
186
235
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// The trailer format that makes a commit undoable.
|
|
2
|
+
//
|
|
3
|
+
// Trailers rather than tags or notes: a tag per change set means thousands of
|
|
4
|
+
// refs, and notes are not fetched by default so they vanish on the next clone.
|
|
5
|
+
// Trailers are ordinary commit-message lines, they survive every normal git
|
|
6
|
+
// operation, and `git log --grep` finds them.
|
|
7
|
+
//
|
|
8
|
+
// The change-set id is also the PERMISSION boundary, not only the grouping
|
|
9
|
+
// key. Undo reverts commits carrying one and never touches the unattributed
|
|
10
|
+
// sweep commits, so a human's hand edit or an API write cannot be removed by
|
|
11
|
+
// an agent that did not make it.
|
|
12
|
+
|
|
13
|
+
export const CHANGE_SET_TRAILER = 'Mikser-Change-Set'
|
|
14
|
+
export const PRINCIPAL_TRAILER = 'Mikser-Principal'
|
|
15
|
+
export const UNDO_TRAILER = 'Mikser-Undo-Of'
|
|
16
|
+
|
|
17
|
+
export function changeSetTrailers(set) {
|
|
18
|
+
const lines = [`${CHANGE_SET_TRAILER}: ${set.id}`]
|
|
19
|
+
if (set.principal) lines.push(`${PRINCIPAL_TRAILER}: ${set.principal}`)
|
|
20
|
+
if (set.undoOf) lines.push(`${UNDO_TRAILER}: ${set.undoOf}`)
|
|
21
|
+
return lines.join('\n')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Parse `git log` output into change sets, newest first.
|
|
25
|
+
//
|
|
26
|
+
// One record per SET rather than per commit: a set can span several commits
|
|
27
|
+
// when writes arrive across cycles, and undo has to remove all of them or it
|
|
28
|
+
// removes half a request.
|
|
29
|
+
export function parseChangeSetLog(raw) {
|
|
30
|
+
const sets = new Map()
|
|
31
|
+
for (const block of String(raw ?? '').split('\x1e').filter(b => b.trim())) {
|
|
32
|
+
const [sha, at, subject, ...bodyLines] = block.split('\x1f')
|
|
33
|
+
const body = bodyLines.join('\x1f')
|
|
34
|
+
const id = matchTrailer(body, CHANGE_SET_TRAILER)
|
|
35
|
+
if (!id) continue
|
|
36
|
+
let set = sets.get(id)
|
|
37
|
+
if (!set) {
|
|
38
|
+
set = {
|
|
39
|
+
id,
|
|
40
|
+
summary: subject?.trim() || null,
|
|
41
|
+
principal: matchTrailer(body, PRINCIPAL_TRAILER),
|
|
42
|
+
undoOf: matchTrailer(body, UNDO_TRAILER),
|
|
43
|
+
at: Number(at) * 1000,
|
|
44
|
+
commits: [],
|
|
45
|
+
}
|
|
46
|
+
sets.set(id, set)
|
|
47
|
+
}
|
|
48
|
+
// Log order is newest-first; a set's commits are recorded oldest-first
|
|
49
|
+
// so a revert can walk them newest-first without re-sorting.
|
|
50
|
+
set.commits.unshift(sha)
|
|
51
|
+
set.at = Math.max(set.at, Number(at) * 1000)
|
|
52
|
+
}
|
|
53
|
+
return [...sets.values()]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function matchTrailer(body, name) {
|
|
57
|
+
const m = new RegExp(`^${name}:\\s*(.+)$`, 'm').exec(body ?? '')
|
|
58
|
+
return m ? m[1].trim() : null
|
|
59
|
+
}
|
package/lib/git.js
CHANGED
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
|
|
11
11
|
import { execFile } from 'node:child_process'
|
|
12
12
|
import { promisify } from 'node:util'
|
|
13
|
+
import { mkdtemp, writeFile, rm } from 'node:fs/promises'
|
|
14
|
+
import { tmpdir } from 'node:os'
|
|
15
|
+
import path from 'node:path'
|
|
13
16
|
|
|
14
17
|
const execFileAsync = promisify(execFile)
|
|
15
18
|
|
|
@@ -49,32 +52,49 @@ export async function remoteUrl(folder, remote = 'origin') {
|
|
|
49
52
|
}
|
|
50
53
|
}
|
|
51
54
|
|
|
52
|
-
// Auth via a short-lived per-
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
55
|
+
// Auth via a short-lived per-command header, never embedded in the remote
|
|
56
|
+
// URL or written to .git/config — an embedded `https://token@host/...`
|
|
57
|
+
// remote leaks the token into `git remote -v` output and any log or error
|
|
58
|
+
// that echoes the URL.
|
|
59
|
+
//
|
|
60
|
+
// Delivered through the ENVIRONMENT rather than as a `-c` argument.
|
|
61
|
+
// Same semantics, same one-command lifetime, same no-persistence — but a
|
|
62
|
+
// process's arguments are world-readable and its environment is not:
|
|
63
|
+
//
|
|
64
|
+
// -r--r--r-- /proc/<pid>/cmdline
|
|
65
|
+
// -r-------- /proc/<pid>/environ
|
|
66
|
+
//
|
|
67
|
+
// As a `-c http.extraheader=...` argument the base64 credential is
|
|
68
|
+
// readable by any local user for as long as the git subprocess runs
|
|
69
|
+
// (base64 is encoding, not encryption), and it can surface in an
|
|
70
|
+
// `err.stderr` that a caller then logs. GIT_CONFIG_COUNT / _KEY_n /
|
|
71
|
+
// _VALUE_n is git's own supported equivalent (2.31+) and keeps it out of
|
|
72
|
+
// both places.
|
|
73
|
+
function authEnv(token) {
|
|
74
|
+
if (!token) return undefined
|
|
60
75
|
const b64 = Buffer.from(`x-access-token:${token}`).toString('base64')
|
|
61
|
-
return
|
|
76
|
+
return {
|
|
77
|
+
...process.env,
|
|
78
|
+
GIT_CONFIG_COUNT: '1',
|
|
79
|
+
GIT_CONFIG_KEY_0: 'http.extraheader',
|
|
80
|
+
GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${b64}`,
|
|
81
|
+
}
|
|
62
82
|
}
|
|
63
83
|
|
|
64
84
|
export async function clone(url, folder, { branch, token } = {}) {
|
|
65
|
-
const args = [
|
|
85
|
+
const args = ['clone', ...(branch ? ['--branch', branch] : []), url, folder]
|
|
66
86
|
// clone's cwd doesn't matter (destination is a full path); run from
|
|
67
87
|
// the parent so a not-yet-existing `folder` isn't required as cwd.
|
|
68
|
-
await run('.', args)
|
|
88
|
+
await run('.', args, { env: authEnv(token) })
|
|
69
89
|
}
|
|
70
90
|
|
|
71
91
|
export async function fetch(folder, { remote = 'origin', token } = {}) {
|
|
72
|
-
await run(folder, [
|
|
92
|
+
await run(folder, ['fetch', remote], { env: authEnv(token) })
|
|
73
93
|
}
|
|
74
94
|
|
|
75
95
|
export async function push(folder, refspec, { remote = 'origin', token, force = false } = {}) {
|
|
76
|
-
const args = [
|
|
77
|
-
await run(folder, args)
|
|
96
|
+
const args = ['push', ...(force ? ['--force-with-lease'] : []), remote, refspec]
|
|
97
|
+
await run(folder, args, { env: authEnv(token) })
|
|
78
98
|
}
|
|
79
99
|
|
|
80
100
|
// Non-fast-forward pushes throw with stderr containing "non-fast-forward"
|
|
@@ -127,6 +147,73 @@ export async function addAll(folder, paths) {
|
|
|
127
147
|
await run(folder, ['add', '-A', ...pathspecArgs(paths)])
|
|
128
148
|
}
|
|
129
149
|
|
|
150
|
+
// Stage an explicit list of files — the change-set path. Distinct from
|
|
151
|
+
// addAll's folder pathspec on purpose: this stages what a request wrote and
|
|
152
|
+
// nothing that merely happened to be dirty beside it.
|
|
153
|
+
//
|
|
154
|
+
// `--` separates paths from revisions so a file named like a branch cannot be
|
|
155
|
+
// reinterpreted, and `-A` keeps deletions staged as deletions.
|
|
156
|
+
export async function addPaths(folder, files) {
|
|
157
|
+
if (!files?.length) return
|
|
158
|
+
await run(folder, ['add', '-A', '--', ...files])
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// The reverse patch for a commit: the diff from it back to its parent.
|
|
162
|
+
//
|
|
163
|
+
// This is what makes a dry run possible at all. `git revert` has no
|
|
164
|
+
// --dry-run, so the choice is to attempt it in the live working folder and
|
|
165
|
+
// deal with a conflicted tree — which for a deployed site means the build
|
|
166
|
+
// stops — or to compute the patch and ask `git apply --check` whether it
|
|
167
|
+
// would land. The second never touches the tree.
|
|
168
|
+
export async function reversePatch(folder, sha, files) {
|
|
169
|
+
const args = ['diff', '--binary', sha, `${sha}^`]
|
|
170
|
+
if (files?.length) args.push('--', ...files)
|
|
171
|
+
return await run(folder, args)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Would this patch apply cleanly? Changes nothing either way.
|
|
175
|
+
export async function patchApplies(folder, patch) {
|
|
176
|
+
if (!patch?.trim()) return true
|
|
177
|
+
return await withPatchFile(patch, async (file) => {
|
|
178
|
+
try {
|
|
179
|
+
await run(folder, ['apply', '--check', '--binary', file])
|
|
180
|
+
return true
|
|
181
|
+
} catch {
|
|
182
|
+
return false
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function applyPatch(folder, patch) {
|
|
188
|
+
if (!patch?.trim()) return
|
|
189
|
+
await withPatchFile(patch, (file) => run(folder, ['apply', '--binary', file]))
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// The patch goes to the system temp dir, never inside the repo: a stray file
|
|
193
|
+
// in the working folder is one the plugin's own sweep would commit.
|
|
194
|
+
async function withPatchFile(patch, fn) {
|
|
195
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'mikser-git-patch-'))
|
|
196
|
+
const file = path.join(dir, 'undo.patch')
|
|
197
|
+
try {
|
|
198
|
+
await writeFile(file, patch.endsWith('\n') ? patch : `${patch}\n`, 'utf8')
|
|
199
|
+
return await fn(file)
|
|
200
|
+
} finally {
|
|
201
|
+
await rm(dir, { recursive: true, force: true })
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Commits carrying a change-set trailer, newest first, with unit separators
|
|
206
|
+
// that cannot occur in a commit message.
|
|
207
|
+
export async function logChangeSets(folder, { branch, limit = 50, id } = {}) {
|
|
208
|
+
const args = [
|
|
209
|
+
'log', branch ?? 'HEAD',
|
|
210
|
+
`--max-count=${Math.max(1, Math.min(limit, 500))}`,
|
|
211
|
+
'--format=%H%x1f%at%x1f%s%x1f%b%x1e',
|
|
212
|
+
'--grep', id ? `^Mikser-Change-Set: ${id}$` : '^Mikser-Change-Set: ',
|
|
213
|
+
]
|
|
214
|
+
return await run(folder, args)
|
|
215
|
+
}
|
|
216
|
+
|
|
130
217
|
export async function commit(folder, message, { author } = {}) {
|
|
131
218
|
const args = ['commit', '-m', message]
|
|
132
219
|
if (author?.name) args.push('--author', `${author.name} <${author.email ?? ''}>`)
|
|
@@ -164,7 +251,7 @@ export async function branchExistsLocal(folder, branch) {
|
|
|
164
251
|
export async function branchExistsRemote(folder, branch, { remote = 'origin', token } = {}) {
|
|
165
252
|
// ls-remote hits the network but needs no local ref state — safe
|
|
166
253
|
// to call before any fetch has happened.
|
|
167
|
-
const out = await run(folder, [
|
|
254
|
+
const out = await run(folder, ['ls-remote', '--heads', remote, branch], { env: authEnv(token) })
|
|
168
255
|
return out.length > 0
|
|
169
256
|
}
|
|
170
257
|
|
package/lib/inbound.js
CHANGED
|
@@ -23,14 +23,48 @@
|
|
|
23
23
|
|
|
24
24
|
import * as git from './git.js'
|
|
25
25
|
|
|
26
|
+
// Never throws. Every caller is a timer, and a timer callback that
|
|
27
|
+
// rejects takes the whole process down: Node has treated an unhandled
|
|
28
|
+
// rejection as fatal since v15, mikser installs no process-level handler,
|
|
29
|
+
// and the poll runs every few minutes for the life of a watch server. A
|
|
30
|
+
// network blip, a DNS failure or an expired token would kill the build and
|
|
31
|
+
// the site, and under a supervisor it becomes a restart loop.
|
|
32
|
+
//
|
|
33
|
+
// So the outcome is always a returned shape. A fetch that fails is the same
|
|
34
|
+
// class of event as a merge that conflicts — remote trouble, reported, try
|
|
35
|
+
// again next tick — and it is reported the same way.
|
|
26
36
|
export async function pullInbound(folder, { writeBranch, targetBranch, token, logger }) {
|
|
27
|
-
|
|
37
|
+
try {
|
|
38
|
+
await git.fetch(folder, { token })
|
|
39
|
+
} catch (err) {
|
|
40
|
+
// Nothing was touched: fetch writes only to remote-tracking refs,
|
|
41
|
+
// and a failed one leaves even those alone.
|
|
42
|
+
logger?.warn(
|
|
43
|
+
'git: inbound fetch failed — %s. Working folder untouched; retrying on the next poll.',
|
|
44
|
+
err.stderr || err.message,
|
|
45
|
+
)
|
|
46
|
+
return { merged: false, fetchFailed: true, reason: err.stderr || err.message }
|
|
47
|
+
}
|
|
28
48
|
|
|
29
49
|
for (const ref of [writeBranch, targetBranch]) {
|
|
30
50
|
try {
|
|
31
51
|
await git.mergeBranch(folder, `origin/${ref}`)
|
|
32
52
|
} catch (err) {
|
|
33
|
-
|
|
53
|
+
// A failed abort would throw straight past this handler and
|
|
54
|
+
// become the fatal rejection the fetch guard above exists to
|
|
55
|
+
// prevent. Report it instead: the folder is then in a merge
|
|
56
|
+
// state a human has to look at, which is worth saying loudly.
|
|
57
|
+
try {
|
|
58
|
+
await git.abortMerge(folder)
|
|
59
|
+
} catch (abortErr) {
|
|
60
|
+
logger?.error(
|
|
61
|
+
'git: inbound merge of origin/%s conflicted AND `git merge --abort` failed — %s. ' +
|
|
62
|
+
'The working folder is mid-merge and may contain conflict markers; mikser will render them as content. '
|
|
63
|
+
+ 'Resolve manually before the next cycle.',
|
|
64
|
+
ref, abortErr.stderr || abortErr.message,
|
|
65
|
+
)
|
|
66
|
+
return { merged: false, conflictedRef: ref, abortFailed: true, reason: abortErr.stderr || abortErr.message }
|
|
67
|
+
}
|
|
34
68
|
logger?.error(
|
|
35
69
|
'git: inbound merge of origin/%s conflicted — aborted, working folder left untouched. ' +
|
|
36
70
|
'Resolve manually: cd <folder> && git merge origin/%s (or origin/%s) and fix the conflicts. %s',
|
package/lib/mcp.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// The undo surface, deliberately MCP-only.
|
|
2
|
+
//
|
|
3
|
+
// API and human writes are not attributed and are not undoable here. That is
|
|
4
|
+
// the scope, not a limitation to fix later: those callers have git, and an
|
|
5
|
+
// undo they could reach would be an undo able to remove work it never made.
|
|
6
|
+
// The change-set trailer is the permission boundary — nothing without one is
|
|
7
|
+
// reachable from these tools.
|
|
8
|
+
|
|
9
|
+
import { z } from 'zod'
|
|
10
|
+
import { recordChangeSetWrite } from 'mikser-io'
|
|
11
|
+
|
|
12
|
+
import { listChangeSets, previewUndo } from './undo.js'
|
|
13
|
+
import * as git from './git.js'
|
|
14
|
+
|
|
15
|
+
const ok = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] })
|
|
16
|
+
const fail = (message) => ({ content: [{ type: 'text', text: message }], isError: true })
|
|
17
|
+
|
|
18
|
+
export function registerUndoTools(mcp, { folder, writeBranch, runtime, useLogger, isInert, sync }) {
|
|
19
|
+
if (!mcp) return
|
|
20
|
+
const logger = useLogger?.()
|
|
21
|
+
|
|
22
|
+
mcp.simpleTool(
|
|
23
|
+
'mikser_changes',
|
|
24
|
+
'List recent change sets — the units of work an agent can undo. Each is one request, however many files '
|
|
25
|
+
+ 'it wrote, with the summary given when it was made.\n\n'
|
|
26
|
+
+ 'Only agent writes appear. Documents created through the API and files edited by hand are committed too, '
|
|
27
|
+
+ 'but unattributed and deliberately not undoable from here — reverting them would remove work this tool '
|
|
28
|
+
+ 'has no claim on.',
|
|
29
|
+
{
|
|
30
|
+
limit: z.number().int().positive().max(50).optional()
|
|
31
|
+
.describe('How many change sets to list, newest first. Default 20.'),
|
|
32
|
+
},
|
|
33
|
+
async ({ limit = 20 } = {}) => {
|
|
34
|
+
if (isInert?.()) return fail('git: plugin is inert after a bootstrap refusal — no history to read.')
|
|
35
|
+
try {
|
|
36
|
+
const sets = await listChangeSets(folder, { branch: writeBranch, limit })
|
|
37
|
+
return ok({
|
|
38
|
+
count: sets.length,
|
|
39
|
+
changes: sets.map(set => ({
|
|
40
|
+
id: set.id,
|
|
41
|
+
summary: set.summary,
|
|
42
|
+
at: new Date(set.at).toISOString(),
|
|
43
|
+
files: set.commits.length,
|
|
44
|
+
...(set.principal ? { by: set.principal } : {}),
|
|
45
|
+
...(set.undoOf ? { undoOf: set.undoOf } : {}),
|
|
46
|
+
})),
|
|
47
|
+
next: 'Pass an `id` to mikser_undo with dryRun first — it reports whether the undo applies '
|
|
48
|
+
+ 'cleanly and whether anything added since depends on what it would remove.',
|
|
49
|
+
})
|
|
50
|
+
} catch (err) {
|
|
51
|
+
logger?.error('git: mikser_changes failed — %s', err.stderr || err.message)
|
|
52
|
+
return fail(err.stderr || err.message)
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
mcp.simpleTool(
|
|
58
|
+
'mikser_undo',
|
|
59
|
+
'Take back one change set, keeping everything that happened after it.\n\n'
|
|
60
|
+
+ 'This is not a restore to a previous state: documents added through the API since, and edits made by '
|
|
61
|
+
+ 'hand, are kept. Only the named change set\'s contribution is removed, as an ordinary forward commit — '
|
|
62
|
+
+ 'so history is never rewritten, the deploy branch never has to be force-pushed, and the undo is itself '
|
|
63
|
+
+ 'an undoable change set.\n\n'
|
|
64
|
+
+ 'ALWAYS dryRun first. Two independent things can stop an undo, and they need different answers: a later '
|
|
65
|
+
+ 'edit to the same content makes it inapplicable (`conflict`), and a document added since that references '
|
|
66
|
+
+ 'something this undo REMOVES makes it destructive (`dangling`). The second is the dangerous one — git '
|
|
67
|
+
+ 'applies it cleanly and the site breaks anyway, which is why the reference graph is consulted rather '
|
|
68
|
+
+ 'than just the patch.',
|
|
69
|
+
{
|
|
70
|
+
id: z.string().describe('Change set id, from mikser_changes.'),
|
|
71
|
+
dryRun: z.boolean().optional().describe('Report what the undo would do and change nothing. Default true — pass false to actually apply it.'),
|
|
72
|
+
force: z.boolean().optional().describe('Apply even when the undo would leave references dangling. Never bypasses a conflict, which cannot be applied at all.'),
|
|
73
|
+
},
|
|
74
|
+
async ({ id, dryRun = true, force = false } = {}) => {
|
|
75
|
+
if (isInert?.()) return fail('git: plugin is inert after a bootstrap refusal — refusing to touch the folder.')
|
|
76
|
+
if (!id) return fail('id is required')
|
|
77
|
+
try {
|
|
78
|
+
const preview = await previewUndo(folder, { id, branch: writeBranch, runtime })
|
|
79
|
+
if (!preview.ok) return ok(preview)
|
|
80
|
+
|
|
81
|
+
const { patch, ...report } = preview
|
|
82
|
+
if (dryRun) {
|
|
83
|
+
return ok({
|
|
84
|
+
...report, dryRun: true,
|
|
85
|
+
wouldApply: preview.applies && (!preview.dangling.length || force),
|
|
86
|
+
next: preview.applies
|
|
87
|
+
? 'Call again with dryRun: false to apply.'
|
|
88
|
+
: 'This one cannot be applied automatically. Say so plainly rather than trying '
|
|
89
|
+
+ 'variations — the content it touched has moved on.',
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// A patch that does not apply is refused outright. Attempting
|
|
94
|
+
// it would leave the working folder half-changed, and for a
|
|
95
|
+
// deployed site that means the build stops — an undo that
|
|
96
|
+
// takes the site down is worse than the change it undoes.
|
|
97
|
+
if (!preview.applies) {
|
|
98
|
+
return ok({ ...report, ok: false, refused: 'conflict' })
|
|
99
|
+
}
|
|
100
|
+
if (preview.dangling.length && !force) {
|
|
101
|
+
return ok({
|
|
102
|
+
...report, ok: false, refused: 'would-dangle',
|
|
103
|
+
next: 'Pass force: true only if removing those references is intended.',
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
await git.applyPatch(folder, patch)
|
|
108
|
+
|
|
109
|
+
// Recorded as its own change set so the commit carries a
|
|
110
|
+
// trailer and the undo can itself be undone.
|
|
111
|
+
const undoId = `undo-${id}-${preview.set.commits}-${Math.round(preview.set.at)}`
|
|
112
|
+
for (const rel of preview.touched) {
|
|
113
|
+
recordChangeSetWrite({
|
|
114
|
+
changeSet: undoId,
|
|
115
|
+
summary: `Undo: ${preview.set.summary ?? id}`,
|
|
116
|
+
principal: 'agent',
|
|
117
|
+
undoOf: id,
|
|
118
|
+
uri: `${runtime.options.workingFolder}/${rel}`,
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The files are on disk now; the engine's watcher will pick
|
|
123
|
+
// them up. Nudging the sync pass means the undo reaches the
|
|
124
|
+
// remote without waiting for the next debounce window.
|
|
125
|
+
sync?.()
|
|
126
|
+
|
|
127
|
+
return ok({
|
|
128
|
+
ok: true, undone: id, changeSet: undoId,
|
|
129
|
+
summary: preview.set.summary,
|
|
130
|
+
touched: preview.touched,
|
|
131
|
+
removed: preview.removes,
|
|
132
|
+
next: 'The files are back to their pre-change state and will rebuild on the next cycle. '
|
|
133
|
+
+ `Undo this undo with mikser_undo({ id: '${undoId}' }).`,
|
|
134
|
+
})
|
|
135
|
+
} catch (err) {
|
|
136
|
+
logger?.error('git: mikser_undo failed — %s', err.stderr || err.message)
|
|
137
|
+
return fail(err.stderr || err.message)
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
}
|
package/lib/queue.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// One git operation at a time, per plugin instance.
|
|
2
|
+
//
|
|
3
|
+
// The sync pass and the inbound poll are independent timers — debounced
|
|
4
|
+
// seconds after a green cycle, and every few minutes — over the SAME
|
|
5
|
+
// checkout. Nothing stopped them overlapping, and git serialises through
|
|
6
|
+
// `index.lock`: the loser fails with "Another git process seems to be
|
|
7
|
+
// running", which surfaces as an intermittent sync failure that retries
|
|
8
|
+
// and looks like nothing. The worse shape is a commit landing while an
|
|
9
|
+
// inbound merge is in progress, which commits the merge rather than the
|
|
10
|
+
// intended change.
|
|
11
|
+
//
|
|
12
|
+
// A promise chain rather than a lock: each caller waits for the previous
|
|
13
|
+
// operation to settle, in order, and a rejection cannot break the chain
|
|
14
|
+
// because the guard already turned every body into a resolved promise.
|
|
15
|
+
export function createGitQueue() {
|
|
16
|
+
let tail = Promise.resolve()
|
|
17
|
+
return function enqueue(fn) {
|
|
18
|
+
const next = tail.then(fn, fn)
|
|
19
|
+
// Swallow here only — the caller's own guard reports. Without this
|
|
20
|
+
// the chain itself would carry an unhandled rejection.
|
|
21
|
+
tail = next.catch(() => {})
|
|
22
|
+
return next
|
|
23
|
+
}
|
|
24
|
+
}
|