@wmfs/lerna-sync 1.37.1 → 1.39.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.
@@ -0,0 +1,233 @@
1
+ /* eslint-env mocha */
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const { expect } = require('chai')
6
+ const { Sandbox, git } = require('./helpers/git-sandbox')
7
+ const { executePlan, OUTCOMES } = require('../lib/execute-plan')
8
+ const { ACTIONS, REASONS } = require('../lib/plan-sync')
9
+
10
+ describe('executePlan (real git)', function () {
11
+ this.timeout(30000)
12
+
13
+ let sandbox, mono, origin, options
14
+
15
+ beforeEach(() => {
16
+ sandbox = new Sandbox()
17
+ mono = sandbox.dir('mono')
18
+ origin = sandbox.createOrigin('tymly-test-plugin')
19
+ options = {
20
+ monorepoPath: mono,
21
+ gitHubOrgName: 'wmfs',
22
+ cloneUrl: (org, name) => path.join(sandbox.root, 'remotes', org, `${name}.git`)
23
+ }
24
+ })
25
+
26
+ afterEach(() => sandbox.cleanup())
27
+
28
+ const decision = (action, reason, repoPath, extra = {}) => ({
29
+ repoName: origin.name,
30
+ action,
31
+ reason,
32
+ folder: 'plugins',
33
+ defaultBranch: 'master',
34
+ remote: { name: origin.name },
35
+ local: repoPath ? { path: repoPath, branch: git(repoPath, 'rev-parse', '--abbrev-ref', 'HEAD') } : null,
36
+ warnings: [],
37
+ ...extra
38
+ })
39
+
40
+ const runOne = async d => {
41
+ const [result] = await executePlan({ decisions: [d], ignored: [] }, options)
42
+ return result
43
+ }
44
+
45
+ const head = (repo, ref = 'HEAD') => git(repo, 'rev-parse', ref)
46
+ const originHead = () => git(origin.bare, 'rev-parse', 'master')
47
+
48
+ describe('CLONE', () => {
49
+ it('clones into <folder>/<repo name>', async () => {
50
+ const result = await runOne(decision(ACTIONS.CLONE, REASONS.NEW, null))
51
+
52
+ const dest = path.join(mono, 'plugins', origin.name)
53
+ expect(result.outcome).to.equal(OUTCOMES.CLONED)
54
+ expect(head(dest)).to.equal(originHead())
55
+ })
56
+
57
+ it('reports a failure without throwing', async () => {
58
+ options.cloneUrl = () => path.join(sandbox.root, 'does-not-exist.git')
59
+ const result = await runOne(decision(ACTIONS.CLONE, REASONS.NEW, null))
60
+
61
+ expect(result.outcome).to.equal(OUTCOMES.FAILED)
62
+ expect(result.error).to.be.a('string').and.not.equal('')
63
+ })
64
+ })
65
+
66
+ describe('FAST_FORWARD', () => {
67
+ it('pulls new commits on a clean default branch', async () => {
68
+ const repo = sandbox.cloneInto(origin, 'mono', 'plugins')
69
+ origin.push('a.txt', 'a', 'First new commit')
70
+ origin.push('b.txt', 'b', 'Second new commit')
71
+
72
+ const result = await runOne(decision(ACTIONS.FAST_FORWARD, REASONS.BEHIND, repo))
73
+
74
+ expect(result).to.include({ outcome: OUTCOMES.UPDATED, ahead: 0, behind: 2, packageJsonChanged: false })
75
+ expect(result.commits.map(c => c.subject)).to.deep.equal(['Second new commit', 'First new commit'])
76
+ expect(head(repo)).to.equal(originHead())
77
+ })
78
+
79
+ it('flags when package.json changed', async () => {
80
+ const repo = sandbox.cloneInto(origin, 'mono', 'plugins')
81
+ origin.push('package.json', '{"name":"changed"}', 'Bump deps')
82
+
83
+ const result = await runOne(decision(ACTIONS.FAST_FORWARD, REASONS.BEHIND, repo))
84
+ expect(result.packageJsonChanged).to.equal(true)
85
+ })
86
+
87
+ it('reports current when there is nothing new', async () => {
88
+ const repo = sandbox.cloneInto(origin, 'mono', 'plugins')
89
+ const result = await runOne(decision(ACTIONS.FAST_FORWARD, REASONS.BEHIND, repo))
90
+ expect(result).to.include({ outcome: OUTCOMES.CURRENT, ahead: 0, behind: 0 })
91
+ })
92
+
93
+ it('reports unpushed commits and leaves them alone', async () => {
94
+ const repo = sandbox.cloneInto(origin, 'mono', 'plugins')
95
+ const local = sandbox.commit(repo, 'mine.txt', 'mine', 'Not pushed yet')
96
+
97
+ const result = await runOne(decision(ACTIONS.FAST_FORWARD, REASONS.BEHIND, repo))
98
+ expect(result).to.include({ outcome: OUTCOMES.LOCAL_AHEAD, ahead: 1, behind: 0 })
99
+ expect(head(repo)).to.equal(local)
100
+ })
101
+
102
+ it('does not merge when the default branch has diverged', async () => {
103
+ const repo = sandbox.cloneInto(origin, 'mono', 'plugins')
104
+ const local = sandbox.commit(repo, 'mine.txt', 'mine', 'Local work')
105
+ origin.push('theirs.txt', 'theirs', 'Remote work')
106
+
107
+ const result = await runOne(decision(ACTIONS.FAST_FORWARD, REASONS.BEHIND, repo))
108
+ expect(result).to.include({ outcome: OUTCOMES.DIVERGED, ahead: 1, behind: 1 })
109
+ expect(result.commits.map(c => c.subject)).to.deep.equal(['Remote work'])
110
+ expect(head(repo)).to.equal(local)
111
+ })
112
+
113
+ it('does not overwrite an untracked file that is now tracked upstream', async () => {
114
+ const repo = sandbox.cloneInto(origin, 'mono', 'plugins')
115
+ origin.push('notes.txt', 'from upstream', 'Add notes')
116
+ sandbox.write(repo, 'notes.txt', 'my local notes')
117
+ const before = head(repo)
118
+
119
+ const result = await runOne(decision(ACTIONS.FAST_FORWARD, REASONS.BEHIND, repo))
120
+ expect(result.outcome).to.equal(OUTCOMES.BLOCKED_BY_UNTRACKED)
121
+ expect(head(repo)).to.equal(before)
122
+ expect(fs.readFileSync(path.join(repo, 'notes.txt'), 'utf8')).to.equal('my local notes')
123
+ })
124
+ })
125
+
126
+ describe('FETCH_ONLY', () => {
127
+ it('leaves a feature branch alone and reports what the default branch has', async () => {
128
+ const repo = sandbox.cloneInto(origin, 'mono', 'plugins')
129
+ git(repo, 'checkout', '--quiet', '-b', 'feature/x')
130
+ const branchHead = sandbox.commit(repo, 'feature.txt', 'wip', 'Feature work')
131
+ origin.push('fix.txt', 'fix', 'Fix on master')
132
+
133
+ const result = await runOne(decision(ACTIONS.FETCH_ONLY, REASONS.ON_BRANCH, repo))
134
+
135
+ expect(result).to.include({ outcome: OUTCOMES.BEHIND, ahead: 1, behind: 1 })
136
+ expect(result.commits.map(c => c.subject)).to.deep.equal(['Fix on master'])
137
+ expect(git(repo, 'rev-parse', '--abbrev-ref', 'HEAD')).to.equal('feature/x')
138
+ expect(head(repo)).to.equal(branchHead)
139
+ expect(fs.existsSync(path.join(repo, 'fix.txt'))).to.equal(false)
140
+ })
141
+
142
+ it('fast-forwards the local default branch without checking it out', async () => {
143
+ const repo = sandbox.cloneInto(origin, 'mono', 'plugins')
144
+ git(repo, 'checkout', '--quiet', '-b', 'feature/x')
145
+ origin.push('fix.txt', 'fix', 'Fix on master')
146
+
147
+ await runOne(decision(ACTIONS.FETCH_ONLY, REASONS.ON_BRANCH, repo))
148
+ expect(head(repo, 'master')).to.equal(originHead())
149
+ })
150
+
151
+ it('does not move a local default branch that has its own commits', async () => {
152
+ const repo = sandbox.cloneInto(origin, 'mono', 'plugins')
153
+ const localMaster = sandbox.commit(repo, 'mine.txt', 'mine', 'Unpushed on master')
154
+ git(repo, 'checkout', '--quiet', '-b', 'feature/x')
155
+ origin.push('fix.txt', 'fix', 'Fix on master')
156
+
157
+ await runOne(decision(ACTIONS.FETCH_ONLY, REASONS.ON_BRANCH, repo))
158
+ expect(head(repo, 'master')).to.equal(localMaster)
159
+ })
160
+
161
+ it('respects updateLocalDefaultBranch: false', async () => {
162
+ const repo = sandbox.cloneInto(origin, 'mono', 'plugins')
163
+ const before = head(repo, 'master')
164
+ git(repo, 'checkout', '--quiet', '-b', 'feature/x')
165
+ origin.push('fix.txt', 'fix', 'Fix on master')
166
+
167
+ options.updateLocalDefaultBranch = false
168
+ await runOne(decision(ACTIONS.FETCH_ONLY, REASONS.ON_BRANCH, repo))
169
+ expect(head(repo, 'master')).to.equal(before)
170
+ })
171
+
172
+ it('reports current when the default branch has nothing new', async () => {
173
+ const repo = sandbox.cloneInto(origin, 'mono', 'plugins')
174
+ git(repo, 'checkout', '--quiet', '-b', 'feature/x')
175
+
176
+ const result = await runOne(decision(ACTIONS.FETCH_ONLY, REASONS.ON_BRANCH, repo))
177
+ expect(result).to.include({ outcome: OUTCOMES.CURRENT, behind: 0 })
178
+ expect(result.commits).to.deep.equal([])
179
+ })
180
+
181
+ it('does not touch a checked-out default branch with uncommitted changes', async () => {
182
+ const repo = sandbox.cloneInto(origin, 'mono', 'plugins')
183
+ const before = head(repo)
184
+ sandbox.write(repo, 'package.json', '{"editing": true}')
185
+ origin.push('fix.txt', 'fix', 'Fix on master')
186
+
187
+ const result = await runOne(decision(ACTIONS.FETCH_ONLY, REASONS.LOCAL_CHANGES, repo))
188
+ expect(result).to.include({ outcome: OUTCOMES.BEHIND, behind: 1 })
189
+ expect(head(repo)).to.equal(before)
190
+ expect(fs.readFileSync(path.join(repo, 'package.json'), 'utf8')).to.equal('{"editing": true}')
191
+ })
192
+
193
+ it('handles a detached HEAD', async () => {
194
+ const repo = sandbox.cloneInto(origin, 'mono', 'plugins')
195
+ git(repo, 'checkout', '--quiet', '--detach')
196
+ origin.push('fix.txt', 'fix', 'Fix on master')
197
+
198
+ const result = await runOne(decision(ACTIONS.FETCH_ONLY, REASONS.DETACHED, repo))
199
+ expect(result).to.include({ outcome: OUTCOMES.BEHIND, behind: 1 })
200
+ expect(head(repo, 'master')).to.equal(originHead())
201
+ })
202
+ })
203
+
204
+ describe('running a whole plan', () => {
205
+ it('skips NONE decisions and isolates failures', async () => {
206
+ const repo = sandbox.cloneInto(origin, 'mono', 'plugins')
207
+ origin.push('a.txt', 'a', 'New commit')
208
+
209
+ const good = decision(ACTIONS.FAST_FORWARD, REASONS.BEHIND, repo)
210
+ const bad = decision(ACTIONS.FAST_FORWARD, REASONS.BEHIND, null, {
211
+ repoName: 'missing',
212
+ local: { path: path.join(mono, 'plugins', 'missing'), branch: 'master' }
213
+ })
214
+ const none = decision(ACTIONS.NONE, REASONS.ORPHANED, null, { repoName: 'gone' })
215
+
216
+ const seen = []
217
+ options.onResult = r => seen.push(r.decision.repoName)
218
+ const results = await executePlan({ decisions: [good, bad, none], ignored: [] }, options)
219
+
220
+ expect(results.map(r => [r.decision.repoName, r.outcome])).to.deep.equal([
221
+ [origin.name, OUTCOMES.UPDATED],
222
+ ['missing', OUTCOMES.FAILED]
223
+ ])
224
+ expect(seen).to.have.members([origin.name, 'missing'])
225
+ })
226
+
227
+ it('returns the same decision objects it was given', async () => {
228
+ const d = decision(ACTIONS.CLONE, REASONS.NEW, null)
229
+ const [result] = await executePlan({ decisions: [d], ignored: [] }, options)
230
+ expect(result.decision).to.equal(d)
231
+ })
232
+ })
233
+ })
@@ -0,0 +1,64 @@
1
+ // Builders for the plain records each stage passes to the next.
2
+ // Defaults describe the "boring" case; tests override only what they care about.
3
+
4
+ const chalk = require('chalk')
5
+
6
+ // Keep assertions on output free of colour codes
7
+ chalk.level = 0
8
+
9
+ const router = pkg => {
10
+ const keywords = pkg.keywords || []
11
+ if (!keywords.includes('tymly')) return null
12
+ if (pkg.config && pkg.config.tymly && pkg.config.tymly.sync === false) {
13
+ return { skip: 'Opted out (config.tymly.sync = false)' }
14
+ }
15
+ if (keywords.includes('blueprint')) return 'blueprints'
16
+ if (keywords.includes('plugin')) return 'plugins'
17
+ return null
18
+ }
19
+
20
+ function remote (name, overrides = {}) {
21
+ return {
22
+ name,
23
+ isPrivate: false,
24
+ defaultBranch: 'master',
25
+ sha: 'remote-sha',
26
+ messageHeadline: 'Latest commit',
27
+ author: { name: 'Someone', date: '2026-01-01T00:00:00Z' },
28
+ packageJson: { name: `@wmfs/${name}`, description: `${name} description`, keywords: ['tymly', 'plugin'] },
29
+ ...overrides
30
+ }
31
+ }
32
+
33
+ function local (name, overrides = {}) {
34
+ return {
35
+ path: `/mono/plugins/${name}`,
36
+ repoName: name,
37
+ folder: 'plugins',
38
+ isGitRepo: true,
39
+ branch: 'master',
40
+ isDetached: false,
41
+ headSha: 'local-sha',
42
+ statusKnown: true,
43
+ trackedChanges: 0,
44
+ untrackedFiles: 0,
45
+ operationInProgress: false,
46
+ origin: { owner: 'wmfs', name, url: `https://github.com/wmfs/${name}.git` },
47
+ packageJson: null,
48
+ ...overrides
49
+ }
50
+ }
51
+
52
+ function captureConsole () {
53
+ const lines = []
54
+ const original = { log: console.log, warn: console.warn }
55
+ console.log = (...args) => lines.push(args.join(' '))
56
+ console.warn = (...args) => lines.push(args.join(' '))
57
+ return {
58
+ lines,
59
+ text: () => lines.join('\n'),
60
+ restore: () => Object.assign(console, original)
61
+ }
62
+ }
63
+
64
+ module.exports = { router, remote, local, captureConsole }
@@ -0,0 +1,87 @@
1
+ // Throwaway git repositories in a temp directory, for tests that need real git.
2
+ // No network: "origins" are local bare repos.
3
+
4
+ const fs = require('fs')
5
+ const os = require('os')
6
+ const path = require('path')
7
+ const { execFileSync } = require('child_process')
8
+
9
+ // Isolate from the developer's own git config (signing, hooks, default branch, ...).
10
+ // lib/utils/git.js inherits process.env, so this applies to the code under test too.
11
+ process.env.GIT_CONFIG_GLOBAL = os.devNull
12
+ process.env.GIT_CONFIG_NOSYSTEM = '1'
13
+ process.env.GIT_AUTHOR_NAME = 'Test Author'
14
+ process.env.GIT_AUTHOR_EMAIL = 'author@example.com'
15
+ process.env.GIT_COMMITTER_NAME = 'Test Author'
16
+ process.env.GIT_COMMITTER_EMAIL = 'author@example.com'
17
+ process.env.GIT_TERMINAL_PROMPT = '0'
18
+
19
+ function git (cwd, ...args) {
20
+ return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim()
21
+ }
22
+
23
+ function tryGit (cwd, ...args) {
24
+ try {
25
+ return git(cwd, ...args)
26
+ } catch (e) {
27
+ return null
28
+ }
29
+ }
30
+
31
+ class Sandbox {
32
+ constructor () {
33
+ this.root = fs.mkdtempSync(path.join(os.tmpdir(), 'lerna-sync-test-'))
34
+ }
35
+
36
+ dir (...parts) {
37
+ const p = path.join(this.root, ...parts)
38
+ fs.mkdirSync(p, { recursive: true })
39
+ return p
40
+ }
41
+
42
+ write (repoPath, file, content) {
43
+ const full = path.join(repoPath, file)
44
+ fs.mkdirSync(path.dirname(full), { recursive: true })
45
+ fs.writeFileSync(full, content)
46
+ }
47
+
48
+ commit (repoPath, file, content, message = `Change ${file}`) {
49
+ this.write(repoPath, file, content)
50
+ git(repoPath, 'add', file)
51
+ git(repoPath, 'commit', '--quiet', '-m', message)
52
+ return git(repoPath, 'rev-parse', 'HEAD')
53
+ }
54
+
55
+ // A bare "GitHub" repo plus a working clone used to push new commits to it
56
+ createOrigin (name) {
57
+ const bare = this.dir('remotes', 'wmfs', `${name}.git`)
58
+ git(bare, 'init', '--quiet', '--bare', '-b', 'master')
59
+
60
+ const pusher = path.join(this.root, 'pushers', name)
61
+ git(this.root, 'clone', '--quiet', bare, pusher)
62
+ git(pusher, 'symbolic-ref', 'HEAD', 'refs/heads/master')
63
+ this.commit(pusher, 'package.json', JSON.stringify({ name, keywords: ['tymly', 'plugin'] }, null, 2), 'Initial commit')
64
+ git(pusher, 'push', '--quiet', 'origin', 'master')
65
+
66
+ const push = (file, content, message) => {
67
+ const sha = this.commit(pusher, file, content, message)
68
+ git(pusher, 'push', '--quiet', 'origin', 'master')
69
+ return sha
70
+ }
71
+
72
+ return { name, bare, pusher, push }
73
+ }
74
+
75
+ // A developer's clone of an origin, inside the monorepo husk
76
+ cloneInto (origin, ...folderParts) {
77
+ const dest = path.join(this.dir(...folderParts), origin.name)
78
+ git(this.root, 'clone', '--quiet', origin.bare, dest)
79
+ return dest
80
+ }
81
+
82
+ cleanup () {
83
+ fs.rmSync(this.root, { recursive: true, force: true })
84
+ }
85
+ }
86
+
87
+ module.exports = { Sandbox, git, tryGit }
@@ -0,0 +1,148 @@
1
+ /* eslint-env mocha */
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const { expect } = require('chai')
6
+ const { Sandbox, git, tryGit } = require('./helpers/git-sandbox')
7
+ const { inspectLocalRepos, inspectRepo } = require('../lib/inspect')
8
+
9
+ describe('inspect (real git)', function () {
10
+ this.timeout(30000)
11
+
12
+ let sandbox, mono, origin
13
+
14
+ beforeEach(() => {
15
+ sandbox = new Sandbox()
16
+ origin = sandbox.createOrigin('tymly-test-plugin')
17
+
18
+ // The husk is itself a git repo, as the real tymly monorepo is
19
+ mono = sandbox.dir('mono')
20
+ git(mono, 'init', '--quiet', '-b', 'master')
21
+ sandbox.dir('mono', 'plugins')
22
+ sandbox.dir('mono', 'blueprints')
23
+ })
24
+
25
+ afterEach(() => sandbox.cleanup())
26
+
27
+ const cloneAndPointAtGitHub = (...folder) => {
28
+ const repo = sandbox.cloneInto(origin, 'mono', ...folder)
29
+ git(repo, 'remote', 'set-url', 'origin', `https://github.com/wmfs/${origin.name}.git`)
30
+ return repo
31
+ }
32
+
33
+ it('finds one record per folder matched by the lerna patterns', async () => {
34
+ cloneAndPointAtGitHub('plugins')
35
+ sandbox.dir('mono', 'blueprints', 'scratch')
36
+ fs.writeFileSync(path.join(mono, 'plugins', 'README.md'), 'a file, not a package folder')
37
+
38
+ const records = await inspectLocalRepos(mono, ['plugins/*', 'blueprints/*'])
39
+
40
+ expect(records.map(r => `${r.folder}/${r.repoName}`))
41
+ .to.deep.equal(['blueprints/scratch', `plugins/${origin.name}`])
42
+ })
43
+
44
+ it('returns nothing for empty package folders', async () => {
45
+ expect(await inspectLocalRepos(mono, ['plugins/*', 'blueprints/*'])).to.deep.equal([])
46
+ })
47
+
48
+ it('reads a clean clone on the default branch', async () => {
49
+ const repo = cloneAndPointAtGitHub('plugins')
50
+ const record = await inspectRepo(mono, repo)
51
+
52
+ expect(record).to.include({
53
+ repoName: origin.name,
54
+ folder: 'plugins',
55
+ isGitRepo: true,
56
+ branch: 'master',
57
+ isDetached: false,
58
+ headSha: git(repo, 'rev-parse', 'HEAD'),
59
+ statusKnown: true,
60
+ trackedChanges: 0,
61
+ untrackedFiles: 0,
62
+ operationInProgress: false
63
+ })
64
+ expect(record.origin).to.include({ owner: 'wmfs', name: origin.name })
65
+ expect(record.packageJson.name).to.equal(origin.name)
66
+ })
67
+
68
+ it('does not mistake a plain folder for a repo, even inside the husk\'s repo', async () => {
69
+ const plain = sandbox.dir('mono', 'plugins', 'not-a-repo')
70
+ const record = await inspectRepo(mono, plain)
71
+
72
+ expect(record).to.deep.equal({
73
+ path: plain,
74
+ repoName: 'not-a-repo',
75
+ folder: 'plugins',
76
+ isGitRepo: false
77
+ })
78
+ })
79
+
80
+ it('reports the current feature branch', async () => {
81
+ const repo = cloneAndPointAtGitHub('plugins')
82
+ git(repo, 'checkout', '--quiet', '-b', 'feature/new-thing')
83
+
84
+ const record = await inspectRepo(mono, repo)
85
+ expect(record).to.include({ branch: 'feature/new-thing', isDetached: false })
86
+ })
87
+
88
+ it('reports a detached HEAD', async () => {
89
+ const repo = cloneAndPointAtGitHub('plugins')
90
+ git(repo, 'checkout', '--quiet', '--detach')
91
+
92
+ const record = await inspectRepo(mono, repo)
93
+ expect(record).to.include({ branch: null, isDetached: true })
94
+ expect(record.headSha).to.equal(git(repo, 'rev-parse', 'HEAD'))
95
+ })
96
+
97
+ it('counts tracked changes and untracked files separately', async () => {
98
+ const repo = cloneAndPointAtGitHub('plugins')
99
+ sandbox.write(repo, 'package.json', '{"changed": true}')
100
+ sandbox.write(repo, 'new-1.txt', 'x')
101
+ sandbox.write(repo, 'new-2.txt', 'x')
102
+
103
+ const record = await inspectRepo(mono, repo)
104
+ expect(record).to.include({ trackedChanges: 1, untrackedFiles: 2 })
105
+ })
106
+
107
+ it('detects a merge in progress', async () => {
108
+ const repo = cloneAndPointAtGitHub('plugins')
109
+ git(repo, 'checkout', '--quiet', '-b', 'other')
110
+ sandbox.commit(repo, 'conflict.txt', 'theirs')
111
+ git(repo, 'checkout', '--quiet', 'master')
112
+ sandbox.commit(repo, 'conflict.txt', 'ours')
113
+ tryGit(repo, 'merge', 'other') // conflicts, leaving MERGE_HEAD
114
+
115
+ const record = await inspectRepo(mono, repo)
116
+ expect(record.operationInProgress).to.equal(true)
117
+ })
118
+
119
+ it('handles a repo with no commits yet', async () => {
120
+ const repo = sandbox.dir('mono', 'plugins', 'brand-new')
121
+ git(repo, 'init', '--quiet', '-b', 'master')
122
+
123
+ const record = await inspectRepo(mono, repo)
124
+ expect(record).to.include({ isGitRepo: true, headSha: null, branch: 'master' })
125
+ expect(record.origin).to.equal(null)
126
+ })
127
+
128
+ it('parses SSH origin URLs', async () => {
129
+ const repo = cloneAndPointAtGitHub('plugins')
130
+ git(repo, 'remote', 'set-url', 'origin', 'git@github.com:wmfs/renamed-repo.git')
131
+
132
+ const record = await inspectRepo(mono, repo)
133
+ expect(record.origin).to.include({ owner: 'wmfs', name: 'renamed-repo' })
134
+ })
135
+
136
+ it('records a non-GitHub origin without an owner', async () => {
137
+ const repo = sandbox.cloneInto(origin, 'mono', 'plugins') // origin is a local path
138
+
139
+ const record = await inspectRepo(mono, repo)
140
+ expect(record.origin).to.include({ owner: null, name: null })
141
+ })
142
+
143
+ it('reports nested package folders relative to the husk', async () => {
144
+ const repo = cloneAndPointAtGitHub('plugins', 'core')
145
+ const record = await inspectRepo(mono, repo)
146
+ expect(record.folder).to.equal('plugins/core')
147
+ })
148
+ })