mikser-io-git 2.1.0 → 2.2.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 +15 -2
- package/index.js +28 -4
- package/lib/git.js +32 -15
- package/lib/inbound.js +36 -2
- package/lib/queue.js +24 -0
- package/package.json +7 -1
- package/test/bootstrap.test.js +0 -59
- package/test/config.test.js +0 -101
- package/test/debounce.test.js +0 -57
- package/test/duration.test.js +0 -32
- package/test/forge/gitea.test.js +0 -91
- package/test/forge/github.test.js +0 -70
- package/test/git.test.js +0 -220
- package/test/repo-url.test.js +0 -35
package/README.md
CHANGED
|
@@ -193,15 +193,28 @@ Both forges have *some* direct-merge concept, but they're not the same feature a
|
|
|
193
193
|
|
|
194
194
|
Pull requests, by contrast, are nearly identical between the two — `POST .../pulls { title, head, base }` creates one on both GitHub and Gitea. And a PR gives you something the direct-merge endpoints don't: a real conflict surface. A `/merges` 409 is a status code with nowhere to look; a conflicted PR is a page that names the exact files in conflict and offers to resolve them in the forge's own UI. That's a better fit for "leave it open, let a human resolve it" than either forge's direct-merge shortcut.
|
|
195
195
|
|
|
196
|
+
## Requirements
|
|
197
|
+
|
|
198
|
+
- **git 2.31 or newer** on the machine running mikser. The auth token is
|
|
199
|
+
delivered to git through `GIT_CONFIG_COUNT` / `GIT_CONFIG_KEY_n` /
|
|
200
|
+
`GIT_CONFIG_VALUE_n`, which older git ignores — it would fall back to
|
|
201
|
+
unauthenticated access and fail against a private repo. 2.31 shipped in
|
|
202
|
+
March 2021; `git --version` if unsure.
|
|
203
|
+
- No runtime npm dependencies. Everything goes through the `git` binary.
|
|
204
|
+
|
|
196
205
|
## Security
|
|
197
206
|
|
|
198
|
-
- **The auth token is never written to disk.** It's passed as a one-off `http.extraheader`
|
|
207
|
+
- **The auth token is never written to disk, and never appears in the process's arguments.** It's passed as a one-off `http.extraheader` for the specific git command that needs it (`clone`/`fetch`/`push`/`ls-remote`), delivered through the environment (`GIT_CONFIG_COUNT` / `GIT_CONFIG_KEY_0` / `GIT_CONFIG_VALUE_0`, git 2.31+) rather than as a `-c` argument, and never embedded in the remote URL.
|
|
208
|
+
|
|
209
|
+
Three places a credential can leak, and what each avoids: an embedded `https://token@host/...` remote persists into `.git/config` in plaintext and shows up in `git remote -v` and any log line echoing the remote; a `-c http.extraheader=...` argument lands in the process's argument list, which is world-readable on Linux (`/proc/<pid>/cmdline` is `-r--r--r--`, while `/proc/<pid>/environ` is `-r--------`) and can surface in an `err.stderr` a caller then logs; the environment form has neither property and the same one-command lifetime. Note that base64 here is encoding, not encryption — it is the HTTP Basic wire format, so keeping it out of world-readable places is the whole protection.
|
|
199
210
|
- **Every git invocation goes through `execFile` with an argv array — never a shell string.** Commit messages are built from a file count, not raw content, but nothing here ever risks passing arbitrary content through a shell regardless.
|
|
211
|
+
- **A timer body can never end the process.** Both schedulers here — the debounced sync pass and the inbound poll — run for the life of a watch server, and Node has treated an unhandled rejection as fatal since v15 while mikser installs no process-level handler. `pullInbound` returns a result shape for every outcome including a failed `fetch`, and both callbacks are wrapped besides: a transient remote failure must not take down the build and the site, least of all in a supervisor restart loop.
|
|
212
|
+
- **Git operations are serialised per instance.** The sync pass and the inbound poll share one checkout; without a queue they overlap and lose to `index.lock`, or worse, commit while an inbound merge is in progress.
|
|
200
213
|
- **The write branch is force-pushed only after a successful promotion**, using `--force-with-lease` (refuses if the remote moved unexpectedly since the last fetch) rather than a bare `--force`. This is safe specifically because `mikser`/`writeBranch` is a branch this plugin owns exclusively — nothing else's history is ever at risk on it.
|
|
201
214
|
|
|
202
215
|
## Verified end-to-end
|
|
203
216
|
|
|
204
|
-
The unit suite (
|
|
217
|
+
The unit suite (73 tests) covers every pure module directly, the forge adapters via an injected `fetchImpl` mock, and — critically for the working-folder-as-checkout model — the pathspec scope itself against a real temp git repo (`test/git.test.js`'s "pathspec scoping" suite: a config-file edit and a whole new out-of-scope directory are both proven invisible to a `paths`-scoped add, while an in-scope file commits normally). Beyond the unit suite, this has been run against **real GitHub repos and mikser's own example blog** — not just mocks:
|
|
205
218
|
|
|
206
219
|
- **The "adopt an existing non-empty folder" recipe** (see [First connect](#first-connect--and-why-it-can-refuse-to-guess)), run by hand exactly as documented, against the real blog's working folder — `mikser.config.js`, `node_modules/`, `layouts/`, `documents/`, everything — and a fresh throwaway GitHub repo. `refuse` fired correctly on the very first connect attempt (confirming that's now the expected first-run path, not an edge case); the manual recipe attached history without touching a file; `git status` afterward showed exactly the expected divergence.
|
|
207
220
|
- **The `paths` scope boundary, live, not just unit-tested.** With `paths: ['documents', 'layouts']` on that same checkout: a build with only a `mikser.config.js`/`LICENSE` edit produced **zero commits** — no `git: committed + pushed` line at all; a build with a real `layouts/` edit committed, pushed, and promoted normally. A fresh clone of the repo afterward showed the tree contained **only** `documents/`, `layouts/`, and the seed file — no `mikser.config.js`, no `node_modules`, nothing leaked from outside `paths`.
|
package/index.js
CHANGED
|
@@ -53,9 +53,24 @@ import { gatherFolderState, decideBootstrap, performClone, performVerify } from
|
|
|
53
53
|
import { commitAndPushWriteBranch, promote } from './lib/sync.js'
|
|
54
54
|
import { pullInbound } from './lib/inbound.js'
|
|
55
55
|
import { reduceDebounce, IDLE_DEBOUNCE_STATE } from './lib/debounce.js'
|
|
56
|
+
import { createGitQueue } from './lib/queue.js'
|
|
56
57
|
|
|
57
58
|
const REANNOUNCE_MS = 30 * 60 * 1000 // re-log a still-open conflict at most every 30 min
|
|
58
59
|
|
|
60
|
+
// Run a timer body so that nothing it does can end the process.
|
|
61
|
+
//
|
|
62
|
+
// Both schedulers here fire for the life of a watch server, and an async
|
|
63
|
+
// callback that rejects is fatal — Node has treated an unhandled rejection
|
|
64
|
+
// as process-ending since v15, and mikser core installs no handler. The
|
|
65
|
+
// failure mode this prevents is a transient remote error taking down the
|
|
66
|
+
// build AND the site, and looping under a supervisor.
|
|
67
|
+
function withGuard(logger, what, fn) {
|
|
68
|
+
Promise.resolve()
|
|
69
|
+
.then(fn)
|
|
70
|
+
.catch(err => logger?.error('git: %s failed — %s', what, err?.stderr || err?.message || err))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
59
74
|
export function git(options = {}) {
|
|
60
75
|
const {
|
|
61
76
|
url, paths, forge, targetBranch, writeBranch,
|
|
@@ -69,6 +84,7 @@ export function git(options = {}) {
|
|
|
69
84
|
// scopes this instance's reach within it.
|
|
70
85
|
const folder = runtime.options.workingFolder
|
|
71
86
|
|
|
87
|
+
const enqueueGit = createGitQueue()
|
|
72
88
|
let debounceState = IDLE_DEBOUNCE_STATE
|
|
73
89
|
let timer = null
|
|
74
90
|
let pollTimer = null
|
|
@@ -111,7 +127,7 @@ export function git(options = {}) {
|
|
|
111
127
|
function scheduleFire(logger) {
|
|
112
128
|
if (timer) clearTimeout(timer)
|
|
113
129
|
const delay = Math.max(0, debounceState.fireAt - Date.now())
|
|
114
|
-
timer = setTimeout(() => runSyncPass(logger), delay)
|
|
130
|
+
timer = setTimeout(() => withGuard(logger, 'sync pass', () => enqueueGit(() => runSyncPass(logger))), delay)
|
|
115
131
|
timer.unref?.()
|
|
116
132
|
}
|
|
117
133
|
|
|
@@ -148,9 +164,17 @@ export function git(options = {}) {
|
|
|
148
164
|
// "later" to pull into. Webhook delivery is not implemented
|
|
149
165
|
// (see README); poll is the only supported inbound trigger.
|
|
150
166
|
if (runtime.options.watch && pollIntervalMs > 0) {
|
|
151
|
-
pollTimer = setInterval(
|
|
167
|
+
pollTimer = setInterval(() => {
|
|
152
168
|
if (inert) return
|
|
153
|
-
|
|
169
|
+
// Belt AND braces. pullInbound is written not to throw,
|
|
170
|
+
// but a timer callback is the one place where being
|
|
171
|
+
// wrong about that is fatal rather than noisy: Node has
|
|
172
|
+
// treated an unhandled rejection as process-ending since
|
|
173
|
+
// v15, and mikser installs no handler. Anything reaching
|
|
174
|
+
// here is a bug worth logging, not worth killing the
|
|
175
|
+
// build server for.
|
|
176
|
+
withGuard(logger, 'inbound poll', () => enqueueGit(() =>
|
|
177
|
+
pullInbound(folder, { writeBranch, targetBranch, token, logger })))
|
|
154
178
|
}, pollIntervalMs)
|
|
155
179
|
pollTimer.unref?.()
|
|
156
180
|
}
|
|
@@ -180,7 +204,7 @@ export function git(options = {}) {
|
|
|
180
204
|
logger.warn('git: build had failures (%s) — not syncing', culprits.join(', '))
|
|
181
205
|
return
|
|
182
206
|
}
|
|
183
|
-
await runSyncPass(logger)
|
|
207
|
+
await enqueueGit(() => runSyncPass(logger))
|
|
184
208
|
return
|
|
185
209
|
}
|
|
186
210
|
|
package/lib/git.js
CHANGED
|
@@ -49,32 +49,49 @@ export async function remoteUrl(folder, remote = 'origin') {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
// Auth via a short-lived per-
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
52
|
+
// Auth via a short-lived per-command header, never embedded in the remote
|
|
53
|
+
// URL or written to .git/config — an embedded `https://token@host/...`
|
|
54
|
+
// remote leaks the token into `git remote -v` output and any log or error
|
|
55
|
+
// that echoes the URL.
|
|
56
|
+
//
|
|
57
|
+
// Delivered through the ENVIRONMENT rather than as a `-c` argument.
|
|
58
|
+
// Same semantics, same one-command lifetime, same no-persistence — but a
|
|
59
|
+
// process's arguments are world-readable and its environment is not:
|
|
60
|
+
//
|
|
61
|
+
// -r--r--r-- /proc/<pid>/cmdline
|
|
62
|
+
// -r-------- /proc/<pid>/environ
|
|
63
|
+
//
|
|
64
|
+
// As a `-c http.extraheader=...` argument the base64 credential is
|
|
65
|
+
// readable by any local user for as long as the git subprocess runs
|
|
66
|
+
// (base64 is encoding, not encryption), and it can surface in an
|
|
67
|
+
// `err.stderr` that a caller then logs. GIT_CONFIG_COUNT / _KEY_n /
|
|
68
|
+
// _VALUE_n is git's own supported equivalent (2.31+) and keeps it out of
|
|
69
|
+
// both places.
|
|
70
|
+
function authEnv(token) {
|
|
71
|
+
if (!token) return undefined
|
|
60
72
|
const b64 = Buffer.from(`x-access-token:${token}`).toString('base64')
|
|
61
|
-
return
|
|
73
|
+
return {
|
|
74
|
+
...process.env,
|
|
75
|
+
GIT_CONFIG_COUNT: '1',
|
|
76
|
+
GIT_CONFIG_KEY_0: 'http.extraheader',
|
|
77
|
+
GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${b64}`,
|
|
78
|
+
}
|
|
62
79
|
}
|
|
63
80
|
|
|
64
81
|
export async function clone(url, folder, { branch, token } = {}) {
|
|
65
|
-
const args = [
|
|
82
|
+
const args = ['clone', ...(branch ? ['--branch', branch] : []), url, folder]
|
|
66
83
|
// clone's cwd doesn't matter (destination is a full path); run from
|
|
67
84
|
// the parent so a not-yet-existing `folder` isn't required as cwd.
|
|
68
|
-
await run('.', args)
|
|
85
|
+
await run('.', args, { env: authEnv(token) })
|
|
69
86
|
}
|
|
70
87
|
|
|
71
88
|
export async function fetch(folder, { remote = 'origin', token } = {}) {
|
|
72
|
-
await run(folder, [
|
|
89
|
+
await run(folder, ['fetch', remote], { env: authEnv(token) })
|
|
73
90
|
}
|
|
74
91
|
|
|
75
92
|
export async function push(folder, refspec, { remote = 'origin', token, force = false } = {}) {
|
|
76
|
-
const args = [
|
|
77
|
-
await run(folder, args)
|
|
93
|
+
const args = ['push', ...(force ? ['--force-with-lease'] : []), remote, refspec]
|
|
94
|
+
await run(folder, args, { env: authEnv(token) })
|
|
78
95
|
}
|
|
79
96
|
|
|
80
97
|
// Non-fast-forward pushes throw with stderr containing "non-fast-forward"
|
|
@@ -164,7 +181,7 @@ export async function branchExistsLocal(folder, branch) {
|
|
|
164
181
|
export async function branchExistsRemote(folder, branch, { remote = 'origin', token } = {}) {
|
|
165
182
|
// ls-remote hits the network but needs no local ref state — safe
|
|
166
183
|
// to call before any fetch has happened.
|
|
167
|
-
const out = await run(folder, [
|
|
184
|
+
const out = await run(folder, ['ls-remote', '--heads', remote, branch], { env: authEnv(token) })
|
|
168
185
|
return out.length > 0
|
|
169
186
|
}
|
|
170
187
|
|
package/lib/inbound.js
CHANGED
|
@@ -23,14 +23,48 @@
|
|
|
23
23
|
|
|
24
24
|
import * as git from './git.js'
|
|
25
25
|
|
|
26
|
+
// Never throws. Every caller is a timer, and a timer callback that
|
|
27
|
+
// rejects takes the whole process down: Node has treated an unhandled
|
|
28
|
+
// rejection as fatal since v15, mikser installs no process-level handler,
|
|
29
|
+
// and the poll runs every few minutes for the life of a watch server. A
|
|
30
|
+
// network blip, a DNS failure or an expired token would kill the build and
|
|
31
|
+
// the site, and under a supervisor it becomes a restart loop.
|
|
32
|
+
//
|
|
33
|
+
// So the outcome is always a returned shape. A fetch that fails is the same
|
|
34
|
+
// class of event as a merge that conflicts — remote trouble, reported, try
|
|
35
|
+
// again next tick — and it is reported the same way.
|
|
26
36
|
export async function pullInbound(folder, { writeBranch, targetBranch, token, logger }) {
|
|
27
|
-
|
|
37
|
+
try {
|
|
38
|
+
await git.fetch(folder, { token })
|
|
39
|
+
} catch (err) {
|
|
40
|
+
// Nothing was touched: fetch writes only to remote-tracking refs,
|
|
41
|
+
// and a failed one leaves even those alone.
|
|
42
|
+
logger?.warn(
|
|
43
|
+
'git: inbound fetch failed — %s. Working folder untouched; retrying on the next poll.',
|
|
44
|
+
err.stderr || err.message,
|
|
45
|
+
)
|
|
46
|
+
return { merged: false, fetchFailed: true, reason: err.stderr || err.message }
|
|
47
|
+
}
|
|
28
48
|
|
|
29
49
|
for (const ref of [writeBranch, targetBranch]) {
|
|
30
50
|
try {
|
|
31
51
|
await git.mergeBranch(folder, `origin/${ref}`)
|
|
32
52
|
} catch (err) {
|
|
33
|
-
|
|
53
|
+
// A failed abort would throw straight past this handler and
|
|
54
|
+
// become the fatal rejection the fetch guard above exists to
|
|
55
|
+
// prevent. Report it instead: the folder is then in a merge
|
|
56
|
+
// state a human has to look at, which is worth saying loudly.
|
|
57
|
+
try {
|
|
58
|
+
await git.abortMerge(folder)
|
|
59
|
+
} catch (abortErr) {
|
|
60
|
+
logger?.error(
|
|
61
|
+
'git: inbound merge of origin/%s conflicted AND `git merge --abort` failed — %s. ' +
|
|
62
|
+
'The working folder is mid-merge and may contain conflict markers; mikser will render them as content. '
|
|
63
|
+
+ 'Resolve manually before the next cycle.',
|
|
64
|
+
ref, abortErr.stderr || abortErr.message,
|
|
65
|
+
)
|
|
66
|
+
return { merged: false, conflictedRef: ref, abortFailed: true, reason: abortErr.stderr || abortErr.message }
|
|
67
|
+
}
|
|
34
68
|
logger?.error(
|
|
35
69
|
'git: inbound merge of origin/%s conflicted — aborted, working folder left untouched. ' +
|
|
36
70
|
'Resolve manually: cd <folder> && git merge origin/%s (or origin/%s) and fix the conflicts. %s',
|
package/lib/queue.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// One git operation at a time, per plugin instance.
|
|
2
|
+
//
|
|
3
|
+
// The sync pass and the inbound poll are independent timers — debounced
|
|
4
|
+
// seconds after a green cycle, and every few minutes — over the SAME
|
|
5
|
+
// checkout. Nothing stopped them overlapping, and git serialises through
|
|
6
|
+
// `index.lock`: the loser fails with "Another git process seems to be
|
|
7
|
+
// running", which surfaces as an intermittent sync failure that retries
|
|
8
|
+
// and looks like nothing. The worse shape is a commit landing while an
|
|
9
|
+
// inbound merge is in progress, which commits the merge rather than the
|
|
10
|
+
// intended change.
|
|
11
|
+
//
|
|
12
|
+
// A promise chain rather than a lock: each caller waits for the previous
|
|
13
|
+
// operation to settle, in order, and a rejection cannot break the chain
|
|
14
|
+
// because the guard already turned every body into a resolved promise.
|
|
15
|
+
export function createGitQueue() {
|
|
16
|
+
let tail = Promise.resolve()
|
|
17
|
+
return function enqueue(fn) {
|
|
18
|
+
const next = tail.then(fn, fn)
|
|
19
|
+
// Swallow here only — the caller's own guard reports. Without this
|
|
20
|
+
// the chain itself would carry an unhandled rejection.
|
|
21
|
+
tail = next.catch(() => {})
|
|
22
|
+
return next
|
|
23
|
+
}
|
|
24
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikser-io-git",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.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
|
+
"files": [
|
|
7
|
+
"index.js",
|
|
8
|
+
"lib",
|
|
9
|
+
"README.md",
|
|
10
|
+
"LICENSE"
|
|
11
|
+
],
|
|
6
12
|
"type": "module",
|
|
7
13
|
"scripts": {
|
|
8
14
|
"test": "node --no-warnings --test --test-reporter=spec 'test/**/*.test.js'"
|
package/test/bootstrap.test.js
DELETED
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
// decideBootstrap is pure — plain facts in, a decision out — so every
|
|
2
|
-
// folder-state combination is directly testable without touching a
|
|
3
|
-
// filesystem or a git binary.
|
|
4
|
-
|
|
5
|
-
import { describe, it } from 'node:test'
|
|
6
|
-
import assert from 'node:assert/strict'
|
|
7
|
-
|
|
8
|
-
import { decideBootstrap } from '../lib/bootstrap.js'
|
|
9
|
-
|
|
10
|
-
const EXPECTED = 'https://github.com/org/content.git'
|
|
11
|
-
|
|
12
|
-
describe('decideBootstrap', () => {
|
|
13
|
-
it('clones into a folder that does not exist yet', () => {
|
|
14
|
-
const d = decideBootstrap({ folderExists: false, folderEmpty: true, isRepo: false, remoteUrl: null, expectedUrl: EXPECTED })
|
|
15
|
-
assert.equal(d.action, 'clone')
|
|
16
|
-
})
|
|
17
|
-
|
|
18
|
-
it('clones into an existing empty folder', () => {
|
|
19
|
-
const d = decideBootstrap({ folderExists: true, folderEmpty: true, isRepo: false, remoteUrl: null, expectedUrl: EXPECTED })
|
|
20
|
-
assert.equal(d.action, 'clone')
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
it('refuses a non-empty folder that is not a git repo — the destructive case', () => {
|
|
24
|
-
const d = decideBootstrap({ folderExists: true, folderEmpty: false, isRepo: false, remoteUrl: null, expectedUrl: EXPECTED })
|
|
25
|
-
assert.equal(d.action, 'refuse')
|
|
26
|
-
assert.match(d.reason, /not a git repository/i)
|
|
27
|
-
assert.match(d.reason, /git init/) // the manual recipe must be in the message
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
it('verifies an existing checkout whose remote matches', () => {
|
|
31
|
-
const d = decideBootstrap({ folderExists: true, folderEmpty: false, isRepo: true, remoteUrl: EXPECTED, expectedUrl: EXPECTED })
|
|
32
|
-
assert.equal(d.action, 'verify')
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
it('verify tolerates a trailing-slash / missing .git cosmetic difference', () => {
|
|
36
|
-
const d = decideBootstrap({
|
|
37
|
-
folderExists: true, folderEmpty: false, isRepo: true,
|
|
38
|
-
remoteUrl: 'https://github.com/org/content', // no .git suffix
|
|
39
|
-
expectedUrl: EXPECTED, // has .git suffix
|
|
40
|
-
})
|
|
41
|
-
assert.equal(d.action, 'verify')
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
it('refuses an existing repo with no origin remote at all', () => {
|
|
45
|
-
const d = decideBootstrap({ folderExists: true, folderEmpty: false, isRepo: true, remoteUrl: null, expectedUrl: EXPECTED })
|
|
46
|
-
assert.equal(d.action, 'refuse')
|
|
47
|
-
assert.match(d.reason, /no "origin" remote/i)
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
-
it('refuses an existing repo whose remote points somewhere else', () => {
|
|
51
|
-
const d = decideBootstrap({
|
|
52
|
-
folderExists: true, folderEmpty: false, isRepo: true,
|
|
53
|
-
remoteUrl: 'https://github.com/someone-else/other-repo.git',
|
|
54
|
-
expectedUrl: EXPECTED,
|
|
55
|
-
})
|
|
56
|
-
assert.equal(d.action, 'refuse')
|
|
57
|
-
assert.match(d.reason, /does not match the configured repo/i)
|
|
58
|
-
})
|
|
59
|
-
})
|
package/test/config.test.js
DELETED
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
import { describe, it } from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
|
|
4
|
-
import { resolveConfig } from '../lib/config.js'
|
|
5
|
-
|
|
6
|
-
describe('resolveConfig', () => {
|
|
7
|
-
it('requires url', () => {
|
|
8
|
-
assert.throws(() => resolveConfig({}), /`url` is required/)
|
|
9
|
-
})
|
|
10
|
-
|
|
11
|
-
it('rejects an unknown forge', () => {
|
|
12
|
-
assert.throws(
|
|
13
|
-
() => resolveConfig({ url: 'https://github.com/org/repo.git', forge: 'bitbucket' }),
|
|
14
|
-
/must be "github", "gitea", or "none"/,
|
|
15
|
-
)
|
|
16
|
-
})
|
|
17
|
-
|
|
18
|
-
it('rejects an empty paths array', () => {
|
|
19
|
-
assert.throws(
|
|
20
|
-
() => resolveConfig({ url: 'https://example.com/org/repo.git', paths: [] }),
|
|
21
|
-
/`paths` must be a non-empty/,
|
|
22
|
-
)
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
it('normalizes a single path string to a one-element array', () => {
|
|
26
|
-
const cfg = resolveConfig({ url: 'https://example.com/org/repo.git', paths: 'layouts' })
|
|
27
|
-
assert.deepEqual(cfg.paths, ['layouts'])
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
it('accepts an array of paths for multiple collections sharing one checkout', () => {
|
|
31
|
-
const cfg = resolveConfig({ url: 'https://example.com/org/repo.git', paths: ['documents', 'layouts', 'files'] })
|
|
32
|
-
assert.deepEqual(cfg.paths, ['documents', 'layouts', 'files'])
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
it('omitting paths means "no scope" — the whole working folder, not a narrower default', () => {
|
|
36
|
-
// Deliberately distinct from '[\'documents\']': the zero-config
|
|
37
|
-
// case should match the model's own framing ("the working
|
|
38
|
-
// folder is the checkout"), not silently narrow to one
|
|
39
|
-
// collection nobody asked for.
|
|
40
|
-
const cfg = resolveConfig({ url: 'https://example.com/org/repo.git' })
|
|
41
|
-
assert.equal(cfg.paths, null)
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
it('applies sane defaults for a minimal forge:none config', () => {
|
|
45
|
-
const cfg = resolveConfig({ url: 'https://example.com/org/repo.git' })
|
|
46
|
-
assert.equal(cfg.forge, 'none')
|
|
47
|
-
assert.equal(cfg.paths, null)
|
|
48
|
-
assert.equal(cfg.targetBranch, 'main')
|
|
49
|
-
assert.equal(cfg.writeBranch, 'mikser')
|
|
50
|
-
assert.equal(cfg.afterMs, 60_000)
|
|
51
|
-
assert.equal(cfg.maxWaitMs, 600_000)
|
|
52
|
-
assert.equal(cfg.pollIntervalMs, 300_000)
|
|
53
|
-
// forge:none never needs owner/repo/apiBase — no API involved.
|
|
54
|
-
assert.equal(cfg.owner, undefined)
|
|
55
|
-
assert.equal(cfg.repo, undefined)
|
|
56
|
-
assert.equal(cfg.apiBase, undefined)
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
it('derives owner/repo/apiBase from the url when forge is github', () => {
|
|
60
|
-
const cfg = resolveConfig({ url: 'https://github.com/almero-digital-marketing/gpoint-content.git', forge: 'github', token: 'x' })
|
|
61
|
-
assert.equal(cfg.owner, 'almero-digital-marketing')
|
|
62
|
-
assert.equal(cfg.repo, 'gpoint-content')
|
|
63
|
-
assert.equal(cfg.apiBase, 'https://api.github.com')
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
it('derives owner/repo/apiBase from the url when forge is gitea', () => {
|
|
67
|
-
const cfg = resolveConfig({ url: 'https://git.almero.bg/org/content.git', forge: 'gitea', token: 'x' })
|
|
68
|
-
assert.equal(cfg.owner, 'org')
|
|
69
|
-
assert.equal(cfg.repo, 'content')
|
|
70
|
-
assert.equal(cfg.apiBase, 'https://git.almero.bg')
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
it('explicit owner/repo/apiBase override the url-derived values', () => {
|
|
74
|
-
const cfg = resolveConfig({
|
|
75
|
-
url: 'https://github.com/org/repo.git', forge: 'github', token: 'x',
|
|
76
|
-
owner: 'other-org', repo: 'other-repo', apiBase: 'https://ghe.example.com/api/v3',
|
|
77
|
-
})
|
|
78
|
-
assert.equal(cfg.owner, 'other-org')
|
|
79
|
-
assert.equal(cfg.repo, 'other-repo')
|
|
80
|
-
assert.equal(cfg.apiBase, 'https://ghe.example.com/api/v3')
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
it('respects custom branch names, paths, and durations', () => {
|
|
84
|
-
const cfg = resolveConfig({
|
|
85
|
-
url: 'https://example.com/org/repo.git',
|
|
86
|
-
branch: 'live', writeBranch: 'agents', paths: ['content'],
|
|
87
|
-
after: '30s', maxWait: '5m', pollInterval: '1m',
|
|
88
|
-
})
|
|
89
|
-
assert.equal(cfg.targetBranch, 'live')
|
|
90
|
-
assert.equal(cfg.writeBranch, 'agents')
|
|
91
|
-
assert.deepEqual(cfg.paths, ['content'])
|
|
92
|
-
assert.equal(cfg.afterMs, 30_000)
|
|
93
|
-
assert.equal(cfg.maxWaitMs, 300_000)
|
|
94
|
-
assert.equal(cfg.pollIntervalMs, 60_000)
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
it('the default message builder includes the file count', () => {
|
|
98
|
-
const cfg = resolveConfig({ url: 'https://example.com/org/repo.git' })
|
|
99
|
-
assert.equal(cfg.message({ fileCount: 3 }), 'content: 3 file(s) via mikser')
|
|
100
|
-
})
|
|
101
|
-
})
|
package/test/debounce.test.js
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
// reduceDebounce is pure — feed a sequence of green/red events with
|
|
2
|
-
// explicit timestamps, assert the resulting fireAt. Covers both halves
|
|
3
|
-
// of the design: the short debounce after a green cycle, and the
|
|
4
|
-
// maxWait ceiling that keeps a steady stream of green cycles from
|
|
5
|
-
// starving the sync forever.
|
|
6
|
-
|
|
7
|
-
import { describe, it } from 'node:test'
|
|
8
|
-
import assert from 'node:assert/strict'
|
|
9
|
-
|
|
10
|
-
import { reduceDebounce, IDLE_DEBOUNCE_STATE } from '../lib/debounce.js'
|
|
11
|
-
|
|
12
|
-
const CFG = { afterMs: 60_000, maxWaitMs: 600_000 } // 1m / 10m, matches the plugin's defaults
|
|
13
|
-
|
|
14
|
-
describe('reduceDebounce', () => {
|
|
15
|
-
it('a single green event fires after `after`', () => {
|
|
16
|
-
const s = reduceDebounce(IDLE_DEBOUNCE_STATE, { type: 'green', now: 1000 }, CFG)
|
|
17
|
-
assert.equal(s.pendingSince, 1000)
|
|
18
|
-
assert.equal(s.fireAt, 1000 + CFG.afterMs)
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
it('a red event clears the window outright', () => {
|
|
22
|
-
const afterGreen = reduceDebounce(IDLE_DEBOUNCE_STATE, { type: 'green', now: 1000 }, CFG)
|
|
23
|
-
const afterRed = reduceDebounce(afterGreen, { type: 'red', now: 2000 }, CFG)
|
|
24
|
-
assert.deepEqual(afterRed, IDLE_DEBOUNCE_STATE)
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
it('a later green event pushes fireAt out again (trailing debounce)', () => {
|
|
28
|
-
let s = reduceDebounce(IDLE_DEBOUNCE_STATE, { type: 'green', now: 0 }, CFG)
|
|
29
|
-
s = reduceDebounce(s, { type: 'green', now: 30_000 }, CFG)
|
|
30
|
-
// pendingSince stays at the FIRST green (0); fireAt tracks the latest green + after,
|
|
31
|
-
// capped by pendingSince + maxWait (0 + 600_000 = 600_000, not yet reached).
|
|
32
|
-
assert.equal(s.pendingSince, 0)
|
|
33
|
-
assert.equal(s.fireAt, 30_000 + CFG.afterMs)
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
it('a steady stream of green events is bounded by maxWait — never starves forever', () => {
|
|
37
|
-
let s = reduceDebounce(IDLE_DEBOUNCE_STATE, { type: 'green', now: 0 }, CFG)
|
|
38
|
-
// Green every 30s, well under the 60s `after` window, for 20 minutes —
|
|
39
|
-
// a pure trailing debounce would never fire.
|
|
40
|
-
for (let now = 30_000; now <= 1_200_000; now += 30_000) {
|
|
41
|
-
s = reduceDebounce(s, { type: 'green', now }, CFG)
|
|
42
|
-
}
|
|
43
|
-
// fireAt must never exceed pendingSince + maxWait (0 + 600_000).
|
|
44
|
-
assert.ok(s.fireAt <= CFG.maxWaitMs, `fireAt ${s.fireAt} exceeded the maxWait ceiling ${CFG.maxWaitMs}`)
|
|
45
|
-
assert.equal(s.fireAt, CFG.maxWaitMs)
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
it('a red event after being interrupted then going green again starts a FRESH window', () => {
|
|
49
|
-
let s = reduceDebounce(IDLE_DEBOUNCE_STATE, { type: 'green', now: 0 }, CFG)
|
|
50
|
-
s = reduceDebounce(s, { type: 'red', now: 100_000 }, CFG)
|
|
51
|
-
s = reduceDebounce(s, { type: 'green', now: 200_000 }, CFG)
|
|
52
|
-
// pendingSince resets to the new green's timestamp, not the original 0 —
|
|
53
|
-
// otherwise the maxWait ceiling would already be looming from unrelated history.
|
|
54
|
-
assert.equal(s.pendingSince, 200_000)
|
|
55
|
-
assert.equal(s.fireAt, 200_000 + CFG.afterMs)
|
|
56
|
-
})
|
|
57
|
-
})
|
package/test/duration.test.js
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { describe, it } from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
|
|
4
|
-
import { parseDuration } from '../lib/duration.js'
|
|
5
|
-
|
|
6
|
-
describe('parseDuration', () => {
|
|
7
|
-
it('passes a plain number through unchanged', () => {
|
|
8
|
-
assert.equal(parseDuration(5000), 5000)
|
|
9
|
-
})
|
|
10
|
-
|
|
11
|
-
it('returns the fallback for null/undefined', () => {
|
|
12
|
-
assert.equal(parseDuration(undefined, 42), 42)
|
|
13
|
-
assert.equal(parseDuration(null, 42), 42)
|
|
14
|
-
})
|
|
15
|
-
|
|
16
|
-
it('parses each unit', () => {
|
|
17
|
-
assert.equal(parseDuration('500ms'), 500)
|
|
18
|
-
assert.equal(parseDuration('30s'), 30_000)
|
|
19
|
-
assert.equal(parseDuration('1m'), 60_000)
|
|
20
|
-
assert.equal(parseDuration('10m'), 600_000)
|
|
21
|
-
assert.equal(parseDuration('2h'), 7_200_000)
|
|
22
|
-
assert.equal(parseDuration('1d'), 86_400_000)
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
it('is case-insensitive on the unit', () => {
|
|
26
|
-
assert.equal(parseDuration('1M'), 60_000)
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
it('throws on an unparseable string', () => {
|
|
30
|
-
assert.throws(() => parseDuration('soon'), /invalid duration/i)
|
|
31
|
-
})
|
|
32
|
-
})
|
package/test/forge/gitea.test.js
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import { describe, it } from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
|
|
4
|
-
import { ensurePR, mergePR } from '../../lib/forge/gitea.js'
|
|
5
|
-
|
|
6
|
-
function fakeResponse({ ok = true, status = 200, body = {} } = {}) {
|
|
7
|
-
return {
|
|
8
|
-
ok,
|
|
9
|
-
status,
|
|
10
|
-
statusText: ok ? 'OK' : 'Error',
|
|
11
|
-
json: async () => body,
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const CTX = { apiBase: 'https://git.almero.bg', owner: 'org', repo: 'content', head: 'mikser', base: 'main', title: 'Promote', token: 'tok' }
|
|
16
|
-
|
|
17
|
-
describe('gitea forge adapter', () => {
|
|
18
|
-
it('ensurePR reuses an existing open PR matched by head/base ref', async () => {
|
|
19
|
-
const calls = []
|
|
20
|
-
const fetchImpl = async (url, opts) => {
|
|
21
|
-
calls.push({ url, method: opts?.method ?? 'GET' })
|
|
22
|
-
return fakeResponse({
|
|
23
|
-
body: [
|
|
24
|
-
{ number: 3, head: { ref: 'some-other-branch' }, base: { ref: 'main' } },
|
|
25
|
-
{ number: 5, head: { ref: 'mikser' }, base: { ref: 'main' }, html_url: 'https://git.almero.bg/org/content/pulls/5' },
|
|
26
|
-
],
|
|
27
|
-
})
|
|
28
|
-
}
|
|
29
|
-
const pr = await ensurePR({ ...CTX, fetchImpl })
|
|
30
|
-
assert.deepEqual(pr, { number: 5, url: 'https://git.almero.bg/org/content/pulls/5' })
|
|
31
|
-
assert.equal(calls.length, 1)
|
|
32
|
-
assert.match(calls[0].url, /\/api\/v1\/repos\/org\/content\/pulls\?state=open/)
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
it('ensurePR creates a new PR when the open list has no head/base match', async () => {
|
|
36
|
-
const calls = []
|
|
37
|
-
const fetchImpl = async (url, opts) => {
|
|
38
|
-
calls.push({ url, method: opts?.method ?? 'GET' })
|
|
39
|
-
if (!opts?.method || opts.method === 'GET') return fakeResponse({ body: [] })
|
|
40
|
-
return fakeResponse({ status: 201, body: { number: 11, html_url: 'https://git.almero.bg/org/content/pulls/11' } })
|
|
41
|
-
}
|
|
42
|
-
const pr = await ensurePR({ ...CTX, fetchImpl })
|
|
43
|
-
assert.deepEqual(pr, { number: 11, url: 'https://git.almero.bg/org/content/pulls/11' })
|
|
44
|
-
assert.equal(calls[1].method, 'POST')
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
it('ensurePR posts the correct body shape when creating', async () => {
|
|
48
|
-
let capturedBody = null
|
|
49
|
-
const fetchImpl = async (url, opts) => {
|
|
50
|
-
if (!opts?.method || opts.method === 'GET') return fakeResponse({ body: [] })
|
|
51
|
-
capturedBody = JSON.parse(opts.body)
|
|
52
|
-
return fakeResponse({ status: 201, body: { number: 1, html_url: 'x' } })
|
|
53
|
-
}
|
|
54
|
-
await ensurePR({ ...CTX, fetchImpl })
|
|
55
|
-
assert.deepEqual(capturedBody, { title: 'Promote', head: 'mikser', base: 'main' })
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
it('mergePR posts { Do: "merge" } — Gitea-specific field name and casing', async () => {
|
|
59
|
-
let capturedBody = null
|
|
60
|
-
let capturedMethod = null
|
|
61
|
-
const fetchImpl = async (url, opts) => {
|
|
62
|
-
capturedMethod = opts.method
|
|
63
|
-
capturedBody = JSON.parse(opts.body)
|
|
64
|
-
return fakeResponse({ body: {} })
|
|
65
|
-
}
|
|
66
|
-
const result = await mergePR({ apiBase: 'https://git.almero.bg', owner: 'org', repo: 'content', number: 5, token: 'tok', fetchImpl })
|
|
67
|
-
assert.deepEqual(result, { merged: true })
|
|
68
|
-
assert.equal(capturedMethod, 'POST') // NOT PUT — differs from the GitHub adapter
|
|
69
|
-
assert.deepEqual(capturedBody, { Do: 'merge' })
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
it('mergePR reports merged:false with a reason on failure', async () => {
|
|
73
|
-
const fetchImpl = async () => fakeResponse({ ok: false, status: 409, body: { message: 'merge conflict' } })
|
|
74
|
-
const result = await mergePR({ apiBase: 'https://git.almero.bg', owner: 'org', repo: 'content', number: 5, token: 'tok', fetchImpl })
|
|
75
|
-
assert.equal(result.merged, false)
|
|
76
|
-
assert.match(result.reason, /merge conflict/)
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
it('auth header uses the Gitea "token" scheme, not Bearer', async () => {
|
|
80
|
-
const seenHeaders = []
|
|
81
|
-
const fetchImpl = async (url, opts) => {
|
|
82
|
-
seenHeaders.push(opts.headers)
|
|
83
|
-
return fakeResponse({ body: [] }) // empty list → falls through to create, also hit by this fetchImpl
|
|
84
|
-
}
|
|
85
|
-
await ensurePR({ ...CTX, fetchImpl })
|
|
86
|
-
assert.ok(seenHeaders.length > 0)
|
|
87
|
-
for (const headers of seenHeaders) {
|
|
88
|
-
assert.equal(headers.Authorization, 'token tok')
|
|
89
|
-
}
|
|
90
|
-
})
|
|
91
|
-
})
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import { describe, it } from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
|
|
4
|
-
import { ensurePR, mergePR } from '../../lib/forge/github.js'
|
|
5
|
-
|
|
6
|
-
function fakeResponse({ ok = true, status = 200, body = {} } = {}) {
|
|
7
|
-
return {
|
|
8
|
-
ok,
|
|
9
|
-
status,
|
|
10
|
-
statusText: ok ? 'OK' : 'Error',
|
|
11
|
-
json: async () => body,
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const CTX = { owner: 'org', repo: 'content', head: 'mikser', base: 'main', title: 'Promote', token: 'tok' }
|
|
16
|
-
|
|
17
|
-
describe('github forge adapter', () => {
|
|
18
|
-
it('ensurePR reuses an existing open PR instead of creating a new one', async () => {
|
|
19
|
-
const calls = []
|
|
20
|
-
const fetchImpl = async (url, opts) => {
|
|
21
|
-
calls.push({ url, method: opts?.method ?? 'GET' })
|
|
22
|
-
return fakeResponse({ body: [{ number: 7, html_url: 'https://github.com/org/content/pull/7' }] })
|
|
23
|
-
}
|
|
24
|
-
const pr = await ensurePR({ ...CTX, fetchImpl })
|
|
25
|
-
assert.deepEqual(pr, { number: 7, url: 'https://github.com/org/content/pull/7' })
|
|
26
|
-
assert.equal(calls.length, 1, 'must not call the create endpoint when one already exists')
|
|
27
|
-
assert.match(calls[0].url, /head=org:mikser&base=main&state=open/)
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
it('ensurePR creates a new PR when none is open', async () => {
|
|
31
|
-
const calls = []
|
|
32
|
-
const fetchImpl = async (url, opts) => {
|
|
33
|
-
calls.push({ url, method: opts?.method ?? 'GET' })
|
|
34
|
-
if (!opts?.method || opts.method === 'GET') return fakeResponse({ body: [] })
|
|
35
|
-
return fakeResponse({ status: 201, body: { number: 9, html_url: 'https://github.com/org/content/pull/9' } })
|
|
36
|
-
}
|
|
37
|
-
const pr = await ensurePR({ ...CTX, fetchImpl })
|
|
38
|
-
assert.deepEqual(pr, { number: 9, url: 'https://github.com/org/content/pull/9' })
|
|
39
|
-
assert.equal(calls.length, 2)
|
|
40
|
-
assert.equal(calls[1].method, 'POST')
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
it('ensurePR throws with a readable message when creation fails', async () => {
|
|
44
|
-
const fetchImpl = async (url, opts) => {
|
|
45
|
-
if (!opts?.method) return fakeResponse({ body: [] })
|
|
46
|
-
return fakeResponse({ ok: false, status: 422, body: { message: 'Validation failed' } })
|
|
47
|
-
}
|
|
48
|
-
await assert.rejects(ensurePR({ ...CTX, fetchImpl }), /Validation failed/)
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
it('mergePR reports merged:true on success', async () => {
|
|
52
|
-
const fetchImpl = async () => fakeResponse({ body: { merged: true, sha: 'abc' } })
|
|
53
|
-
const result = await mergePR({ owner: 'org', repo: 'content', number: 7, token: 'tok', fetchImpl })
|
|
54
|
-
assert.deepEqual(result, { merged: true })
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
it('mergePR reports merged:false with a reason on conflict', async () => {
|
|
58
|
-
const fetchImpl = async () => fakeResponse({ ok: false, status: 405, body: { message: 'Pull Request is not mergeable' } })
|
|
59
|
-
const result = await mergePR({ owner: 'org', repo: 'content', number: 7, token: 'tok', fetchImpl })
|
|
60
|
-
assert.equal(result.merged, false)
|
|
61
|
-
assert.match(result.reason, /not mergeable/)
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
it('mergePR falls back to statusText when the error body is not JSON', async () => {
|
|
65
|
-
const fetchImpl = async () => ({ ok: false, status: 500, statusText: 'Internal Server Error', json: async () => { throw new Error('not json') } })
|
|
66
|
-
const result = await mergePR({ owner: 'org', repo: 'content', number: 7, token: 'tok', fetchImpl })
|
|
67
|
-
assert.equal(result.merged, false)
|
|
68
|
-
assert.equal(result.reason, 'Internal Server Error')
|
|
69
|
-
})
|
|
70
|
-
})
|
package/test/git.test.js
DELETED
|
@@ -1,220 +0,0 @@
|
|
|
1
|
-
// isNonFastForwardError is pure string-matching, tested directly.
|
|
2
|
-
// Everything else in lib/git.js is a thin execFile wrapper — real
|
|
3
|
-
// confidence there comes from exercising it against an actual git
|
|
4
|
-
// repo in a temp directory. This only covers the LOCAL-only
|
|
5
|
-
// operations (init, add, commit, status, branch) — clone/fetch/push
|
|
6
|
-
// need a real remote and are exercised by hand against a real forge,
|
|
7
|
-
// not in this suite.
|
|
8
|
-
|
|
9
|
-
import { describe, it, before, after } from 'node:test'
|
|
10
|
-
import assert from 'node:assert/strict'
|
|
11
|
-
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
|
|
12
|
-
import { tmpdir } from 'node:os'
|
|
13
|
-
import path from 'node:path'
|
|
14
|
-
|
|
15
|
-
import * as git from '../lib/git.js'
|
|
16
|
-
|
|
17
|
-
describe('isNonFastForwardError', () => {
|
|
18
|
-
it('recognizes the common rejection messages', () => {
|
|
19
|
-
assert.ok(git.isNonFastForwardError({ stderr: '! [rejected] main -> main (non-fast-forward)' }))
|
|
20
|
-
assert.ok(git.isNonFastForwardError({ message: 'failed to push some refs (fetch first)' }))
|
|
21
|
-
assert.ok(git.isNonFastForwardError({ stderr: 'Updates were rejected because the tip of your current branch is behind' }))
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
it('does not misclassify an unrelated error', () => {
|
|
25
|
-
assert.equal(git.isNonFastForwardError({ message: 'fatal: repository not found' }), false)
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
it('handles a bare/malformed error object without throwing', () => {
|
|
29
|
-
assert.equal(git.isNonFastForwardError({}), false)
|
|
30
|
-
assert.equal(git.isNonFastForwardError(null), false)
|
|
31
|
-
})
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
describe('git.js against a real local repo', () => {
|
|
35
|
-
let dir
|
|
36
|
-
|
|
37
|
-
before(async () => {
|
|
38
|
-
dir = await mkdtemp(path.join(tmpdir(), 'mikser-git-'))
|
|
39
|
-
await git.run(dir, ['init', '-b', 'main'])
|
|
40
|
-
// Local-only identity so commit() works in a sandboxed CI
|
|
41
|
-
// environment with no global git user configured.
|
|
42
|
-
await git.run(dir, ['config', 'user.email', 'test@example.com'])
|
|
43
|
-
await git.run(dir, ['config', 'user.name', 'Test'])
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
after(async () => {
|
|
47
|
-
await rm(dir, { recursive: true, force: true })
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
-
it('an empty repo has no changes', async () => {
|
|
51
|
-
assert.equal(await git.hasChanges(dir), false)
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
it('a new file registers as a change', async () => {
|
|
55
|
-
await writeFile(path.join(dir, 'a.md'), 'hello')
|
|
56
|
-
assert.equal(await git.hasChanges(dir), true)
|
|
57
|
-
const status = await git.statusPorcelain(dir)
|
|
58
|
-
assert.match(status, /a\.md/)
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
it('addAll stages it, then commit records it', async () => {
|
|
62
|
-
await git.addAll(dir)
|
|
63
|
-
assert.equal(await git.hasStagedChanges(dir), true)
|
|
64
|
-
await git.commit(dir, 'first commit')
|
|
65
|
-
assert.equal(await git.hasChanges(dir), false)
|
|
66
|
-
assert.equal(await git.hasStagedChanges(dir), false)
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
it('currentBranch reports the init branch', async () => {
|
|
70
|
-
assert.equal(await git.currentBranch(dir), 'main')
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
it('checkoutBranch creates and switches to a new branch', async () => {
|
|
74
|
-
await git.checkoutBranch(dir, 'mikser', { create: true })
|
|
75
|
-
assert.equal(await git.currentBranch(dir), 'mikser')
|
|
76
|
-
assert.equal(await git.branchExistsLocal(dir, 'mikser'), true)
|
|
77
|
-
assert.equal(await git.branchExistsLocal(dir, 'nonexistent'), false)
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
it('a second file + commit round-trips through the same flow on the new branch', async () => {
|
|
81
|
-
await writeFile(path.join(dir, 'b.md'), 'world')
|
|
82
|
-
await git.addAll(dir)
|
|
83
|
-
await git.commit(dir, 'second commit')
|
|
84
|
-
assert.equal(await git.hasChanges(dir), false)
|
|
85
|
-
assert.equal(await git.revParse(dir, 'HEAD') !== '', true)
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
it('resetHard moves the branch tip and the working tree together', async () => {
|
|
89
|
-
const beforeReset = await git.revParse(dir, 'HEAD')
|
|
90
|
-
await writeFile(path.join(dir, 'c.md'), 'discard me')
|
|
91
|
-
await git.addAll(dir)
|
|
92
|
-
await git.commit(dir, 'to be discarded')
|
|
93
|
-
assert.notEqual(await git.revParse(dir, 'HEAD'), beforeReset)
|
|
94
|
-
await git.resetHard(dir, beforeReset)
|
|
95
|
-
assert.equal(await git.revParse(dir, 'HEAD'), beforeReset)
|
|
96
|
-
})
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
// The hard scope boundary the working-folder-as-checkout model depends
|
|
100
|
-
// on: mikser.config.js, node_modules/, runtime/, out/, .env can live in
|
|
101
|
-
// the SAME checkout this plugin manages, alongside collections it
|
|
102
|
-
// SHOULD auto-commit (documents/, layouts/), as long as every git
|
|
103
|
-
// operation is scoped to `paths`. Proven directly here, not assumed.
|
|
104
|
-
describe('git.js pathspec scoping (paths param)', () => {
|
|
105
|
-
let dir
|
|
106
|
-
|
|
107
|
-
before(async () => {
|
|
108
|
-
dir = await mkdtemp(path.join(tmpdir(), 'mikser-git-scope-'))
|
|
109
|
-
await git.run(dir, ['init', '-b', 'main'])
|
|
110
|
-
await git.run(dir, ['config', 'user.email', 'test@example.com'])
|
|
111
|
-
await git.run(dir, ['config', 'user.name', 'Test'])
|
|
112
|
-
await mkdir(path.join(dir, 'documents'))
|
|
113
|
-
await mkdir(path.join(dir, 'node_modules'))
|
|
114
|
-
await writeFile(path.join(dir, 'mikser.config.js'), 'export default {}')
|
|
115
|
-
await writeFile(path.join(dir, 'documents', 'post.md'), 'hello')
|
|
116
|
-
await writeFile(path.join(dir, 'node_modules', 'dep.js'), 'module.exports = {}')
|
|
117
|
-
await git.addAll(dir) // baseline commit — everything present so far
|
|
118
|
-
await git.commit(dir, 'baseline')
|
|
119
|
-
})
|
|
120
|
-
|
|
121
|
-
after(async () => {
|
|
122
|
-
await rm(dir, { recursive: true, force: true })
|
|
123
|
-
})
|
|
124
|
-
|
|
125
|
-
it('statusPorcelain with paths only reports changes inside the scope', async () => {
|
|
126
|
-
await writeFile(path.join(dir, 'documents', 'post.md'), 'edited') // in scope
|
|
127
|
-
await writeFile(path.join(dir, 'mikser.config.js'), 'export default { x: 1 }') // OUT of scope
|
|
128
|
-
await writeFile(path.join(dir, 'node_modules', 'dep.js'), 'changed') // OUT of scope
|
|
129
|
-
|
|
130
|
-
const scoped = await git.statusPorcelain(dir, ['documents'])
|
|
131
|
-
assert.match(scoped, /post\.md/)
|
|
132
|
-
assert.doesNotMatch(scoped, /mikser\.config\.js/)
|
|
133
|
-
assert.doesNotMatch(scoped, /dep\.js/)
|
|
134
|
-
|
|
135
|
-
// Unscoped status proves the OTHER files really were dirty —
|
|
136
|
-
// the scoped call above wasn't just quiet because nothing changed.
|
|
137
|
-
const unscoped = await git.statusPorcelain(dir)
|
|
138
|
-
assert.match(unscoped, /mikser\.config\.js/)
|
|
139
|
-
assert.match(unscoped, /dep\.js/)
|
|
140
|
-
})
|
|
141
|
-
|
|
142
|
-
it('addAll with paths stages only the in-scope file — the config edit stays untracked', async () => {
|
|
143
|
-
await git.addAll(dir, ['documents'])
|
|
144
|
-
assert.equal(await git.hasStagedChanges(dir, ['documents']), true)
|
|
145
|
-
// Prove mikser.config.js's edit was NOT staged: the unscoped
|
|
146
|
-
// status must still show it as an unstaged modification (' M'),
|
|
147
|
-
// not staged ('M ').
|
|
148
|
-
const status = await git.statusPorcelain(dir)
|
|
149
|
-
assert.match(status, / M mikser\.config\.js/)
|
|
150
|
-
assert.doesNotMatch(status, /^M {2}mikser\.config\.js/m)
|
|
151
|
-
|
|
152
|
-
await git.commit(dir, 'documents-only change')
|
|
153
|
-
|
|
154
|
-
// The committed tree must NOT include the config edit or the
|
|
155
|
-
// node_modules change — only documents/post.md's new content.
|
|
156
|
-
const committed = await git.run(dir, ['show', '--stat', 'HEAD'])
|
|
157
|
-
assert.match(committed, /documents\/post\.md/)
|
|
158
|
-
assert.doesNotMatch(committed, /mikser\.config\.js/)
|
|
159
|
-
assert.doesNotMatch(committed, /dep\.js/)
|
|
160
|
-
})
|
|
161
|
-
|
|
162
|
-
it('a brand-new directory outside paths is invisible to a scoped add, even with --untracked-files=all', async () => {
|
|
163
|
-
await mkdir(path.join(dir, 'layouts'))
|
|
164
|
-
await writeFile(path.join(dir, 'layouts', 'post.hbs'), '<html></html>')
|
|
165
|
-
|
|
166
|
-
await git.addAll(dir, ['documents']) // layouts/ deliberately not in scope
|
|
167
|
-
assert.equal(await git.hasStagedChanges(dir, ['documents']), false, 'nothing new in documents/ this round')
|
|
168
|
-
assert.equal(await git.hasStagedChanges(dir, ['layouts']), false, 'layouts/ was never staged — out of scope')
|
|
169
|
-
|
|
170
|
-
const unscoped = await git.statusPorcelain(dir)
|
|
171
|
-
assert.match(unscoped, /layouts\//) // still genuinely untracked, just untouched by the scoped add
|
|
172
|
-
})
|
|
173
|
-
})
|
|
174
|
-
|
|
175
|
-
// The zero-config default (index.js's `paths` config omitted → resolveConfig
|
|
176
|
-
// gives `null`): with NO pathspec at all, git.js's functions operate on the
|
|
177
|
-
// whole repo. This proves the actual behavior that default implies —
|
|
178
|
-
// something outside any collection folder DOES get staged when no `paths`
|
|
179
|
-
// was configured, and .gitignore (not a pathspec) is what would keep
|
|
180
|
-
// node_modules/.env out in that case. Distinct from the scoped-paths suite
|
|
181
|
-
// above, which proves the OPPOSITE guarantee when paths IS given.
|
|
182
|
-
describe('git.js with paths=null (the no-`paths`-configured default)', () => {
|
|
183
|
-
let dir
|
|
184
|
-
|
|
185
|
-
before(async () => {
|
|
186
|
-
dir = await mkdtemp(path.join(tmpdir(), 'mikser-git-noscope-'))
|
|
187
|
-
await git.run(dir, ['init', '-b', 'main'])
|
|
188
|
-
await git.run(dir, ['config', 'user.email', 'test@example.com'])
|
|
189
|
-
await git.run(dir, ['config', 'user.name', 'Test'])
|
|
190
|
-
await writeFile(path.join(dir, '.gitignore'), 'ignored-dir/\n')
|
|
191
|
-
await mkdir(path.join(dir, 'documents'))
|
|
192
|
-
await mkdir(path.join(dir, 'ignored-dir'))
|
|
193
|
-
await writeFile(path.join(dir, 'documents', 'post.md'), 'hello')
|
|
194
|
-
await writeFile(path.join(dir, 'mikser.config.js'), 'export default {}')
|
|
195
|
-
await writeFile(path.join(dir, 'ignored-dir', 'dep.js'), 'x')
|
|
196
|
-
await git.addAll(dir) // no paths arg — baseline covers everything not gitignored
|
|
197
|
-
await git.commit(dir, 'baseline')
|
|
198
|
-
})
|
|
199
|
-
|
|
200
|
-
after(async () => {
|
|
201
|
-
await rm(dir, { recursive: true, force: true })
|
|
202
|
-
})
|
|
203
|
-
|
|
204
|
-
it('with no paths arg, an edit OUTSIDE any collection folder still gets staged', async () => {
|
|
205
|
-
await writeFile(path.join(dir, 'mikser.config.js'), 'export default { changed: true }')
|
|
206
|
-
await git.addAll(dir) // no paths — the default-config behavior
|
|
207
|
-
assert.equal(await git.hasStagedChanges(dir), true)
|
|
208
|
-
const status = await git.statusPorcelain(dir)
|
|
209
|
-
assert.match(status, /^M {2}mikser\.config\.js/m) // staged, not just modified
|
|
210
|
-
})
|
|
211
|
-
|
|
212
|
-
it('.gitignore, not a pathspec, is what keeps an ignored directory out — even unscoped', async () => {
|
|
213
|
-
await writeFile(path.join(dir, 'ignored-dir', 'dep.js'), 'changed')
|
|
214
|
-
await git.addAll(dir)
|
|
215
|
-
// The gitignored file's change never shows up at all, scoped or not —
|
|
216
|
-
// this is .gitignore doing the work, since there's no pathspec here.
|
|
217
|
-
const status = await git.statusPorcelain(dir)
|
|
218
|
-
assert.doesNotMatch(status, /dep\.js/)
|
|
219
|
-
})
|
|
220
|
-
})
|
package/test/repo-url.test.js
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import { describe, it } from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
|
|
4
|
-
import { parseRepoUrl } from '../lib/repo-url.js'
|
|
5
|
-
|
|
6
|
-
describe('parseRepoUrl', () => {
|
|
7
|
-
it('parses a github.com URL and defaults apiOrigin to api.github.com', () => {
|
|
8
|
-
const r = parseRepoUrl('https://github.com/almero-digital-marketing/gpoint-content.git')
|
|
9
|
-
assert.deepEqual(r, { owner: 'almero-digital-marketing', repo: 'gpoint-content', apiOrigin: 'https://api.github.com' })
|
|
10
|
-
})
|
|
11
|
-
|
|
12
|
-
it('parses without the .git suffix identically', () => {
|
|
13
|
-
const r = parseRepoUrl('https://github.com/org/repo')
|
|
14
|
-
assert.equal(r.owner, 'org')
|
|
15
|
-
assert.equal(r.repo, 'repo')
|
|
16
|
-
})
|
|
17
|
-
|
|
18
|
-
it('a self-hosted Gitea URL uses its own origin as apiOrigin (adapter appends /api/v1)', () => {
|
|
19
|
-
const r = parseRepoUrl('https://git.almero.bg/org/content.git')
|
|
20
|
-
assert.deepEqual(r, { owner: 'org', repo: 'content', apiOrigin: 'https://git.almero.bg' })
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
it('tolerates a trailing slash', () => {
|
|
24
|
-
const r = parseRepoUrl('https://git.almero.bg/org/content/')
|
|
25
|
-
assert.equal(r.repo, 'content')
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
it('throws on a URL with no owner/repo path', () => {
|
|
29
|
-
assert.throws(() => parseRepoUrl('https://github.com/'), /cannot derive owner\/repo/)
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
it('throws on garbage input', () => {
|
|
33
|
-
assert.throws(() => parseRepoUrl('not a url'), /not a valid URL/)
|
|
34
|
-
})
|
|
35
|
-
})
|