- Pipelines can be created to push snapshots to a connected Git repository. Currently, only GitHub.com and Azure DevOps hosted repositories are supported.
+ Pipelines can be created to push snapshots to a connected Git repository — GitHub, Azure DevOps, GitLab, Bitbucket, or any HTTPS Git server.
Here you can manage the tokens used by your pipelines to access the repositories.
- To get started, create a GitHub or Azure DevOps Personal Access Token and add it here. You can then create Git Repository pipeline stages.
+ To get started, create a personal access token on your Git provider and add it here. You can then create Git Repository pipeline stages.
Name
Token
+ Username
+
+ CA Certificate (optional)
+ Only needed for self-hosted servers that use a private certificate authority.
+
+
@@ -21,12 +27,14 @@
import { markRaw } from 'vue'
import AzureInstructions from './components/CreateGitTokenDialog/AzureInstructions.vue'
+import GenericInstructions from './components/CreateGitTokenDialog/GenericInstructions.vue'
import GitHubInstructions from './components/CreateGitTokenDialog/GitHubInstructions.vue'
import teamApi from '@/api/team.js'
import FormRow from '@/components/FormRow.vue'
import AzureIcon from '@/components/icons/Azure.js'
+import GitIcon from '@/components/icons/Git.js'
import GitHubIcon from '@/components/icons/GitHub.js'
import { CascadingSelector, OptionTileSelector } from '@/components/variant-selector/index.js'
import alerts from '@/services/alerts.js'
@@ -51,6 +59,8 @@ export default {
this.input.name = ''
this.input.token = ''
this.input.type = 'github'
+ this.input.username = ''
+ this.input.caCertificate = ''
this.$refs.dialog.show()
}
}
@@ -60,13 +70,15 @@ export default {
input: {
name: '',
token: '',
- type: 'github'
+ type: 'github',
+ username: '',
+ caCertificate: ''
},
errors: {},
providerTree: {
id: 'root',
component: markRaw(OptionTileSelector),
- props: { columns: 2 },
+ props: { columns: 3 },
children: [
{
id: 'github',
@@ -77,6 +89,11 @@ export default {
id: 'azure',
component: markRaw(AzureInstructions),
props: { label: 'Azure DevOps', icon: markRaw(AzureIcon) }
+ },
+ {
+ id: 'generic',
+ component: markRaw(GenericInstructions),
+ props: { label: 'Other', icon: markRaw(GitIcon) }
}
]
}
@@ -92,6 +109,8 @@ export default {
'input.type' () {
this.input.name = ''
this.input.token = ''
+ this.input.username = ''
+ this.input.caCertificate = ''
this.errors = {}
}
},
@@ -101,7 +120,9 @@ export default {
name: this.input.name.trim(),
token: this.input.token,
team: this.team.id,
- type: this.input.type
+ type: this.input.type,
+ username: this.input.username,
+ caCertificate: this.input.caCertificate
}
this.$emit('token-creating')
teamApi.createGitToken(opts.team, opts).then((response) => {
diff --git a/frontend/src/pages/team/Settings/dialogs/components/CreateGitTokenDialog/GenericInstructions.vue b/frontend/src/pages/team/Settings/dialogs/components/CreateGitTokenDialog/GenericInstructions.vue
new file mode 100644
index 0000000000..7296e38387
--- /dev/null
+++ b/frontend/src/pages/team/Settings/dialogs/components/CreateGitTokenDialog/GenericInstructions.vue
@@ -0,0 +1,17 @@
+
+
+ Connect to any Git server (GitLab, Bitbucket, Gitea, or a self-hosted instance) over HTTPS.
+
+ - Create a Personal Access Token (or App Password) on your Git server with read & write access to the repository
+ - Enter the username associated with that token
+ - Paste the token value into the field below
+
+
+
+
+
diff --git a/frontend/src/types/generated.ts b/frontend/src/types/generated.ts
index 86625dec01..322e7eed80 100644
--- a/frontend/src/types/generated.ts
+++ b/frontend/src/types/generated.ts
@@ -10939,10 +10939,10 @@ export interface components {
pullBranch: string;
pushPath: string;
pullPath: string;
- lastPushAt: string;
- lastPullAt: string;
- status: string;
- statusMessage: string;
+ lastPushAt: string | null;
+ lastPullAt: string | null;
+ status: string | null;
+ statusMessage: string | null;
credentialSecret: boolean;
};
/** @enum {string} */
diff --git a/package-lock.json b/package-lock.json
index a2c2796314..17a96a13c2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -131,6 +131,7 @@
"postcss-loader": "^8.1.1",
"postcss-preset-env": "^9.1.3",
"promptly": "^3.2.0",
+ "proxyquire": "^2.1.3",
"sass": "^1.97.3",
"sass-loader": "^16.0.2",
"should": "^13.2.3",
@@ -14462,6 +14463,20 @@
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="
},
+ "node_modules/fill-keys": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/fill-keys/-/fill-keys-1.0.2.tgz",
+ "integrity": "sha512-tcgI872xXjwFF4xgQmLxi76GnwJG3g/3isB1l4/G5Z4zrbddGpBjqZCO9oEAcB5wX0Hj/5iQB3toxfO7in1hHA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-object": "~1.0.1",
+ "merge-descriptors": "~1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -16196,6 +16211,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-object": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz",
+ "integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/is-path-cwd": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz",
@@ -18634,6 +18659,13 @@
"resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz",
"integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="
},
+ "node_modules/module-not-found-error": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/module-not-found-error/-/module-not-found-error-1.0.1.tgz",
+ "integrity": "sha512-pEk4ECWQXV6z2zjhRZUongnLJNUeGQJ3w6OQ5ctGwD+i5o93qjRQUk2Rt6VdNeu3sEP0AB4LcfvdebpxBRVr4g==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/moment": {
"version": "2.29.4",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
@@ -21453,6 +21485,18 @@
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
+ "node_modules/proxyquire": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/proxyquire/-/proxyquire-2.1.3.tgz",
+ "integrity": "sha512-BQWfCqYM+QINd+yawJz23tbBM40VIGXOdDw3X344KcclI/gtBbdWF6SlQ4nK/bYhF9d27KYug9WzljHC6B9Ysg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-keys": "^1.0.2",
+ "module-not-found-error": "^1.0.1",
+ "resolve": "^1.11.1"
+ }
+ },
"node_modules/pseudomap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz",
@@ -36131,6 +36175,16 @@
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="
},
+ "fill-keys": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/fill-keys/-/fill-keys-1.0.2.tgz",
+ "integrity": "sha512-tcgI872xXjwFF4xgQmLxi76GnwJG3g/3isB1l4/G5Z4zrbddGpBjqZCO9oEAcB5wX0Hj/5iQB3toxfO7in1hHA==",
+ "dev": true,
+ "requires": {
+ "is-object": "~1.0.1",
+ "merge-descriptors": "~1.0.0"
+ }
+ },
"fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -37304,6 +37358,12 @@
"has-tostringtag": "^1.0.0"
}
},
+ "is-object": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz",
+ "integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==",
+ "dev": true
+ },
"is-path-cwd": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz",
@@ -38953,6 +39013,12 @@
"resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz",
"integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="
},
+ "module-not-found-error": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/module-not-found-error/-/module-not-found-error-1.0.1.tgz",
+ "integrity": "sha512-pEk4ECWQXV6z2zjhRZUongnLJNUeGQJ3w6OQ5ctGwD+i5o93qjRQUk2Rt6VdNeu3sEP0AB4LcfvdebpxBRVr4g==",
+ "dev": true
+ },
"moment": {
"version": "2.29.4",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
@@ -40789,6 +40855,17 @@
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
+ "proxyquire": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/proxyquire/-/proxyquire-2.1.3.tgz",
+ "integrity": "sha512-BQWfCqYM+QINd+yawJz23tbBM40VIGXOdDw3X344KcclI/gtBbdWF6SlQ4nK/bYhF9d27KYug9WzljHC6B9Ysg==",
+ "dev": true,
+ "requires": {
+ "fill-keys": "^1.0.2",
+ "module-not-found-error": "^1.0.1",
+ "resolve": "^1.11.1"
+ }
+ },
"pseudomap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz",
diff --git a/package.json b/package.json
index 6c4371470b..7627ba6a49 100644
--- a/package.json
+++ b/package.json
@@ -173,6 +173,7 @@
"postcss-loader": "^8.1.1",
"postcss-preset-env": "^9.1.3",
"promptly": "^3.2.0",
+ "proxyquire": "^2.1.3",
"sass": "^1.97.3",
"sass-loader": "^16.0.2",
"should": "^13.2.3",
diff --git a/test/unit/forge/ee/db/models/GitToken_spec.js b/test/unit/forge/ee/db/models/GitToken_spec.js
new file mode 100644
index 0000000000..359f8f62b1
--- /dev/null
+++ b/test/unit/forge/ee/db/models/GitToken_spec.js
@@ -0,0 +1,43 @@
+const should = require('should') // eslint-disable-line
+const setup = require('../../setup')
+
+describe('GitToken Model', function () {
+ let app
+
+ before(async function () {
+ const license = 'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImZkNDFmNmRjLTBmM2QtNGFmNy1hNzk0LWIyNWFhNGJmYTliZCIsInZlciI6IjIwMjQtMDMtMDQiLCJpc3MiOiJGbG93Rm9yZ2UgSW5jLiIsInN1YiI6IkZsb3dGdXNlIERldmVsb3BtZW50IiwibmJmIjoxNzMwNjc4NDAwLCJleHAiOjIwNzc3NDcyMDAsIm5vdGUiOiJEZXZlbG9wbWVudC1tb2RlIE9ubHkuIE5vdCBmb3IgcHJvZHVjdGlvbiIsInVzZXJzIjoxMCwidGVhbXMiOjEwLCJpbnN0YW5jZXMiOjEwLCJtcXR0Q2xpZW50cyI6NiwidGllciI6ImVudGVycHJpc2UiLCJkZXYiOnRydWUsImlhdCI6MTczMDcyMTEyNH0.02KMRf5kogkpH3HXHVSGprUm0QQFLn21-3QIORhxFgRE9N5DIE8YnTH_f8W_21T6TlYbDUmf4PtWyj120HTM2w'
+ app = await setup({ license })
+ })
+
+ after(async function () {
+ await app.close()
+ })
+
+ it('stores username and caCertificate for a generic token', async function () {
+ const cert = '-----BEGIN CERTIFICATE-----\nMIICertContent\n-----END CERTIFICATE-----'
+ const token = await app.db.models.GitToken.create({
+ name: 'generic-token',
+ token: 'secret-token',
+ type: 'generic',
+ username: 'noley',
+ caCertificate: cert,
+ TeamId: app.team.id
+ })
+ const reloaded = await app.db.models.GitToken.byId(token.id, app.team.id)
+ reloaded.should.have.property('type', 'generic')
+ reloaded.should.have.property('username', 'noley')
+ reloaded.should.have.property('caCertificate', cert)
+ })
+
+ it('leaves username and caCertificate null when omitted', async function () {
+ const token = await app.db.models.GitToken.create({
+ name: 'github-token',
+ token: 'gh-token',
+ type: 'github',
+ TeamId: app.team.id
+ })
+ const reloaded = await app.db.models.GitToken.byId(token.id, app.team.id)
+ should(reloaded.username).be.null()
+ should(reloaded.caCertificate).be.null()
+ })
+})
diff --git a/test/unit/forge/ee/lib/gitops/backends/generic_spec.js b/test/unit/forge/ee/lib/gitops/backends/generic_spec.js
new file mode 100644
index 0000000000..e9f43b36f7
--- /dev/null
+++ b/test/unit/forge/ee/lib/gitops/backends/generic_spec.js
@@ -0,0 +1,165 @@
+const fs = require('node:fs/promises')
+const path = require('node:path')
+
+const proxyquire = require('proxyquire')
+const sinon = require('sinon')
+
+require('should')
+
+const MODULE = '../../../../../../../forge/ee/lib/gitops/backends/generic'
+
+describe('Generic git backend', function () {
+ let cloneStub, execFileStub, exportSnapshotStub, snapshotExportStub, encryptStub, decryptStub
+
+ const baseOptions = { pipeline: { name: 'My Pipeline' }, user: { username: 'alice' } }
+
+ function loadBackend () {
+ const generic = proxyquire(MODULE, {
+ 'node:child_process': { execFile: execFileStub },
+ './utils': { cloneRepository: cloneStub },
+ '../../../../db/utils': { encryptValue: encryptStub, decryptValue: decryptStub }
+ })
+ const app = {
+ db: {
+ controllers: { Snapshot: { exportSnapshot: exportSnapshotStub } },
+ views: { ProjectSnapshot: { snapshotExport: snapshotExportStub } }
+ },
+ config: { domain: 'example.com' }
+ }
+ return generic.init(app)
+ }
+
+ beforeEach(function () {
+ cloneStub = sinon.stub().resolves()
+ // promisify(execFile) calls (file, args, opts, cb) — default to success
+ execFileStub = sinon.stub().callsFake((file, args, opts, cb) => cb(null, { stdout: '', stderr: '' }))
+ encryptStub = sinon.stub().returns('ENCRYPTED')
+ decryptStub = sinon.stub().returns('DECRYPTED')
+ exportSnapshotStub = sinon.stub().resolves({ flows: [] })
+ snapshotExportStub = sinon.stub().returns({ name: 'snap', settings: {} })
+ })
+
+ describe('pushToRepository', function () {
+ it('rejects non-HTTPS urls', async function () {
+ const backend = await loadBackend()
+ await backend.pushToRepository({ url: 'http://example.com/r.git' }, {}, baseOptions)
+ .should.be.rejectedWith('Only HTTPS git URLs are supported')
+ })
+
+ it('clones with embedded username/token, then commits and pushes', async function () {
+ const backend = await loadBackend()
+ await backend.pushToRepository(
+ { url: 'https://git.example.com/o/r.git', username: 'bob', token: 'tok', credentialSecret: 's', path: 'snapshot.json' },
+ {}, baseOptions
+ )
+ cloneStub.calledOnce.should.be.true()
+ const urlArg = cloneStub.firstCall.args[0]
+ urlArg.username.should.equal('bob')
+ urlArg.password.should.equal('tok')
+ // config x3 + add + commit + push
+ execFileStub.callCount.should.equal(6)
+ execFileStub.getCalls().map(c => c.args[1][0]).should.containDeep(['config', 'add', 'commit', 'push'])
+ })
+
+ it('falls back to x-access-token when no username given', async function () {
+ const backend = await loadBackend()
+ await backend.pushToRepository(
+ { url: 'https://git.example.com/o/r.git', token: 'tok', credentialSecret: 's' },
+ {}, baseOptions
+ )
+ cloneStub.firstCall.args[0].username.should.equal('x-access-token')
+ })
+
+ it('writes a CA file and passes GIT_SSL_CAINFO when caCertificate is set', async function () {
+ const backend = await loadBackend()
+ await backend.pushToRepository(
+ { url: 'https://git.example.com/o/r.git', token: 'tok', caCertificate: 'CERT', credentialSecret: 's' },
+ {}, baseOptions
+ )
+ const env = cloneStub.firstCall.args[3]
+ env.should.have.property('GIT_SSL_CAINFO')
+ env.GIT_SSL_CAINFO.should.match(/-ca\.pem$/)
+ })
+
+ it('encrypts the npmrc in the exported snapshot', async function () {
+ snapshotExportStub.returns({ name: 'snap', settings: { settings: { palette: { npmrc: 'registry=x' } } } })
+ const backend = await loadBackend()
+ await backend.pushToRepository(
+ { url: 'https://git.example.com/o/r.git', token: 'tok', credentialSecret: 's' },
+ {}, baseOptions
+ )
+ encryptStub.calledOnce.should.be.true()
+ })
+
+ it('maps an unable-to-access push failure to invalid_token', async function () {
+ execFileStub.callsFake((file, args, opts, cb) => {
+ if (args[0] === 'push') {
+ const err = new Error('boom'); err.stdout = ''; err.stderr = 'fatal: unable to access'; return cb(err)
+ }
+ cb(null, { stdout: '', stderr: '' })
+ })
+ const backend = await loadBackend()
+ let caught
+ try {
+ await backend.pushToRepository({ url: 'https://git.example.com/o/r.git', token: 'tok', credentialSecret: 's' }, {}, baseOptions)
+ } catch (err) { caught = err }
+ caught.message.should.equal('Permission denied')
+ caught.code.should.equal('invalid_token')
+ })
+
+ it('surfaces a fatal push error message', async function () {
+ execFileStub.callsFake((file, args, opts, cb) => {
+ if (args[0] === 'push') {
+ const err = new Error('boom'); err.stdout = ''; err.stderr = 'fatal: remote rejected'; return cb(err)
+ }
+ cb(null, { stdout: '', stderr: '' })
+ })
+ const backend = await loadBackend()
+ await backend.pushToRepository({ url: 'https://git.example.com/o/r.git', token: 'tok', credentialSecret: 's' }, {}, baseOptions)
+ .should.be.rejectedWith('Failed to push repository: remote rejected')
+ })
+ })
+
+ describe('pullFromRepository', function () {
+ it('rejects non-HTTPS urls', async function () {
+ const backend = await loadBackend()
+ await backend.pullFromRepository({ url: 'http://example.com/r.git' })
+ .should.be.rejectedWith('Only HTTPS git URLs are supported')
+ })
+
+ it('throws when the snapshot file is missing', async function () {
+ const backend = await loadBackend()
+ await backend.pullFromRepository({ url: 'https://git.example.com/o/r.git', token: 't', credentialSecret: 's' })
+ .should.be.rejectedWith('Snapshot file not found in repository')
+ })
+
+ it('reads and decrypts the snapshot file', async function () {
+ const snapshotContent = {
+ name: 'snap',
+ settings: {
+ env: { SECRET: { hidden: true, $: 'enc' }, PLAIN: { value: 'x' } },
+ settings: { palette: { npmrc: { $: 'encnpm' } } }
+ }
+ }
+ cloneStub.callsFake(async (url, branch, workingDir) => {
+ await fs.writeFile(path.join(workingDir, 'snapshot.json'), JSON.stringify(snapshotContent))
+ })
+ const backend = await loadBackend()
+ const result = await backend.pullFromRepository({ url: 'https://git.example.com/o/r.git', token: 't', credentialSecret: 's' })
+ result.name.should.equal('snap')
+ result.settings.env.SECRET.value.should.equal('DECRYPTED')
+ result.settings.env.SECRET.should.not.have.property('$')
+ result.settings.settings.palette.npmrc.should.equal('DECRYPTED')
+ decryptStub.callCount.should.equal(2)
+ })
+
+ it('passes GIT_SSL_CAINFO on pull when caCertificate is set', async function () {
+ cloneStub.callsFake(async (url, branch, workingDir) => {
+ await fs.writeFile(path.join(workingDir, 'snapshot.json'), JSON.stringify({ name: 'snap' }))
+ })
+ const backend = await loadBackend()
+ await backend.pullFromRepository({ url: 'https://git.example.com/o/r.git', token: 't', caCertificate: 'CERT', credentialSecret: 's' })
+ cloneStub.firstCall.args[3].should.have.property('GIT_SSL_CAINFO')
+ })
+ })
+})
diff --git a/test/unit/forge/ee/lib/gitops/backends/utils_spec.js b/test/unit/forge/ee/lib/gitops/backends/utils_spec.js
new file mode 100644
index 0000000000..1302c1c0e9
--- /dev/null
+++ b/test/unit/forge/ee/lib/gitops/backends/utils_spec.js
@@ -0,0 +1,60 @@
+const proxyquire = require('proxyquire')
+const sinon = require('sinon')
+
+require('should')
+
+const MODULE = '../../../../../../../forge/ee/lib/gitops/backends/utils'
+
+describe('gitops cloneRepository', function () {
+ let execFileStub
+ const url = new URL('https://git.example.com/o/r.git')
+
+ function load () {
+ return proxyquire(MODULE, {
+ 'node:child_process': { execFile: execFileStub }
+ }).cloneRepository
+ }
+
+ function rejectWith (stderr) {
+ execFileStub.callsFake((file, args, opts, cb) => {
+ const err = new Error('boom'); err.stdout = ''; err.stderr = stderr; cb(err)
+ })
+ }
+
+ beforeEach(function () {
+ execFileStub = sinon.stub().callsFake((file, args, opts, cb) => cb(null, { stdout: '', stderr: '' }))
+ })
+
+ it('runs git clone with the expected arguments', async function () {
+ const cloneRepository = load()
+ await cloneRepository(url, 'main', '/tmp/x')
+ execFileStub.calledOnce.should.be.true()
+ execFileStub.firstCall.args[0].should.equal('git')
+ execFileStub.firstCall.args[1].should.containDeep(['clone', '-b', 'main', '--single-branch'])
+ })
+
+ it('maps unable-to-access to Permission denied', async function () {
+ rejectWith('fatal: unable to access')
+ const cloneRepository = load()
+ let caught
+ try { await cloneRepository(url, 'main', '/tmp/x') } catch (err) { caught = err }
+ caught.message.should.equal('Permission denied')
+ caught.code.should.equal('invalid_token')
+ })
+
+ it('maps a missing remote branch to Branch not found', async function () {
+ rejectWith('fatal: Remote branch main not found')
+ const cloneRepository = load()
+ let caught
+ try { await cloneRepository(url, 'main', '/tmp/x') } catch (err) { caught = err }
+ caught.message.should.equal('Branch not found')
+ caught.code.should.equal('invalid_branch')
+ })
+
+ it('surfaces a generic fatal clone error', async function () {
+ rejectWith('fatal: something broke')
+ const cloneRepository = load()
+ await cloneRepository(url, 'main', '/tmp/x')
+ .should.be.rejectedWith('Failed to clone repository: something broke')
+ })
+})