mikser-io-git 2.2.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 +53 -0
- package/index.js +26 -1
- package/lib/changeset-commit.js +59 -0
- package/lib/git.js +70 -0
- package/lib/mcp.js +141 -0
- package/lib/sync.js +60 -5
- package/lib/undo.js +147 -0
- package/package.json +3 -2
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
|
package/index.js
CHANGED
|
@@ -71,6 +71,9 @@ function withGuard(logger, what, fn) {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
|
|
74
|
+
import { pendingChangeSets, clearChangeSets } from 'mikser-io'
|
|
75
|
+
import { registerUndoTools } from './lib/mcp.js'
|
|
76
|
+
|
|
74
77
|
export function git(options = {}) {
|
|
75
78
|
const {
|
|
76
79
|
url, paths, forge, targetBranch, writeBranch,
|
|
@@ -95,7 +98,17 @@ export function git(options = {}) {
|
|
|
95
98
|
async function runSyncPass(logger) {
|
|
96
99
|
debounceState = IDLE_DEBOUNCE_STATE
|
|
97
100
|
try {
|
|
98
|
-
|
|
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)
|
|
99
112
|
if (!committed) return
|
|
100
113
|
logger.info('git: committed + pushed to %s', writeBranch)
|
|
101
114
|
|
|
@@ -160,6 +173,18 @@ export function git(options = {}) {
|
|
|
160
173
|
targetBranch, forge,
|
|
161
174
|
)
|
|
162
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
|
+
|
|
163
188
|
// Inbound polling — watch mode only; a one-shot build has no
|
|
164
189
|
// "later" to pull into. Webhook delivery is not implemented
|
|
165
190
|
// (see README); poll is the only supported inbound trigger.
|
|
@@ -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
|
|
|
@@ -144,6 +147,73 @@ export async function addAll(folder, paths) {
|
|
|
144
147
|
await run(folder, ['add', '-A', ...pathspecArgs(paths)])
|
|
145
148
|
}
|
|
146
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
|
+
|
|
147
217
|
export async function commit(folder, message, { author } = {}) {
|
|
148
218
|
const args = ['commit', '-m', message]
|
|
149
219
|
if (author?.name) args.push('--author', `${author.name} <${author.email ?? ''}>`)
|
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/sync.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// stays queued on the write branch until a human intervenes.
|
|
14
14
|
|
|
15
15
|
import * as git from './git.js'
|
|
16
|
+
import { changeSetTrailers } from './changeset-commit.js'
|
|
16
17
|
import * as github from './forge/github.js'
|
|
17
18
|
import * as gitea from './forge/gitea.js'
|
|
18
19
|
|
|
@@ -38,10 +39,42 @@ const ADAPTERS = { github, gitea }
|
|
|
38
39
|
// returning a string — a count rather than a file list, since a full
|
|
39
40
|
// list of paths in a commit message gets unwieldy past a handful of
|
|
40
41
|
// files and git status is cheap to re-derive if anyone needs detail.
|
|
41
|
-
export async function commitAndPushWriteBranch(
|
|
42
|
+
export async function commitAndPushWriteBranch(
|
|
43
|
+
folder, { paths, writeBranch, message, author, token, changeSets = [], onCommitted },
|
|
44
|
+
) {
|
|
42
45
|
const porcelain = await git.statusPorcelain(folder, paths)
|
|
43
46
|
if (!porcelain) return { committed: false, pushed: false }
|
|
44
|
-
|
|
47
|
+
|
|
48
|
+
let committedAny = false
|
|
49
|
+
|
|
50
|
+
// Claimed writes first, each set its own commit staged to exactly the
|
|
51
|
+
// paths that set wrote.
|
|
52
|
+
//
|
|
53
|
+
// Staging by FOLDER instead would sweep in whatever else happened to be
|
|
54
|
+
// dirty — a document created through the API a second earlier lands in the
|
|
55
|
+
// agent's commit, and undoing the agent then deletes that document. The
|
|
56
|
+
// commit's contents have to match its label, or the label is a lie that
|
|
57
|
+
// only shows up at undo time.
|
|
58
|
+
for (const set of changeSets) {
|
|
59
|
+
const scoped = withinPaths(set.paths, paths)
|
|
60
|
+
if (!scoped.length) continue
|
|
61
|
+
await git.addPaths(folder, scoped)
|
|
62
|
+
if (!(await git.hasStagedChanges(folder, scoped))) continue
|
|
63
|
+
await git.commit(folder, changeSetMessage(set, scoped), { author })
|
|
64
|
+
committedAny = true
|
|
65
|
+
onCommitted?.(set.id)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Then everything still dirty and unclaimed: API writes, human edits, a
|
|
69
|
+
// set whose attribution was lost to a crash. Unattributed and therefore
|
|
70
|
+
// not undoable, but never dropped.
|
|
71
|
+
const remaining = await git.statusPorcelain(folder, paths)
|
|
72
|
+
if (!remaining) {
|
|
73
|
+
return committedAny
|
|
74
|
+
? { committed: true, pushed: await pushWriteBranch(folder, { writeBranch, token }) }
|
|
75
|
+
: { committed: false, pushed: false }
|
|
76
|
+
}
|
|
77
|
+
const fileCount = remaining.split('\n').filter(Boolean).length
|
|
45
78
|
|
|
46
79
|
await git.addAll(folder, paths)
|
|
47
80
|
if (!(await git.hasStagedChanges(folder, paths))) {
|
|
@@ -49,14 +82,36 @@ export async function commitAndPushWriteBranch(folder, { paths, writeBranch, mes
|
|
|
49
82
|
// end up staging as a diff from HEAD in edge cases (e.g. a
|
|
50
83
|
// file that matches .gitignore was force-added) — defensive,
|
|
51
84
|
// should not normally happen.
|
|
52
|
-
return
|
|
85
|
+
return committedAny
|
|
86
|
+
? { committed: true, pushed: await pushWriteBranch(folder, { writeBranch, token }) }
|
|
87
|
+
: { committed: false, pushed: false }
|
|
53
88
|
}
|
|
54
89
|
const resolvedMessage = typeof message === 'function' ? message({ fileCount }) : message
|
|
55
90
|
await git.commit(folder, resolvedMessage, { author })
|
|
56
91
|
|
|
92
|
+
return { committed: true, pushed: await pushWriteBranch(folder, { writeBranch, token }) }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Only the paths this instance is scoped to. A change set can span
|
|
96
|
+
// collections this git instance does not manage; those stay for whichever
|
|
97
|
+
// instance does own them, rather than being committed here.
|
|
98
|
+
function withinPaths(candidates, paths) {
|
|
99
|
+
if (!paths?.length) return candidates
|
|
100
|
+
return candidates.filter(rel => paths.some(p => rel === p || rel.startsWith(`${p.replace(/\/$/, '')}/`)))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Subject is the caller's own summary, so `git log --oneline` reads as a list
|
|
104
|
+
// of what was asked for. Trailers carry the machine-readable half.
|
|
105
|
+
function changeSetMessage(set, scoped) {
|
|
106
|
+
const subject = set.summary?.trim().split('\n')[0]
|
|
107
|
+
|| `content: ${scoped.length} file(s) via mikser`
|
|
108
|
+
return `${subject}\n\n${changeSetTrailers(set)}`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function pushWriteBranch(folder, { writeBranch, token }) {
|
|
57
112
|
try {
|
|
58
113
|
await git.push(folder, writeBranch, { token })
|
|
59
|
-
return
|
|
114
|
+
return true
|
|
60
115
|
} catch (err) {
|
|
61
116
|
if (!git.isNonFastForwardError(err)) throw err
|
|
62
117
|
// The remote write branch moved (an inbound pull landed
|
|
@@ -69,7 +124,7 @@ export async function commitAndPushWriteBranch(folder, { paths, writeBranch, mes
|
|
|
69
124
|
await git.fetch(folder, { token })
|
|
70
125
|
await git.run(folder, ['rebase', `origin/${writeBranch}`])
|
|
71
126
|
await git.push(folder, writeBranch, { token })
|
|
72
|
-
return
|
|
127
|
+
return true
|
|
73
128
|
}
|
|
74
129
|
}
|
|
75
130
|
|
package/lib/undo.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Remove one change set's contribution from the current state.
|
|
2
|
+
//
|
|
3
|
+
// Not "restore a previous state". The site has moved on — documents were added
|
|
4
|
+
// through the API, a human edited something — and the request is to take back
|
|
5
|
+
// ONE change while keeping everything that came after. That is a merge-shaped
|
|
6
|
+
// operation, which is exactly what `git revert` is for and exactly what a
|
|
7
|
+
// snapshot restore is not.
|
|
8
|
+
//
|
|
9
|
+
// Git handles the textual half. It cannot handle the other half: a document
|
|
10
|
+
// added after the change set may DEPEND on what the change set created, so a
|
|
11
|
+
// revert that applies with no conflict at all can still leave the site
|
|
12
|
+
// referencing a layout that no longer exists. Git reports success; the site is
|
|
13
|
+
// broken. That check needs the reference graph, so it happens here.
|
|
14
|
+
|
|
15
|
+
import { findEntities, lookupKeys } from 'mikser-io'
|
|
16
|
+
|
|
17
|
+
import * as git from './git.js'
|
|
18
|
+
import { parseChangeSetLog } from './changeset-commit.js'
|
|
19
|
+
|
|
20
|
+
// Change sets in the branch's history, newest first.
|
|
21
|
+
export async function listChangeSets(folder, { branch, limit = 20 } = {}) {
|
|
22
|
+
const raw = await git.logChangeSets(folder, { branch, limit: limit * 3 })
|
|
23
|
+
return parseChangeSetLog(raw).sort((a, b) => b.at - a.at).slice(0, limit)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function findChangeSet(folder, id, { branch } = {}) {
|
|
27
|
+
const raw = await git.logChangeSets(folder, { branch, limit: 500, id })
|
|
28
|
+
return parseChangeSetLog(raw).find(set => set.id === id) ?? null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The reverse patch for a whole set: every commit undone, newest first.
|
|
32
|
+
//
|
|
33
|
+
// Newest-first because the commits stack — undoing the oldest edit to a file
|
|
34
|
+
// before the newest one would be applying a patch to content that has moved
|
|
35
|
+
// on since.
|
|
36
|
+
export async function reversePatchFor(folder, set) {
|
|
37
|
+
const parts = []
|
|
38
|
+
for (const sha of [...set.commits].reverse()) {
|
|
39
|
+
const patch = await git.reversePatch(folder, sha)
|
|
40
|
+
if (patch?.trim()) parts.push(patch)
|
|
41
|
+
}
|
|
42
|
+
return parts.join('\n')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Paths the undo would touch, and which of them it would REMOVE.
|
|
46
|
+
//
|
|
47
|
+
// A removal is where the danger is: reverting a create deletes a file, and
|
|
48
|
+
// anything that came to depend on it since is what breaks.
|
|
49
|
+
export function pathsInPatch(patch) {
|
|
50
|
+
const touched = new Set()
|
|
51
|
+
const deleted = new Set()
|
|
52
|
+
let current = null
|
|
53
|
+
for (const line of String(patch ?? '').split('\n')) {
|
|
54
|
+
const m = /^diff --git a\/(.+?) b\/(.+)$/.exec(line)
|
|
55
|
+
if (m) { current = m[2]; touched.add(current); continue }
|
|
56
|
+
// In a REVERSE patch, "new file mode" means the forward commit
|
|
57
|
+
// deleted it and undoing restores it; "deleted file mode" means the
|
|
58
|
+
// forward commit created it and undoing removes it.
|
|
59
|
+
if (line.startsWith('deleted file mode') && current) deleted.add(current)
|
|
60
|
+
}
|
|
61
|
+
return { touched: [...touched], deleted: [...deleted] }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// What would still point at the things this undo removes.
|
|
65
|
+
//
|
|
66
|
+
// Uses the engine's own inverse reference index rather than a text search, so
|
|
67
|
+
// it sees a `$`-ref exactly the way invalidation does — and asks about every
|
|
68
|
+
// key the entity can be referenced BY (`lookupKeys`), not just its id, since
|
|
69
|
+
// content refers to served paths far more often than to catalog ids.
|
|
70
|
+
//
|
|
71
|
+
// Referrers belonging to the change set itself are excluded: undoing a
|
|
72
|
+
// document together with the layout only it used is coherent, and counting
|
|
73
|
+
// that as breakage would make every complete undo look dangerous.
|
|
74
|
+
export async function danglingAfterUndo({ runtime, deletedPaths, setPaths }) {
|
|
75
|
+
const refs = runtime?.refs
|
|
76
|
+
const workingFolder = runtime?.options?.workingFolder
|
|
77
|
+
if (!refs?.inboundFor || !workingFolder || !deletedPaths.length) return []
|
|
78
|
+
|
|
79
|
+
const own = new Set(setPaths)
|
|
80
|
+
const relOf = (uri) => (uri && uri.startsWith(`${workingFolder}/`)
|
|
81
|
+
? uri.slice(workingFolder.length + 1)
|
|
82
|
+
: null)
|
|
83
|
+
|
|
84
|
+
const broken = []
|
|
85
|
+
for (const rel of deletedPaths) {
|
|
86
|
+
const [entity] = await findEntities({ uri: `${workingFolder}/${rel}` }) ?? []
|
|
87
|
+
if (!entity) continue
|
|
88
|
+
|
|
89
|
+
const referrers = new Map()
|
|
90
|
+
for (const key of [entity.id, ...(lookupKeys(entity) ?? [])]) {
|
|
91
|
+
if (!key) continue
|
|
92
|
+
let inbound = []
|
|
93
|
+
try { inbound = refs.inboundFor(key) ?? [] } catch { continue }
|
|
94
|
+
for (const ref of inbound) {
|
|
95
|
+
if (!ref?.id || ref.id === entity.id) continue
|
|
96
|
+
referrers.set(ref.id, ref.field ?? null)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const outside = []
|
|
101
|
+
for (const [sourceId, field] of referrers) {
|
|
102
|
+
const [source] = await findEntities({ id: sourceId }) ?? []
|
|
103
|
+
const sourceRel = relOf(source?.uri)
|
|
104
|
+
if (sourceRel && own.has(sourceRel)) continue
|
|
105
|
+
outside.push({ id: sourceId, ...(field ? { field } : {}) })
|
|
106
|
+
}
|
|
107
|
+
if (outside.length) broken.push({ removes: rel, id: entity.id, referencedBy: outside })
|
|
108
|
+
}
|
|
109
|
+
return broken
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Everything a caller needs to decide, computed without touching the tree.
|
|
113
|
+
export async function previewUndo(folder, { id, branch, runtime } = {}) {
|
|
114
|
+
const set = await findChangeSet(folder, id, { branch })
|
|
115
|
+
if (!set) {
|
|
116
|
+
return { ok: false, refused: 'unknown-change-set',
|
|
117
|
+
error: `No change set ${id} in this branch's history.` }
|
|
118
|
+
}
|
|
119
|
+
const patch = await reversePatchFor(folder, set)
|
|
120
|
+
if (!patch.trim()) {
|
|
121
|
+
return { ok: false, refused: 'nothing-to-undo', set,
|
|
122
|
+
error: 'That change set left nothing to reverse.' }
|
|
123
|
+
}
|
|
124
|
+
const { touched, deleted } = pathsInPatch(patch)
|
|
125
|
+
const applies = await git.patchApplies(folder, patch)
|
|
126
|
+
const dangling = await danglingAfterUndo({ runtime, deletedPaths: deleted, setPaths: touched })
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
ok: true,
|
|
130
|
+
set: { id: set.id, summary: set.summary, at: set.at, principal: set.principal, commits: set.commits.length },
|
|
131
|
+
touched,
|
|
132
|
+
removes: deleted,
|
|
133
|
+
applies,
|
|
134
|
+
dangling,
|
|
135
|
+
patch,
|
|
136
|
+
// Two independent reasons to stop, reported separately because the
|
|
137
|
+
// answers differ: a conflict means "not automatically", dangling refs
|
|
138
|
+
// mean "this will break something that arrived later".
|
|
139
|
+
...(applies ? {} : {
|
|
140
|
+
conflict: 'A later change edited the same content, so this undo cannot be applied automatically.',
|
|
141
|
+
}),
|
|
142
|
+
...(dangling.length ? {
|
|
143
|
+
warning: `Undoing this removes ${dangling.length} entit${dangling.length === 1 ? 'y' : 'ies'} that `
|
|
144
|
+
+ 'something added since still references.',
|
|
145
|
+
} : {}),
|
|
146
|
+
}
|
|
147
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikser-io-git",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "Two-way git sync for mikser-io. The working folder itself is the checkout, on a dedicated `mikser` branch (durable write log for API/MCP/agent edits). `paths` (optional) scopes auto-commits to specific collection folders (documents, layouts, files, ...) as a hard pathspec; omit it and the whole working folder is committed, subject to .gitignore. Every green cycle commits + pushes whatever's in scope and attempts to merge into the target branch (`main`) via a pull request; a red cycle (any render/postprocess failure) holds the merge and leaves the PR open for a human to resolve. Inbound changes on the target branch merge back into the working copy via poll. Forge-portable: GitHub and Gitea adapters, plus a no-forge fast-forward-only floor for any bare remote.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"files": [
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
},
|
|
25
25
|
"homepage": "https://github.com/almero-digital-marketing/mikser-io-git#readme",
|
|
26
26
|
"peerDependencies": {
|
|
27
|
-
"mikser-io": "^9.
|
|
27
|
+
"mikser-io": "^9.44.0",
|
|
28
|
+
"zod": "^4.0.0"
|
|
28
29
|
}
|
|
29
30
|
}
|