mikser-io-git 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/config.js ADDED
@@ -0,0 +1,82 @@
1
+ // Resolve the factory's raw options into the fully-derived config the
2
+ // rest of the plugin uses. Pure — no filesystem, no network — so the
3
+ // validation and owner/repo/apiBase derivation are directly testable
4
+ // without instantiating the lifecycle plugin.
5
+
6
+ import { parseDuration } from './duration.js'
7
+ import { parseRepoUrl } from './repo-url.js'
8
+
9
+ const VALID_FORGES = ['github', 'gitea', 'none']
10
+
11
+ const DEFAULT_MESSAGE = ({ fileCount }) => `content: ${fileCount} file(s) via mikser`
12
+
13
+ // Accept a single path string or an array; resolve to either a
14
+ // non-empty array of strings, or `null` meaning "no scope — the
15
+ // working folder itself, whatever git sees, subject to .gitignore."
16
+ // `paths`, when given, are pathspecs RELATIVE to the working folder
17
+ // (the repo root — see index.js) naming the specific collection
18
+ // folders (documents, layouts, files, ...) this plugin is allowed to
19
+ // touch: every git operation is scoped to exactly those pathspecs, so
20
+ // mikser.config.js/node_modules/runtime/out/.env are never staged or
21
+ // committed regardless of what's dirty there — a hard scope, not a
22
+ // convenience.
23
+ //
24
+ // Omitting `paths` entirely is a DIFFERENT, weaker guarantee: with no
25
+ // pathspec at all, git operates on the whole working folder, and
26
+ // `.gitignore` is the only thing keeping node_modules/.env/etc out of
27
+ // the commit — this plugin provides no scoping of its own in that
28
+ // case. The zero-config default is "the working folder is the
29
+ // checkout, commit whatever's in it" (matching the model's own
30
+ // framing), not "commit just documents/" — narrower defaults are an
31
+ // explicit `paths` away, not the unconfigured behavior.
32
+ //
33
+ // An explicitly-passed EMPTY array is treated as a mistake (throws),
34
+ // not as an alias for "no scope" — a bare `paths: []` reads as an
35
+ // oversight, not an intentional "commit everything" request, and
36
+ // silently reinterpreting it that way would hide the typo.
37
+ function normalizePaths(value) {
38
+ if (value == null) return null
39
+ const arr = Array.isArray(value) ? value : [value]
40
+ const cleaned = arr.map(p => String(p).trim()).filter(Boolean)
41
+ if (cleaned.length === 0) {
42
+ throw new Error('git: `paths` must be a non-empty string or array of strings (omit `paths` entirely to commit the whole working folder)')
43
+ }
44
+ return cleaned
45
+ }
46
+
47
+ export function resolveConfig(options = {}) {
48
+ if (!options.url) {
49
+ throw new Error('git: `url` is required — the repo the working folder syncs with')
50
+ }
51
+ const forge = options.forge ?? 'none'
52
+ if (!VALID_FORGES.includes(forge)) {
53
+ throw new Error(`git: \`forge\` must be "github", "gitea", or "none"; got ${JSON.stringify(forge)}`)
54
+ }
55
+
56
+ let owner = options.owner
57
+ let repo = options.repo
58
+ let apiBase = options.apiBase
59
+ if (forge !== 'none' && (!owner || !repo || !apiBase)) {
60
+ const parsed = parseRepoUrl(options.url)
61
+ owner = owner ?? parsed.owner
62
+ repo = repo ?? parsed.repo
63
+ apiBase = apiBase ?? parsed.apiOrigin
64
+ }
65
+
66
+ return {
67
+ url: options.url,
68
+ paths: normalizePaths(options.paths),
69
+ forge,
70
+ targetBranch: options.branch ?? 'main',
71
+ writeBranch: options.writeBranch ?? 'mikser',
72
+ token: options.token,
73
+ message: options.message ?? DEFAULT_MESSAGE,
74
+ author: options.author,
75
+ afterMs: parseDuration(options.after, 60_000),
76
+ maxWaitMs: parseDuration(options.maxWait, 600_000),
77
+ pollIntervalMs: parseDuration(options.pollInterval, 300_000),
78
+ owner,
79
+ repo,
80
+ apiBase,
81
+ }
82
+ }
@@ -0,0 +1,26 @@
1
+ // Pure debounce-with-ceiling reducer for the green-gated sync timer.
2
+ //
3
+ // A green cycle should (re)start a short debounce (`after`) so a burst
4
+ // of quick edits collapses into one sync pass rather than one per
5
+ // cycle. But a pure trailing debounce never fires if green cycles keep
6
+ // arriving faster than `after` — so `maxWait` bounds how long a change
7
+ // can wait from the moment it FIRST became eligible, regardless of how
8
+ // many more green cycles follow.
9
+ //
10
+ // A red cycle clears the window outright: don't commit a state that's
11
+ // currently broken. When cycles turn green again later, that starts a
12
+ // fresh window — there's no reason to preserve how long a change was
13
+ // waiting before an interruption once the interruption has cleared.
14
+ //
15
+ // state shape: { pendingSince: number|null, fireAt: number|null }
16
+ // (both null means idle — nothing queued, no timer needed)
17
+ export function reduceDebounce(state, event, { afterMs, maxWaitMs }) {
18
+ if (event.type === 'red') {
19
+ return { pendingSince: null, fireAt: null }
20
+ }
21
+ const pendingSince = state.pendingSince ?? event.now
22
+ const fireAt = Math.min(event.now + afterMs, pendingSince + maxWaitMs)
23
+ return { pendingSince, fireAt }
24
+ }
25
+
26
+ export const IDLE_DEBOUNCE_STATE = { pendingSince: null, fireAt: null }
@@ -0,0 +1,14 @@
1
+ // Parse a duration config value: a plain number of ms, or a string
2
+ // like '1m', '30s', '10m', '1h'. Same shape as other mikser-io sibling
3
+ // plugins' duration configs (e.g. mikser-io-post-email's maxDelay) —
4
+ // not shared via import (no cross-plugin imports), just a consistent
5
+ // small convention repeated where it's needed.
6
+ export function parseDuration(value, fallback) {
7
+ if (value == null) return fallback
8
+ if (typeof value === 'number') return value
9
+ const m = /^\s*(\d+)\s*(ms|s|m|h|d)\s*$/i.exec(String(value))
10
+ if (!m) throw new Error(`git: invalid duration "${value}" (expected e.g. "1m", "30s", "10m", "1h")`)
11
+ const n = Number(m[1])
12
+ const u = m[2].toLowerCase()
13
+ return n * ({ ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[u])
14
+ }
@@ -0,0 +1,71 @@
1
+ // Gitea forge adapter. Gitea's API is deliberately modeled on GitHub's
2
+ // for pull requests, but with real differences confirmed against
3
+ // Gitea's own route definitions (routers/api/v1/api.go):
4
+ // - base path is <instance-url>/api/v1, not a fixed host
5
+ // - auth header is `Authorization: token <token>` (Gitea's documented
6
+ // scheme), not `Bearer`
7
+ // - merge is POST .../pulls/{index}/merge with body `{ Do: 'merge' }`
8
+ // (GitHub: PUT .../merge with `{ merge_method }`) — there is NO
9
+ // bare "merge branch A into B" endpoint; merge-upstream exists but
10
+ // is a different feature (syncing a fork from its upstream), so
11
+ // the pull-request path is the only portable one.
12
+ // The head/base ref field names on a listed PR (`head.ref` / `base.ref`)
13
+ // are assumed to mirror GitHub's shape (Gitea's PullRequest struct is
14
+ // modeled the same way) but are NOT independently verified here — if
15
+ // they don't match, ensurePR's existing-PR lookup simply finds nothing
16
+ // and creates a new PR. Worst case is a duplicate open PR, never lost
17
+ // content, so this is a safe direction to be wrong in.
18
+ //
19
+ // Same contract as lib/forge/github.js:
20
+ // ensurePR({...}) -> { number, url }
21
+ // mergePR({...}) -> { merged: true } | { merged: false, reason }
22
+
23
+ function headers(token) {
24
+ return {
25
+ 'Authorization': `token ${token}`,
26
+ 'Accept': 'application/json',
27
+ 'Content-Type': 'application/json',
28
+ }
29
+ }
30
+
31
+ async function bestEffortMessage(res) {
32
+ try {
33
+ const body = await res.json()
34
+ return body?.message ?? res.statusText
35
+ } catch {
36
+ return res.statusText
37
+ }
38
+ }
39
+
40
+ export async function ensurePR({ apiBase, owner, repo, head, base, title, token, fetchImpl = fetch }) {
41
+ const listRes = await fetchImpl(
42
+ `${apiBase}/api/v1/repos/${owner}/${repo}/pulls?state=open`,
43
+ { headers: headers(token) },
44
+ )
45
+ if (listRes.ok) {
46
+ const list = await listRes.json()
47
+ const match = Array.isArray(list)
48
+ ? list.find(pr => pr.head?.ref === head && pr.base?.ref === base)
49
+ : null
50
+ if (match) return { number: match.number, url: match.html_url ?? match.url }
51
+ }
52
+
53
+ const created = await fetchImpl(
54
+ `${apiBase}/api/v1/repos/${owner}/${repo}/pulls`,
55
+ { method: 'POST', headers: headers(token), body: JSON.stringify({ title, head, base }) },
56
+ )
57
+ if (!created.ok) {
58
+ throw new Error(`Gitea: failed to create PR ${head} → ${base}: ${await bestEffortMessage(created)}`)
59
+ }
60
+ const pr = await created.json()
61
+ return { number: pr.number, url: pr.html_url ?? pr.url }
62
+ }
63
+
64
+ export async function mergePR({ apiBase, owner, repo, number, token, fetchImpl = fetch }) {
65
+ const res = await fetchImpl(
66
+ `${apiBase}/api/v1/repos/${owner}/${repo}/pulls/${number}/merge`,
67
+ { method: 'POST', headers: headers(token), body: JSON.stringify({ Do: 'merge' }) },
68
+ )
69
+ if (res.ok) return { merged: true }
70
+ return { merged: false, reason: await bestEffortMessage(res) }
71
+ }
@@ -0,0 +1,64 @@
1
+ // GitHub forge adapter — create-or-reuse a PR from the write branch
2
+ // onto the target branch, and attempt to merge it.
3
+ //
4
+ // `fetchImpl` is injectable (defaults to the global fetch) purely so
5
+ // tests can supply a mock without a network or a real token.
6
+ //
7
+ // Contract shared with lib/forge/gitea.js:
8
+ // ensurePR({...}) -> { number, url }
9
+ // mergePR({...}) -> { merged: true } | { merged: false, reason }
10
+ // Any non-2xx response is treated as "not merged" with a best-effort
11
+ // reason string — this plugin never needs to distinguish a real merge
12
+ // conflict from a permissions error or a moved base branch; either way
13
+ // the PR stays open and a human looks at it.
14
+
15
+ function headers(token) {
16
+ return {
17
+ 'Authorization': `Bearer ${token}`,
18
+ 'Accept': 'application/vnd.github+json',
19
+ 'X-GitHub-Api-Version': '2022-11-28',
20
+ 'Content-Type': 'application/json',
21
+ // GitHub requires a User-Agent on API requests; any non-empty
22
+ // value satisfies it.
23
+ 'User-Agent': 'mikser-io-git',
24
+ }
25
+ }
26
+
27
+ async function bestEffortMessage(res) {
28
+ try {
29
+ const body = await res.json()
30
+ return body?.message ?? res.statusText
31
+ } catch {
32
+ return res.statusText
33
+ }
34
+ }
35
+
36
+ export async function ensurePR({ apiBase = 'https://api.github.com', owner, repo, head, base, title, token, fetchImpl = fetch }) {
37
+ const existing = await fetchImpl(
38
+ `${apiBase}/repos/${owner}/${repo}/pulls?head=${owner}:${head}&base=${base}&state=open`,
39
+ { headers: headers(token) },
40
+ )
41
+ if (existing.ok) {
42
+ const list = await existing.json()
43
+ if (list.length > 0) return { number: list[0].number, url: list[0].html_url }
44
+ }
45
+
46
+ const created = await fetchImpl(
47
+ `${apiBase}/repos/${owner}/${repo}/pulls`,
48
+ { method: 'POST', headers: headers(token), body: JSON.stringify({ title, head, base }) },
49
+ )
50
+ if (!created.ok) {
51
+ throw new Error(`GitHub: failed to create PR ${head} → ${base}: ${await bestEffortMessage(created)}`)
52
+ }
53
+ const pr = await created.json()
54
+ return { number: pr.number, url: pr.html_url }
55
+ }
56
+
57
+ export async function mergePR({ apiBase = 'https://api.github.com', owner, repo, number, token, fetchImpl = fetch }) {
58
+ const res = await fetchImpl(
59
+ `${apiBase}/repos/${owner}/${repo}/pulls/${number}/merge`,
60
+ { method: 'PUT', headers: headers(token), body: JSON.stringify({ merge_method: 'merge' }) },
61
+ )
62
+ if (res.ok) return { merged: true }
63
+ return { merged: false, reason: await bestEffortMessage(res) }
64
+ }
package/lib/git.js ADDED
@@ -0,0 +1,191 @@
1
+ // Thin wrapper over the `git` binary. Every call goes through execFile
2
+ // with argv arrays — never a shell string — so a commit message or
3
+ // branch name built from entity ids (arbitrary content from documents)
4
+ // can never be interpreted as a shell command.
5
+ //
6
+ // `run` is the one low-level primitive; everything else composes it.
7
+ // Kept thin and exported individually so tests can stub `run` and
8
+ // exercise the higher-level functions' argv-building and result
9
+ // parsing without a real git binary or network.
10
+
11
+ import { execFile } from 'node:child_process'
12
+ import { promisify } from 'node:util'
13
+
14
+ const execFileAsync = promisify(execFile)
15
+
16
+ // Run a git subcommand in `cwd`. Returns trimmed stdout on success;
17
+ // throws with `.stderr` and `.code` attached on failure (execFile's
18
+ // own shape) so callers can branch on specific failures (e.g. a
19
+ // non-fast-forward push) without string-matching stdout.
20
+ export async function run(cwd, args, opts = {}) {
21
+ try {
22
+ const { stdout } = await execFileAsync('git', args, { cwd, ...opts })
23
+ return stdout.trim()
24
+ } catch (err) {
25
+ // execFile already attaches .code / .stderr / .stdout to the
26
+ // error; re-throw as-is so callers can inspect them.
27
+ throw err
28
+ }
29
+ }
30
+
31
+ export async function isInsideWorkTree(folder) {
32
+ try {
33
+ const out = await run(folder, ['rev-parse', '--is-inside-work-tree'])
34
+ return out === 'true'
35
+ } catch {
36
+ return false
37
+ }
38
+ }
39
+
40
+ export async function currentBranch(folder) {
41
+ return run(folder, ['rev-parse', '--abbrev-ref', 'HEAD'])
42
+ }
43
+
44
+ export async function remoteUrl(folder, remote = 'origin') {
45
+ try {
46
+ return await run(folder, ['remote', 'get-url', remote])
47
+ } catch {
48
+ return null
49
+ }
50
+ }
51
+
52
+ // Auth via a short-lived per-request header, never embedded in the
53
+ // remote URL or written to .git/config — an embedded
54
+ // `https://token@host/...` remote leaks the token into `git remote -v`
55
+ // output and any log/error that echoes the URL. `http.extraheader` is
56
+ // passed as a one-off `-c` flag on the specific command that needs it
57
+ // (fetch/pull/push/clone), so it never persists to disk.
58
+ function authArgs(token) {
59
+ if (!token) return []
60
+ const b64 = Buffer.from(`x-access-token:${token}`).toString('base64')
61
+ return ['-c', `http.extraheader=AUTHORIZATION: basic ${b64}`]
62
+ }
63
+
64
+ export async function clone(url, folder, { branch, token } = {}) {
65
+ const args = [...authArgs(token), 'clone', ...(branch ? ['--branch', branch] : []), url, folder]
66
+ // clone's cwd doesn't matter (destination is a full path); run from
67
+ // the parent so a not-yet-existing `folder` isn't required as cwd.
68
+ await run('.', args)
69
+ }
70
+
71
+ export async function fetch(folder, { remote = 'origin', token } = {}) {
72
+ await run(folder, [...authArgs(token), 'fetch', remote])
73
+ }
74
+
75
+ export async function push(folder, refspec, { remote = 'origin', token, force = false } = {}) {
76
+ const args = [...authArgs(token), 'push', ...(force ? ['--force-with-lease'] : []), remote, refspec]
77
+ await run(folder, args)
78
+ }
79
+
80
+ // Non-fast-forward pushes throw with stderr containing "non-fast-forward"
81
+ // or "fetch first" (exact wording varies by git version/remote); callers
82
+ // that need to distinguish "rejected, needs integration" from other
83
+ // failures should check this rather than assuming any push() throw
84
+ // means the same thing.
85
+ export function isNonFastForwardError(err) {
86
+ const msg = `${err?.stderr ?? ''} ${err?.message ?? ''}`
87
+ return /non-fast-forward|fetch first|rejected/i.test(msg)
88
+ }
89
+
90
+ // `paths`, when given, scopes the command to those pathspecs via git's
91
+ // `--` separator — e.g. `['documents', 'layouts']`. This is the actual
92
+ // enforcement mechanism behind "this plugin only touches the folders
93
+ // you configured": mikser.config.js, node_modules/, runtime/, out/,
94
+ // .env can all sit in the SAME checkout (the working folder IS the
95
+ // checkout — see index.js) without ever being staged, added, or
96
+ // committed, because they're simply not in the pathspec. Omit `paths`
97
+ // for the rare whole-repo case (nothing in this plugin currently needs
98
+ // that, but the functions stay generally usable).
99
+ function pathspecArgs(paths) {
100
+ return paths?.length ? ['--', ...paths] : []
101
+ }
102
+
103
+ export async function statusPorcelain(folder, paths) {
104
+ // --untracked-files=all: without it, git collapses an entire new
105
+ // untracked directory into ONE porcelain line ("?? newdir/") rather
106
+ // than one line per file inside it. Harmless for hasChanges() (any
107
+ // non-empty output still means "changed"), but sync.js derives its
108
+ // commit message's file count from this output's line count — with
109
+ // the default, a brand-new author's folder or category directory
110
+ // would silently undercount (confirmed against a real git repo: a
111
+ // 3-file new directory reported as a single line). `git add`
112
+ // itself was never affected by this — it always stages everything
113
+ // in its scope regardless of how status reports it — this only
114
+ // fixes what the commit message claims happened.
115
+ return run(folder, ['status', '--porcelain', '--untracked-files=all', ...pathspecArgs(paths)])
116
+ }
117
+
118
+ export async function hasChanges(folder, paths) {
119
+ return (await statusPorcelain(folder, paths)).length > 0
120
+ }
121
+
122
+ export async function addAll(folder, paths) {
123
+ // `-A` with a pathspec stages new/modified/deleted files WITHIN
124
+ // that pathspec only — it does not fall back to staging the whole
125
+ // repo. Verified directly (see test/git.test.js) before relying on
126
+ // it as the scope boundary.
127
+ await run(folder, ['add', '-A', ...pathspecArgs(paths)])
128
+ }
129
+
130
+ export async function commit(folder, message, { author } = {}) {
131
+ const args = ['commit', '-m', message]
132
+ if (author?.name) args.push('--author', `${author.name} <${author.email ?? ''}>`)
133
+ await run(folder, args)
134
+ }
135
+
136
+ // True when there's nothing staged to commit — the caller's signal to
137
+ // skip commit() entirely rather than treat "nothing to commit" as an
138
+ // error. Cheaper than parsing commit()'s own failure for this one case.
139
+ export async function hasStagedChanges(folder, paths) {
140
+ try {
141
+ // --quiet + --exit-code: exits 1 if there ARE differences
142
+ // (i.e. something staged), 0 if the index matches HEAD.
143
+ await run(folder, ['diff', '--cached', '--quiet', ...pathspecArgs(paths)])
144
+ return false
145
+ } catch {
146
+ return true
147
+ }
148
+ }
149
+
150
+ export async function checkoutBranch(folder, branch, { create = false, startPoint } = {}) {
151
+ const args = ['checkout', ...(create ? ['-b'] : []), branch, ...(startPoint ? [startPoint] : [])]
152
+ await run(folder, args)
153
+ }
154
+
155
+ export async function branchExistsLocal(folder, branch) {
156
+ try {
157
+ await run(folder, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`])
158
+ return true
159
+ } catch {
160
+ return false
161
+ }
162
+ }
163
+
164
+ export async function branchExistsRemote(folder, branch, { remote = 'origin', token } = {}) {
165
+ // ls-remote hits the network but needs no local ref state — safe
166
+ // to call before any fetch has happened.
167
+ const out = await run(folder, [...authArgs(token), 'ls-remote', '--heads', remote, branch])
168
+ return out.length > 0
169
+ }
170
+
171
+ // Reset the current branch (HARD — working tree included) to match
172
+ // `ref` exactly. Used only to converge the local `mikser` branch onto
173
+ // origin/<target> after a successful PR merge, or to recover from a
174
+ // rejected push by re-basing onto the fetched remote. Never used to
175
+ // discard a user's uncommitted work — every call site commits first.
176
+ export async function resetHard(folder, ref) {
177
+ await run(folder, ['reset', '--hard', ref])
178
+ }
179
+
180
+ export async function mergeBranch(folder, ref, { message } = {}) {
181
+ const args = ['merge', '--no-edit', ...(message ? ['-m', message] : []), ref]
182
+ await run(folder, args)
183
+ }
184
+
185
+ export async function abortMerge(folder) {
186
+ try { await run(folder, ['merge', '--abort']) } catch { /* nothing to abort */ }
187
+ }
188
+
189
+ export async function revParse(folder, ref) {
190
+ return run(folder, ['rev-parse', ref])
191
+ }
package/lib/inbound.js ADDED
@@ -0,0 +1,43 @@
1
+ // Pull remote changes into the working folder. Two things can have
2
+ // moved on the remote since our last fetch:
3
+ // - the write branch itself (a human pushed directly to it instead
4
+ // of going through the CMS — discouraged but not prevented)
5
+ // - the target branch (a promotion merged, or a human pushed/merged
6
+ // directly to it)
7
+ // Both are merged in sequentially (not as one octopus merge) so a
8
+ // conflict is attributable to a specific branch and doesn't abort a
9
+ // merge that would otherwise have succeeded.
10
+ //
11
+ // On ANY conflict, the merge is aborted immediately — never left
12
+ // half-applied. This folder is mikser's live working copy; leaving
13
+ // conflict markers in a file would mean the render pipeline reads
14
+ // "<<<<<<< HEAD" as page content on the next cycle. Abort-and-log is
15
+ // the only acceptable outcome here.
16
+ //
17
+ // Note: this module ONLY performs the merge. It doesn't need to wake
18
+ // mikser's watch loop itself — `git merge` writes real files via
19
+ // normal filesystem writes, which the file source's own chokidar
20
+ // watcher already sees as ordinary 'change' events (same as a human
21
+ // editing a file), and manager.js's watch() wakes the process loop
22
+ // from that automatically.
23
+
24
+ import * as git from './git.js'
25
+
26
+ export async function pullInbound(folder, { writeBranch, targetBranch, token, logger }) {
27
+ await git.fetch(folder, { token })
28
+
29
+ for (const ref of [writeBranch, targetBranch]) {
30
+ try {
31
+ await git.mergeBranch(folder, `origin/${ref}`)
32
+ } catch (err) {
33
+ await git.abortMerge(folder)
34
+ logger?.error(
35
+ 'git: inbound merge of origin/%s conflicted — aborted, working folder left untouched. ' +
36
+ 'Resolve manually: cd <folder> && git merge origin/%s (or origin/%s) and fix the conflicts. %s',
37
+ ref, writeBranch, targetBranch, err.stderr || err.message,
38
+ )
39
+ return { merged: false, conflictedRef: ref, reason: err.stderr || err.message }
40
+ }
41
+ }
42
+ return { merged: true }
43
+ }
@@ -0,0 +1,28 @@
1
+ // Derive { owner, repo, apiOrigin } from a plain repo URL, so config
2
+ // only needs `url` + `forge` — not four separately-specified fields
3
+ // that would drift from the clone URL if ever changed independently.
4
+ //
5
+ // apiOrigin is the scheme+host the forge adapter's API lives at. For
6
+ // github.com this is NOT the repo's own host — GitHub's API is on
7
+ // api.github.com, a different subdomain — so github.com URLs get that
8
+ // origin by default. Self-hosted GitHub Enterprise Server serves its
9
+ // API at <host>/api/v3 instead; that's a real case this doesn't derive
10
+ // automatically — pass `apiBase` explicitly for GHES. Gitea (and any
11
+ // other self-hosted forge) serves its API at the SAME host the repo
12
+ // lives on, just under /api/v1 (which the gitea adapter appends
13
+ // itself) — so the repo's own origin is the correct default there.
14
+ export function parseRepoUrl(url) {
15
+ let u
16
+ try {
17
+ u = new URL(url)
18
+ } catch {
19
+ throw new Error(`git: "${url}" is not a valid URL`)
20
+ }
21
+ const parts = u.pathname.replace(/^\/+/, '').replace(/\.git$/, '').replace(/\/+$/, '').split('/')
22
+ if (parts.length < 2 || !parts[0] || !parts[1]) {
23
+ throw new Error(`git: cannot derive owner/repo from url "${url}" — expected .../owner/repo(.git)`)
24
+ }
25
+ const [owner, repo] = parts
26
+ const apiOrigin = u.host === 'github.com' ? 'https://api.github.com' : u.origin
27
+ return { owner, repo, apiOrigin }
28
+ }
package/lib/sync.js ADDED
@@ -0,0 +1,123 @@
1
+ // Orchestrates one sync pass: commit whatever's on disk to the write
2
+ // branch (always — this is the durable log, it must never block),
3
+ // then try to promote the write branch into the target branch (only
4
+ // when the caller has already determined the last cycle was green).
5
+ //
6
+ // Two promotion mechanisms:
7
+ // - forge 'github' | 'gitea': open (or reuse) a PR write→target and
8
+ // attempt to merge it. Conflict or any other failure just leaves
9
+ // the PR open — never a crash, never data loss, always visible.
10
+ // - forge 'none': a direct fast-forward push write:target. Works
11
+ // against any bare remote with no API at all. If target has
12
+ // diverged, the push is rejected (also not a crash) and everything
13
+ // stays queued on the write branch until a human intervenes.
14
+
15
+ import * as git from './git.js'
16
+ import * as github from './forge/github.js'
17
+ import * as gitea from './forge/gitea.js'
18
+
19
+ const ADAPTERS = { github, gitea }
20
+
21
+ // Commit whatever's currently on disk and push it to the write branch.
22
+ // Returns { committed: boolean, pushed: boolean }. Never throws for
23
+ // "nothing to commit" — that's the common, expected case on a quiet
24
+ // cycle. Retries the push once after a pull --rebase-equivalent
25
+ // (fetch + reset onto the remote tip, since this branch is bot-owned
26
+ // and a local commit on top of a stale base is fine to replay) if the
27
+ // initial push is rejected as non-fast-forward — covers the case
28
+ // where an inbound pull (lib/inbound.js) advanced origin/<writeBranch>
29
+ // between this cycle's fetch and its push.
30
+ // `paths` scopes every git operation here to those pathspecs (relative
31
+ // to `folder`, the working folder / repo root) — see git.js's
32
+ // pathspecArgs. This is the hard boundary that lets mikser.config.js,
33
+ // node_modules/, runtime/, out/, and .env live in the SAME checkout
34
+ // this plugin manages without ever being staged or committed by it.
35
+ //
36
+ // `message` is a string or a function receiving { fileCount } (from
37
+ // `git status --porcelain`'s line count, computed before staging) and
38
+ // returning a string — a count rather than a file list, since a full
39
+ // list of paths in a commit message gets unwieldy past a handful of
40
+ // 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
+ const porcelain = await git.statusPorcelain(folder, paths)
43
+ if (!porcelain) return { committed: false, pushed: false }
44
+ const fileCount = porcelain.split('\n').filter(Boolean).length
45
+
46
+ await git.addAll(folder, paths)
47
+ if (!(await git.hasStagedChanges(folder, paths))) {
48
+ // hasChanges() can be true from untracked files git add didn't
49
+ // end up staging as a diff from HEAD in edge cases (e.g. a
50
+ // file that matches .gitignore was force-added) — defensive,
51
+ // should not normally happen.
52
+ return { committed: false, pushed: false }
53
+ }
54
+ const resolvedMessage = typeof message === 'function' ? message({ fileCount }) : message
55
+ await git.commit(folder, resolvedMessage, { author })
56
+
57
+ try {
58
+ await git.push(folder, writeBranch, { token })
59
+ return { committed: true, pushed: true }
60
+ } catch (err) {
61
+ if (!git.isNonFastForwardError(err)) throw err
62
+ // The remote write branch moved (an inbound pull landed
63
+ // between our last fetch and now). Rebase our new commit onto
64
+ // the fresh remote tip and retry once. This branch is
65
+ // exclusively bot-written, so replaying our commit on top of
66
+ // the latest remote state is always safe — there's no other
67
+ // writer's history to preserve alongside it beyond what
68
+ // inbound sync already merged in.
69
+ await git.fetch(folder, { token })
70
+ await git.run(folder, ['rebase', `origin/${writeBranch}`])
71
+ await git.push(folder, writeBranch, { token })
72
+ return { committed: true, pushed: true }
73
+ }
74
+ }
75
+
76
+ // Attempt to promote the write branch into the target branch. Called
77
+ // only when the caller has confirmed the cycle is green — this
78
+ // function does not itself check for render/postprocess failures.
79
+ //
80
+ // Returns { promoted: boolean, reason?, prUrl? }.
81
+ export async function promote(folder, {
82
+ forge, targetBranch, writeBranch, token,
83
+ owner, repo, apiBase, prTitle, fetchImpl,
84
+ }) {
85
+ if (forge === 'none') {
86
+ try {
87
+ await git.push(folder, `${writeBranch}:${targetBranch}`, { token })
88
+ return { promoted: true }
89
+ } catch (err) {
90
+ if (!git.isNonFastForwardError(err)) throw err
91
+ return {
92
+ promoted: false,
93
+ reason: `${targetBranch} has diverged from ${writeBranch}; fast-forward push rejected. ` +
94
+ `Configure a forge adapter (github/gitea) to get a pull request here instead of a ` +
95
+ `manual merge, or merge ${writeBranch} into ${targetBranch} by hand.`,
96
+ }
97
+ }
98
+ }
99
+
100
+ const adapter = ADAPTERS[forge]
101
+ if (!adapter) throw new Error(`git: unknown forge "${forge}" (expected github, gitea, or none)`)
102
+
103
+ const pr = await adapter.ensurePR({
104
+ apiBase, owner, repo, head: writeBranch, base: targetBranch, title: prTitle, token, fetchImpl,
105
+ })
106
+ const result = await adapter.mergePR({ apiBase, owner, repo, number: pr.number, token, fetchImpl })
107
+
108
+ if (!result.merged) {
109
+ return { promoted: false, reason: result.reason, prUrl: pr.url }
110
+ }
111
+
112
+ // Converge: the target branch just advanced past the write
113
+ // branch's old tip (via a merge commit or squash). Pull the write
114
+ // branch up to match so next cycle's commit doesn't keep re-diffing
115
+ // against an increasingly stale base. force-with-lease is safe here
116
+ // — this branch is exclusively bot-written, nothing else's history
117
+ // is at risk, and --force-with-lease still refuses if the remote
118
+ // moved unexpectedly since our last fetch.
119
+ await git.fetch(folder, { token })
120
+ await git.resetHard(folder, `origin/${targetBranch}`)
121
+ await git.push(folder, writeBranch, { token, force: true })
122
+ return { promoted: true, prUrl: pr.url }
123
+ }