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 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
- const { committed } = await commitAndPushWriteBranch(folder, { paths, writeBranch, message, author, token })
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(folder, { paths, writeBranch, message, author, token }) {
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
- const fileCount = porcelain.split('\n').filter(Boolean).length
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 { committed: false, pushed: false }
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 { committed: true, pushed: true }
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 { committed: true, pushed: true }
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.2.0",
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.0.0"
27
+ "mikser-io": "^9.44.0",
28
+ "zod": "^4.0.0"
28
29
  }
29
30
  }