mikser-io-git 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
- })
@@ -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
- })