├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── index.js ├── package.json └── test └── basic.js /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: / 5 | schedule: 6 | interval: daily 7 | labels: 8 | - dependency 9 | versioning-strategy: increase-if-necessary 10 | - package-ecosystem: github-actions 11 | directory: / 12 | schedule: 13 | interval: daily 14 | labels: 15 | - dependency 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 'on': 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Node ${{ matrix.node }} / ${{ matrix.os }} 8 | runs-on: ${{ matrix.os }} 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | os: 13 | - ubuntu-latest 14 | node: 15 | - '14' 16 | steps: 17 | - uses: actions/checkout@v3 18 | - uses: actions/setup-node@v3.1.0 19 | with: 20 | node-version: ${{ matrix.node }} 21 | - run: npm install 22 | - run: npm run build --if-present 23 | - run: npm test 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tmp/ 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Feross Aboukhadijeh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # git-pull-or-clone [![ci][ci-image]][ci-url] [![npm][npm-image]][npm-url] [![downloads][downloads-image]][downloads-url] 2 | 3 | [ci-image]: https://img.shields.io/github/workflow/status/feross/git-pull-or-clone/ci/master 4 | [ci-url]: https://github.com/feross/git-pull-or-clone/actions 5 | [npm-image]: https://img.shields.io/npm/v/git-pull-or-clone.svg 6 | [npm-url]: https://npmjs.org/package/git-pull-or-clone 7 | [downloads-image]: https://img.shields.io/npm/dm/git-pull-or-clone.svg 8 | [downloads-url]: https://npmjs.org/package/git-pull-or-clone 9 | 10 | ### Ensure a git repo exists on disk and that it's up-to-date 11 | 12 | ## Install 13 | 14 | ``` 15 | npm install git-pull-or-clone 16 | ``` 17 | 18 | ## Usage 19 | 20 | ```js 21 | const gitPullOrClone = require('git-pull-or-clone') 22 | 23 | gitPullOrClone('git@github.com:feross/standard.git', '/path/to/destination', (err) => { 24 | if (err) throw err 25 | console.log('SUCCESS!') 26 | }) 27 | ``` 28 | 29 | ## API 30 | 31 | ### `gitPullOrClone(url, outPath[, options], callback)` 32 | 33 | Ensure a git repo exists on disk and that it's up-to-date. 34 | 35 | Clones the git repo specified by `url` to the path `outPath`. If the repo already exists on disk, 36 | then a pull is performed to update the repo instead. 37 | 38 | The git repo is shallowly cloned by default. To make a complete clone, set `options.depth` to `Infinity`. If the git repo was previously cloned shallowly, it remains shallow. 39 | 40 | When the operation is finished, `callback` is called. The first argument to `callback` is either 41 | `null` or an `Error` object if an error occurred. 42 | 43 | ## License 44 | 45 | MIT. Copyright (c) [Feross Aboukhadijeh](http://feross.org). 46 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /*! git-pull-or-clone. MIT License. Feross Aboukhadijeh */ 2 | module.exports = gitPullOrClone 3 | 4 | const crossSpawn = require('cross-spawn') 5 | const debug = require('debug')('git-pull-or-clone') 6 | const fs = require('fs') 7 | 8 | function gitPullOrClone (url, outPath, opts, cb) { 9 | if (typeof opts === 'function') { 10 | cb = opts 11 | opts = {} 12 | } 13 | 14 | const depth = opts.depth == null ? 1 : opts.depth 15 | 16 | if (depth <= 0) { 17 | throw new RangeError('The "depth" option must be greater than 0') 18 | } 19 | 20 | fs.access(outPath, fs.R_OK | fs.W_OK, function (err) { 21 | if (err) { 22 | gitClone() 23 | } else { 24 | gitPull() 25 | } 26 | }) 27 | 28 | function gitClone () { 29 | // --depth implies --single-branch 30 | const flag = depth < Infinity ? '--depth=' + depth : '--single-branch' 31 | const args = ['clone', flag, '--', url, outPath] 32 | debug('git ' + args.join(' ')) 33 | spawn('git', args, {}, function (err) { 34 | if (err) err.message += ' (git clone) (' + url + ')' 35 | cb(err) 36 | }) 37 | } 38 | 39 | function gitPull () { 40 | const args = depth < Infinity ? ['pull', '--depth=' + depth] : ['pull'] 41 | debug('git ' + args.join(' ')) 42 | spawn('git', args, { cwd: outPath }, function (err) { 43 | if (err) err.message += ' (git pull) (' + url + ')' 44 | cb(err) 45 | }) 46 | } 47 | } 48 | 49 | function spawn (command, args, opts, cb) { 50 | opts.stdio = debug.enabled ? 'inherit' : 'ignore' 51 | 52 | const child = crossSpawn(command, args, opts) 53 | child.on('error', cb) 54 | child.on('close', function (code) { 55 | if (code !== 0) return cb(new Error('Non-zero exit code: ' + code)) 56 | cb(null) 57 | }) 58 | return child 59 | } 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "git-pull-or-clone", 3 | "description": "Ensure a git repo exists on disk and that it's up-to-date", 4 | "version": "2.0.2", 5 | "author": { 6 | "name": "Feross Aboukhadijeh", 7 | "email": "feross@feross.org", 8 | "url": "https://feross.org" 9 | }, 10 | "bugs": { 11 | "url": "https://github.com/feross/git-pull-or-clone/issues" 12 | }, 13 | "dependencies": { 14 | "cross-spawn": "^7.0.0", 15 | "debug": "^4.1.1" 16 | }, 17 | "devDependencies": { 18 | "rimraf": "^3.0.0", 19 | "standard": "*", 20 | "tape": "^5.0.0" 21 | }, 22 | "engines": { 23 | "node": ">=8" 24 | }, 25 | "keywords": [ 26 | "clone", 27 | "git", 28 | "git clone", 29 | "git pull", 30 | "git pull or clone", 31 | "git sync", 32 | "pull", 33 | "pull or clone", 34 | "sync" 35 | ], 36 | "license": "MIT", 37 | "main": "index.js", 38 | "repository": { 39 | "type": "git", 40 | "url": "git://github.com/feross/git-pull-or-clone.git" 41 | }, 42 | "scripts": { 43 | "test": "standard && DEBUG=git-pull-or-clone tape test/*.js" 44 | }, 45 | "funding": [ 46 | { 47 | "type": "github", 48 | "url": "https://github.com/sponsors/feross" 49 | }, 50 | { 51 | "type": "patreon", 52 | "url": "https://www.patreon.com/feross" 53 | }, 54 | { 55 | "type": "consulting", 56 | "url": "https://feross.org/support" 57 | } 58 | ] 59 | } 60 | -------------------------------------------------------------------------------- /test/basic.js: -------------------------------------------------------------------------------- 1 | const gitPullOrClone = require('../') 2 | const path = require('path') 3 | const rimraf = require('rimraf') 4 | const test = require('tape') 5 | const fs = require('fs') 6 | const noop = () => {} 7 | 8 | const TMP_PATH = path.join(__dirname, '..', 'tmp') 9 | const OUT_PATH = path.join(TMP_PATH, 'git-pull-or-clone') 10 | const REPO_URL = 'https://github.com/feross/git-pull-or-clone.git' 11 | 12 | test('remove tmp folder', (t) => { 13 | rimraf.sync(TMP_PATH) 14 | t.end() 15 | }) 16 | 17 | test('git clone', (t) => { 18 | t.plan(1) 19 | gitPullOrClone(REPO_URL, OUT_PATH, (err) => { 20 | t.error(err) 21 | }) 22 | }) 23 | 24 | test('git pull', (t) => { 25 | t.plan(1) 26 | gitPullOrClone(REPO_URL, OUT_PATH, (err) => { 27 | t.error(err) 28 | }) 29 | }) 30 | 31 | test('git pull without depth limit', (t) => { 32 | t.plan(1) 33 | gitPullOrClone(REPO_URL, OUT_PATH, { depth: Infinity }, (err) => { 34 | t.error(err) 35 | }) 36 | }) 37 | 38 | test('git pull with invalid depth', (t) => { 39 | t.plan(1) 40 | t.throws( 41 | () => gitPullOrClone(REPO_URL, OUT_PATH, { depth: 0 }, noop), 42 | /The "depth" option must be greater than 0/ 43 | ) 44 | }) 45 | 46 | test('git clone shouldnt allow command injection, via attack vector one', (t) => { 47 | t.plan(2) 48 | 49 | // clean up the tmp folder from prior tests 50 | rimraf.sync(TMP_PATH) 51 | // clone a repo into the tmp folder 52 | gitPullOrClone(REPO_URL, OUT_PATH, (err) => { 53 | t.error(err) 54 | }) 55 | 56 | const OUT_TEST_FILE = '/tmp/pwn3' 57 | const REPO_LOCAL_PATH = `file://${OUT_PATH}` 58 | const OUT_PATH_INJECTION = `--upload-pack=touch ${OUT_TEST_FILE}` 59 | 60 | gitPullOrClone(REPO_LOCAL_PATH, OUT_PATH_INJECTION, () => { 61 | const exploitSucceeded = !!fs.existsSync(OUT_TEST_FILE) 62 | t.notOk(exploitSucceeded, `${OUT_TEST_FILE} should not exist, potential security vulnerability detected`) 63 | 64 | // cleanup the command injection test data 65 | if (exploitSucceeded) rimraf.sync(OUT_TEST_FILE) 66 | }) 67 | }) 68 | 69 | test('git clone shouldnt allow command injection, via attack vector two', (t) => { 70 | t.plan(2) 71 | 72 | // clean up the tmp folder from prior tests 73 | rimraf.sync(TMP_PATH) 74 | // clone a repo into the tmp folder 75 | gitPullOrClone(REPO_URL, OUT_PATH, (err) => { 76 | t.error(err) 77 | }) 78 | 79 | const OUT_TEST_FILE = '/tmp/pwn4' 80 | const OUT_PATH_INJECTION = `file://${OUT_PATH}` 81 | const REPO_LOCAL_PATH = `--upload-pack=touch ${OUT_TEST_FILE}` 82 | 83 | gitPullOrClone(REPO_LOCAL_PATH, OUT_PATH_INJECTION, () => { 84 | const exploitSucceeded = !!fs.existsSync(OUT_TEST_FILE) 85 | t.notOk(exploitSucceeded, `${OUT_TEST_FILE} should not exist, potential security vulnerability detected`) 86 | 87 | // cleanup the command injection test data 88 | if (exploitSucceeded) rimraf.sync(OUT_TEST_FILE) 89 | }) 90 | }) 91 | --------------------------------------------------------------------------------