mikser-io-git 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +225 -0
- package/index.js +197 -0
- package/lib/bootstrap.js +146 -0
- package/lib/config.js +82 -0
- package/lib/debounce.js +26 -0
- package/lib/duration.js +14 -0
- package/lib/forge/gitea.js +71 -0
- package/lib/forge/github.js +64 -0
- package/lib/git.js +191 -0
- package/lib/inbound.js +43 -0
- package/lib/repo-url.js +28 -0
- package/lib/sync.js +123 -0
- package/package.json +23 -0
- package/test/bootstrap.test.js +59 -0
- package/test/config.test.js +101 -0
- package/test/debounce.test.js +57 -0
- package/test/duration.test.js +32 -0
- package/test/forge/gitea.test.js +91 -0
- package/test/forge/github.test.js +70 -0
- package/test/git.test.js +220 -0
- package/test/repo-url.test.js +35 -0
package/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
|
+
}
|
package/lib/debounce.js
ADDED
|
@@ -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 }
|
package/lib/duration.js
ADDED
|
@@ -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
|
+
}
|
package/lib/repo-url.js
ADDED
|
@@ -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
|
+
}
|