Repository: FredrikNoren/ungit Branch: master Commit: 823076b5795c Files: 175 Total size: 664.9 KB Directory structure: gitextract_do455as5/ ├── .gitattributes ├── .github/ │ └── workflows/ │ ├── bump.yml │ └── ci.yml ├── .gitignore ├── .mochaclicktest.json ├── .mochatest.json ├── .npmignore ├── .prettierignore ├── .prettierrc ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── MERGETOOL.md ├── PLUGINS.md ├── README.md ├── appveyor.yml ├── bin/ │ ├── credentials-helper │ └── ungit ├── clicktests/ │ ├── environment.js │ ├── spec.authentication.js │ ├── spec.bare.js │ ├── spec.branches.js │ ├── spec.commands.js │ ├── spec.discard.js │ ├── spec.generic.js │ ├── spec.load-ahead.js │ ├── spec.no-header.js │ ├── spec.remotes.js │ ├── spec.screens.js │ ├── spec.stash.js │ └── spec.submodules.js ├── components/ │ ├── ComponentRoot.ts │ ├── app/ │ │ ├── app.html │ │ ├── app.js │ │ ├── app.less │ │ └── ungit-plugin.json │ ├── branches/ │ │ ├── branches.html │ │ ├── branches.js │ │ ├── branches.less │ │ └── ungit-plugin.json │ ├── commit/ │ │ ├── commit.html │ │ ├── commit.js │ │ ├── commit.less │ │ └── ungit-plugin.json │ ├── commitdiff/ │ │ ├── commitdiff.html │ │ ├── commitdiff.js │ │ ├── commitdiff.less │ │ ├── commitlinediff.js │ │ └── ungit-plugin.json │ ├── crash/ │ │ ├── crash.html │ │ ├── crash.js │ │ ├── crash.less │ │ └── ungit-plugin.json │ ├── gitErrors/ │ │ ├── gitErrors.html │ │ ├── gitErrors.js │ │ └── ungit-plugin.json │ ├── graph/ │ │ ├── animateable.js │ │ ├── edge.js │ │ ├── git-graph-actions.js │ │ ├── git-node.js │ │ ├── git-ref.js │ │ ├── graph-graphics.html │ │ ├── graph.html │ │ ├── graph.js │ │ ├── graph.less │ │ ├── hover-actions.js │ │ ├── selectable.js │ │ └── ungit-plugin.json │ ├── header/ │ │ ├── header.html │ │ ├── header.js │ │ ├── header.less │ │ └── ungit-plugin.json │ ├── home/ │ │ ├── home.html │ │ ├── home.js │ │ ├── home.less │ │ └── ungit-plugin.json │ ├── imagediff/ │ │ ├── imagediff.html │ │ ├── imagediff.js │ │ ├── imagediff.less │ │ └── ungit-plugin.json │ ├── login/ │ │ ├── login.html │ │ ├── login.js │ │ └── ungit-plugin.json │ ├── modals/ │ │ ├── formModal.html │ │ ├── forms.ts │ │ ├── modalBase.ts │ │ ├── modals.ts │ │ ├── promptModal.html │ │ ├── prompts.ts │ │ └── ungit-plugin.json │ ├── path/ │ │ ├── path.html │ │ ├── path.js │ │ ├── path.less │ │ └── ungit-plugin.json │ ├── refreshbutton/ │ │ ├── refreshbutton.html │ │ ├── refreshbutton.js │ │ ├── refreshbutton.less │ │ └── ungit-plugin.json │ ├── remotes/ │ │ ├── remotes.html │ │ ├── remotes.js │ │ └── ungit-plugin.json │ ├── repository/ │ │ ├── repository.html │ │ ├── repository.js │ │ ├── repository.less │ │ └── ungit-plugin.json │ ├── staging/ │ │ ├── staging.html │ │ ├── staging.js │ │ ├── staging.less │ │ └── ungit-plugin.json │ ├── stash/ │ │ ├── stash.html │ │ ├── stash.js │ │ ├── stash.less │ │ └── ungit-plugin.json │ ├── submodules/ │ │ ├── submodules.html │ │ ├── submodules.js │ │ └── ungit-plugin.json │ └── textdiff/ │ ├── textdiff.html │ ├── textdiff.js │ ├── textdiff.less │ └── ungit-plugin.json ├── eslint.config.mjs ├── package.json ├── public/ │ ├── images/ │ │ └── icon.icns │ ├── index.html │ ├── less/ │ │ ├── bootstrap.less │ │ ├── d2h.less │ │ ├── styles.less │ │ └── variables.less │ ├── main.js │ ├── source/ │ │ ├── bootstrap.js │ │ ├── components.js │ │ ├── jquery-ui.js │ │ ├── knockout-bindings.js │ │ ├── main.js │ │ ├── navigation.js │ │ ├── program-events.js │ │ ├── server.js │ │ └── storage.js │ └── vendor/ │ └── css/ │ └── animate.css ├── scripts/ │ ├── build.js │ ├── electronpackage.js │ ├── electronzip.js │ ├── npmpublish.js │ └── teststabilitytester.js ├── source/ │ ├── address-parser.js │ ├── bugtracker.js │ ├── config.js │ ├── git-api.js │ ├── git-parser.js │ ├── git-promise.js │ ├── server.js │ ├── sysinfo.js │ ├── ungit-plugin.js │ └── utils/ │ ├── cache.js │ ├── file-type.js │ └── logger.js ├── test/ │ ├── common-es6.js │ ├── spec.address-parser.js │ ├── spec.cache.js │ ├── spec.credentials-helper.js │ ├── spec.file-type.js │ ├── spec.git-api.branching.js │ ├── spec.git-api.conflict-no-auto-stash.js │ ├── spec.git-api.conflict.js │ ├── spec.git-api.diff.js │ ├── spec.git-api.discardchanges.js │ ├── spec.git-api.ignorefile.js │ ├── spec.git-api.js │ ├── spec.git-api.patch.js │ ├── spec.git-api.remote.js │ ├── spec.git-api.squash.js │ ├── spec.git-api.stash.js │ ├── spec.git-api.submodule.js │ └── spec.git-parser.js └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitattributes ================================================ * text=auto eol=lf /bin/credentials-helper eol=lf /bin/ungit eol=lf ================================================ FILE: .github/workflows/bump.yml ================================================ name: Bump Dependencies on: push: branches: - master schedule: - cron: '0 0 * * *' workflow_dispatch: jobs: bump: if: github.event.repository.fork == false runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Use Node.js uses: actions/setup-node@v6 with: node-version: '18' - run: npm ci - run: | body="$(npm run bumpdependencies)" body="${body#"${body%%[![:space:]]*}"}" body="${body%"${body##*[![:space:]]}"}" echo "$body" echo "body<> $GITHUB_OUTPUT echo "$body" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT id: bumpdependencies - run: npm install - name: Create Pull Request uses: peter-evans/create-pull-request@v8 with: token: ${{ secrets.GITHUB_TOKEN }} author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> commit-message: | Bump Dependencies ${{ steps.bumpdependencies.outputs.body }} title: Bump Dependencies body: | ``` ${{ steps.bumpdependencies.outputs.body }} ``` labels: dependencies branch: bumpdependencies ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: [push, pull_request] permissions: id-token: write # Required for npm OIDC provenance publishing contents: write jobs: test: if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name strategy: fail-fast: false matrix: node-version: ['20', '22', '*'] os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} # env: # electron-packager (win32 ia32 on macos) https://github.com/electron/electron-packager/pull/449#issuecomment-240508298 # WINEDLLOVERRIDES: 'mscoree,mshtml=' steps: - uses: actions/checkout@v6 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} cache: 'npm' registry-url: 'https://registry.npmjs.org' # linux dependencies # https://ubuntu.com/blog/ubuntu-23-10-restricted-unprivileged-user-namespaces - run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 if: matrix.os == 'ubuntu-latest' - run: sudo apt update && sudo apt install -y wine64 && sudo ln -sf /usr/bin/wine /usr/bin/wine64 if: matrix.os == 'ubuntu-latest' && matrix.node-version == '20' - run: wine64 --version if: matrix.os == 'ubuntu-latest' && matrix.node-version == '20' - run: sudo add-apt-repository ppa:git-core/ppa -y && sudo apt-get update -q && sudo apt-get install -y git if: matrix.os == 'ubuntu-latest' && matrix.node-version == '*' # macos dependencies # required for electron-packager # - run: brew update && brew cask install xquartz wine-stable # if: matrix.os == 'macos-latest' && matrix.node-version == '20' # - run: wine64 --version # if: matrix.os == 'macos-latest' && matrix.node-version == '20' - run: brew reinstall git if: matrix.os == 'macos-latest' && matrix.node-version == '*' # windows dependencies # https://github.community/t5/GitHub-Actions/TEMP-is-broken-on-Windows/td-p/30432 - run: echo "TEMP=$env:USERPROFILE\AppData\Local\Temp" >> $env:GITHUB_ENV if: matrix.os == 'windows-latest' - run: choco upgrade git if: matrix.os == 'windows-latest' && matrix.node-version == '*' - run: git --version - run: git config --global user.email "test@testy.com" - run: git config --global user.name "Test testy" - run: git config --global protocol.file.allow always # tests use file based submodules see #1539 - run: npm ci - run: npm run lint if: matrix.os == 'ubuntu-latest' && matrix.node-version == '20' - run: npm run build - run: npm test # publish artifacts - run: npm pack if: matrix.os == 'ubuntu-latest' && matrix.node-version == '20' - run: npm run electronpackage -- --all if: matrix.os == 'ubuntu-latest' && matrix.node-version == '20' - run: npm run electronzip if: matrix.os == 'ubuntu-latest' && matrix.node-version == '20' - name: Upload npm pack if: matrix.os == 'ubuntu-latest' && matrix.node-version == '20' uses: actions/upload-artifact@v7 with: name: ungit path: ungit-*.tgz archive: false retention-days: 7 - name: Upload ungit-darwin-arm64 if: matrix.os == 'ubuntu-latest' && matrix.node-version == '20' uses: actions/upload-artifact@v7 with: name: ungit-darwin-arm64 path: dist/ungit-darwin-arm64.zip archive: false retention-days: 7 - name: Upload ungit-darwin-x64 if: matrix.os == 'ubuntu-latest' && matrix.node-version == '20' uses: actions/upload-artifact@v7 with: name: ungit-darwin-x64 path: dist/ungit-darwin-x64.zip archive: false retention-days: 7 - name: Upload ungit-linux-arm64 if: matrix.os == 'ubuntu-latest' && matrix.node-version == '20' uses: actions/upload-artifact@v7 with: name: ungit-linux-arm64 path: dist/ungit-linux-arm64.zip archive: false retention-days: 7 - name: Upload ungit-linux-armv7l if: matrix.os == 'ubuntu-latest' && matrix.node-version == '20' uses: actions/upload-artifact@v7 with: name: ungit-linux-armv7l path: dist/ungit-linux-armv7l.zip archive: false retention-days: 7 - name: Upload ungit-linux-x64 if: matrix.os == 'ubuntu-latest' && matrix.node-version == '20' uses: actions/upload-artifact@v7 with: name: ungit-linux-x64 path: dist/ungit-linux-x64.zip archive: false retention-days: 7 - name: Upload ungit-win32-arm64 if: matrix.os == 'ubuntu-latest' && matrix.node-version == '20' uses: actions/upload-artifact@v7 with: name: ungit-win32-arm64 path: dist/ungit-win32-arm64.zip archive: false retention-days: 7 - name: Upload ungit-win32-ia32 if: matrix.os == 'ubuntu-latest' && matrix.node-version == '20' uses: actions/upload-artifact@v7 with: name: ungit-win32-ia32 path: dist/ungit-win32-ia32.zip archive: false retention-days: 7 - name: Upload ungit-win32-x64 if: matrix.os == 'ubuntu-latest' && matrix.node-version == '20' uses: actions/upload-artifact@v7 with: name: ungit-win32-x64 path: dist/ungit-win32-x64.zip archive: false retention-days: 7 - name: Upgrade npm for trusted publishing if: matrix.os == 'ubuntu-latest' && matrix.node-version == '20' && github.repository == 'FredrikNoren/ungit' && github.ref == 'refs/heads/master' run: npm install -g npm@latest - name: npm publish if: matrix.os == 'ubuntu-latest' && matrix.node-version == '20' && github.repository == 'FredrikNoren/ungit' && github.ref == 'refs/heads/master' uses: actions/github-script@v8 with: script: | const script = require('./scripts/npmpublish.js') await script({github, context, core, exec}) ================================================ FILE: .gitignore ================================================ # Ignore generated folders .nyc_output/ build/ coverage/ dist/ node_modules/ public/css/ public/fonts/glyphicons-* public/js/ # Ignore compiled files components/**/*.bundle.js components/**/*.bundle.js.map components/**/*.css components/**/*.css.map # Ignore potential files .vscode/ .ungitrc ungit-*.tgz ================================================ FILE: .mochaclicktest.json ================================================ { "spec": "clicktests/spec.*.js", "file": "./source/utils/logger.js", "timeout": 20000, "bail": true, "exit": true } ================================================ FILE: .mochatest.json ================================================ { "spec": "test/spec.*.js", "file": "./source/utils/logger.js", "timeout": 12000, "exit": true } ================================================ FILE: .npmignore ================================================ # Ignore whole folders .github/ .nyc_output/ assets/ build/ clicktests/ coverage/ dist/ scripts/ test/ # Ignore non-compiled sources components/**/*.less components/**/*.js !components/**/*.bundle.js public/images/icon.icns public/images/icon.ico public/js/raven.min.js.map public/less/ public/source/ public/vendor/ # Ignore dot-files .gitattributes .mochaclicktest.json .mochatest.json .prettierignore .prettierrc appveyor.yml eslint.config.mjs tsconfig.json # Ignore docs CONTRIBUTING.md gpg_save_screenshot.png MERGETOOL.md PLUGINS.md screenshot.png xkcd.png # Ignore potential files .vscode/ .ungitrc ungit-*.tgz ================================================ FILE: .prettierignore ================================================ # Generated folders .nyc_output/ build/ coverage/ # Third-party files public/css/ public/js public/vendor/ # Browserify bundles **/*.bundle.js # All css files are autogenerated **/*.css # We don't like the prettier Markdown formatting **/*.md # Don't interfere with npm package-lock.json package.json ================================================ FILE: .prettierrc ================================================ { "endOfLine": "lf", "trailingComma": "es5", "jsdocUseInlineCommentForASingleTagBlock": true, "plugins": ["@homer0/prettier-plugin-jsdoc"], "printWidth": 100, "singleQuote": true } ================================================ FILE: CHANGELOG.md ================================================ # Change Log All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/). We are following the [Keep a Changelog](https://keepachangelog.com/) format. ## [Unreleased](https://github.com/FredrikNoren/ungit/compare/v1.5.29...master) ## [1.5.29](https://github.com/FredrikNoren/ungit/compare/v1.5.28...v1.5.29) ### Changed - Update README.md [#1615](https://github.com/FredrikNoren/ungit/pull/1615) - Bump Dependencies [#1614](https://github.com/FredrikNoren/ungit/pull/1614), [#1645](https://github.com/FredrikNoren/ungit/pull/1645), [#1643](https://github.com/FredrikNoren/ungit/pull/1643), [#1641](https://github.com/FredrikNoren/ungit/pull/1641), [#1639](https://github.com/FredrikNoren/ungit/pull/1639), [#1636](https://github.com/FredrikNoren/ungit/pull/1636), [#1634](https://github.com/FredrikNoren/ungit/pull/1634), [#1632](https://github.com/FredrikNoren/ungit/pull/1632), [#1630](https://github.com/FredrikNoren/ungit/pull/1630), [#1629](https://github.com/FredrikNoren/ungit/pull/1629), [#1626](https://github.com/FredrikNoren/ungit/pull/1626), [#1624](https://github.com/FredrikNoren/ungit/pull/1624), [#1620](https://github.com/FredrikNoren/ungit/pull/1620), [#1619](https://github.com/FredrikNoren/ungit/pull/1619), [#1616](https://github.com/FredrikNoren/ungit/pull/1616), [#1592](https://github.com/FredrikNoren/ungit/pull/1592) - Migrate to eslint flat config [#1607](https://github.com/FredrikNoren/ungit/pull/1607) - Bump formidable from 3.5.2 to 3.5.4 [#1617](https://github.com/FredrikNoren/ungit/pull/1617) - show remote fetch / push url in title [#1618](https://github.com/FredrikNoren/ungit/pull/1618) - Bump qs from 6.14.1 to 6.14.2 [#1644](https://github.com/FredrikNoren/ungit/pull/1644) - Bump actions/upload-artifact to 7 [#1649](https://github.com/FredrikNoren/ungit/pull/1649) ## [1.5.28](https://github.com/FredrikNoren/ungit/compare/v1.5.27...v1.5.28) ### Fixes - Enable git fetch again [#1604](https://github.com/FredrikNoren/ungit/pull/1604) ### Changed - Bump Dependencies https://github.com/FredrikNoren/ungit/commit/dd9b7a35c92fafc3883a2ad7d04814049e04406a, https://github.com/FredrikNoren/ungit/commit/7e4e830cb71bdcbdd92d903419e1ad5e23a5ddc2, https://github.com/FredrikNoren/ungit/commit/944835dc8627c767c7145dad94b9fb4a2028e685, https://github.com/FredrikNoren/ungit/commit/ea063f47acf1f733b2a0af80d9c94293c3d880b7 - CI Fixes https://github.com/FredrikNoren/ungit/commit/c02db509c2c334e7541e8f3dea4f615c7a614ccb, https://github.com/FredrikNoren/ungit/commit/a32668649836d7fd382574615333807ef3eed370, https://github.com/FredrikNoren/ungit/commit/7349b941a58f95117801fa36029fd44769dee8f8, - Replace node 21 with 22 https://github.com/FredrikNoren/ungit/commit/571d03f5fd9f4dca073152b123f117b5fb9bde8b ## [1.5.27](https://github.com/FredrikNoren/ungit/compare/v1.5.26...v1.5.27) ### Added - Show parent commit ids and try to select them on click [#1581](https://github.com/FredrikNoren/ungit/issues/1581) ### Changed - Use monospace font-family in commit body [#1598](https://github.com/FredrikNoren/ungit/pull/1598) - Change watcher to properly filter ignored directories [#1597](https://github.com/FredrikNoren/ungit/pull/1597) ## [1.5.26](https://github.com/FredrikNoren/ungit/compare/v1.5.25...v1.5.26) ### Changed - Bump Dependencies [#1584](https://github.com/FredrikNoren/ungit/pull/1584), [#1585](https://github.com/FredrikNoren/ungit/pull/1585), [#1587](https://github.com/FredrikNoren/ungit/pull/1587), [#1586](https://github.com/FredrikNoren/ungit/pull/1586) ## [1.5.25](https://github.com/FredrikNoren/ungit/compare/v1.5.24...v1.5.25) ### Changed - Bump Dependencies [#1566](https://github.com/FredrikNoren/ungit/pull/1566), [#1571](https://github.com/FredrikNoren/ungit/pull/1571), [#1574](https://github.com/FredrikNoren/ungit/pull/1574), [#1578](https://github.com/FredrikNoren/ungit/pull/1578) ## [1.5.24](https://github.com/FredrikNoren/ungit/compare/v1.5.23...v1.5.24) ### Fixes - Fallback for font if bundled font dont support glyphs [#1564](https://github.com/FredrikNoren/ungit/pull/1564) ### Changed - Bump Dependencies [#1555](https://github.com/FredrikNoren/ungit/pull/1555), [#1559](https://github.com/FredrikNoren/ungit/pull/1559), [#1560](https://github.com/FredrikNoren/ungit/pull/1560), [#1561](https://github.com/FredrikNoren/ungit/pull/1561), [#1563](https://github.com/FredrikNoren/ungit/pull/1563) ## [1.5.23](https://github.com/FredrikNoren/ungit/compare/v1.5.22...v1.5.23) ### Fixes - Ungit returns 0 when wrong arguments are used [#1548](https://github.com/FredrikNoren/ungit/issues/1548) - Server process keeps running when parent gets killed [#1552](https://github.com/FredrikNoren/ungit/issues/1552) ### Changed - Bump Dependencies [#1542](https://github.com/FredrikNoren/ungit/pull/1542), [#1545](https://github.com/FredrikNoren/ungit/pull/1545), [#1551](https://github.com/FredrikNoren/ungit/pull/1551) ## [1.5.22](https://github.com/FredrikNoren/ungit/compare/v1.5.21...v1.5.22) ### Fixes - build: fix memory use by building serially [#1529](https://github.com/FredrikNoren/ungit/pull/1529) - Small fixes and cleanups [#1530](https://github.com/FredrikNoren/ungit/pull/1530) ### Changed - Update README.md [#1526](https://github.com/FredrikNoren/ungit/pull/1526) - Update git version dependency to 2.34.x [#1536](https://github.com/FredrikNoren/ungit/pull/1536) - Use fork to spawn a new node process [#1537](https://github.com/FredrikNoren/ungit/pull/1537) - CI: git allow file protocol which is used in submodule tests [#1540](https://github.com/FredrikNoren/ungit/pull/1540) - Bump Dependencies [#1525](https://github.com/FredrikNoren/ungit/pull/1525), [#1531](https://github.com/FredrikNoren/ungit/pull/1531), [#1533](https://github.com/FredrikNoren/ungit/pull/1533) ## [1.5.21](https://github.com/FredrikNoren/ungit/compare/v1.5.20...v1.5.21) ### Fixes - fix patch checkbox html [#1517](https://github.com/FredrikNoren/ungit/pull/1517) ### Changed - Bump Dependencies [#1512](https://github.com/FredrikNoren/ungit/pull/1512), [#1518](https://github.com/FredrikNoren/ungit/pull/1518) ### Removed - Remove node 12 from build matrix [#1516](https://github.com/FredrikNoren/ungit/pull/1516) ## [1.5.20](https://github.com/FredrikNoren/ungit/compare/v1.5.19...v1.5.20) ### Fixes - Fix potential remote code exec [#1510](https://github.com/FredrikNoren/ungit/pull/1510) - Fix intermittent test failures [#1495](https://github.com/FredrikNoren/ungit/issues/1495) - lint: small bugs + jsdoc [#1504](https://github.com/FredrikNoren/ungit/pull/1504) ### Changed - Bump Dependencies [#1503](https://github.com/FredrikNoren/ungit/pull/1503) ## [1.5.19](https://github.com/FredrikNoren/ungit/compare/v1.5.18...v1.5.19) ### Added - Add Tab Size Configuration [#1499](https://github.com/FredrikNoren/ungit/pull/1499) - Types: add support for VSCode IntelliSense [#1466](https://github.com/FredrikNoren/ungit/pull/1466) ### Changed - Directory view [#1491](https://github.com/FredrikNoren/ungit/pull/1491) - Node watch [#1465](https://github.com/FredrikNoren/ungit/pull/1465) - Bump cached-path-relative from 1.0.2 to 1.1.0 [#1501](https://github.com/FredrikNoren/ungit/pull/1501) - Bump Dependencies [#1483](https://github.com/FredrikNoren/ungit/pull/1483), [#1487](https://github.com/FredrikNoren/ungit/pull/1487), [#1492](https://github.com/FredrikNoren/ungit/pull/1492), [#1494](https://github.com/FredrikNoren/ungit/pull/1494) ## [1.5.18](https://github.com/FredrikNoren/ungit/compare/v1.5.17...v1.5.18) ### Fixes - simple git flow breaks ungit [#1460](https://github.com/FredrikNoren/ungit/issues/1460) ### Changed - Bump Dependencies [#1479](https://github.com/FredrikNoren/ungit/pull/1479) ## [1.5.17](https://github.com/FredrikNoren/ungit/compare/v1.5.16...v1.5.17) ### Changed - node 16 [#1476](https://github.com/FredrikNoren/ungit/pull/1476) - Bump Dependencies [#1475](https://github.com/FredrikNoren/ungit/pull/1475) ## [1.5.16](https://github.com/FredrikNoren/ungit/compare/v1.5.15...v1.5.16) ### Added - Add clipboard button on commit [#1462](https://github.com/FredrikNoren/ungit/pull/1462) - Encode URI paths with slashes [#1378](https://github.com/FredrikNoren/ungit/pull/1378) ### Changed - Bump Dependencies [#1456](https://github.com/FredrikNoren/ungit/pull/1456), [#1464](https://github.com/FredrikNoren/ungit/pull/1464) - Bump elliptic from 6.5.3 to 6.5.4 [#1468](https://github.com/FredrikNoren/ungit/pull/1468) - Bump y18n from 4.0.0 to 4.0.1 [#1471](https://github.com/FredrikNoren/ungit/pull/1471) - git 2.3x changes break unittests [#1472](https://github.com/FredrikNoren/ungit/issues/1472) ## [1.5.15](https://github.com/FredrikNoren/ungit/compare/v1.5.14...v1.5.15) ### Changed - Bump Dependencies [#1451](https://github.com/FredrikNoren/ungit/pull/1451) ## [1.5.14](https://github.com/FredrikNoren/ungit/compare/v1.5.13...v1.5.14) ### Changed - Update socket.io to version 3.0.0 [#1443](https://github.com/FredrikNoren/ungit/pull/1443) - Bump Dependencies [#1442](https://github.com/FredrikNoren/ungit/pull/1442), [#1444](https://github.com/FredrikNoren/ungit/pull/1444), [#1448](https://github.com/FredrikNoren/ungit/pull/1448), [#1449](https://github.com/FredrikNoren/ungit/pull/1449) ## [1.5.13](https://github.com/FredrikNoren/ungit/compare/v1.5.12...v1.5.13) ### Fixed - Unhandled rejection ERR_FEATURE_NOT_AVAILABLE_ON_PLATFORM (recursive watch) [#1389](https://github.com/FredrikNoren/ungit/issues/1389) ### Changed - Bump Dependencies [#1438](https://github.com/FredrikNoren/ungit/pull/1438) ## [1.5.12](https://github.com/FredrikNoren/ungit/compare/v1.5.11...v1.5.12) ### Fixed - branches - can't re-enable disabled groups [#1434](https://github.com/FredrikNoren/ungit/issues/1434) - Support git 2.29 sha256 [#1436](https://github.com/FredrikNoren/ungit/pull/1436) ### Changed - Bump Dependencies [#1427](https://github.com/FredrikNoren/ungit/pull/1427) ## [1.5.11](https://github.com/FredrikNoren/ungit/compare/v1.5.10...v1.5.11) ### Added - Doubleclick to checkout [#190](https://github.com/FredrikNoren/ungit/issues/190) ### Changed - Use page.waitForTimeout API in tests [#1422](https://github.com/FredrikNoren/ungit/pull/1422) - Bump Dependencies [#1417](https://github.com/FredrikNoren/ungit/pull/1417), [#1423](https://github.com/FredrikNoren/ungit/pull/1423) ## [1.5.10](https://github.com/FredrikNoren/ungit/compare/v1.5.9...v1.5.10) ### Fixed - Add copyright to electron executable [#1411](https://github.com/FredrikNoren/ungit/issues/1411) ### Changed - Generate and extract source maps [#1394](https://github.com/FredrikNoren/ungit/pull/1394) - Import Bootstrap from npm and upgrade to latest 3.x [#1395](https://github.com/FredrikNoren/ungit/pull/1395) - Bump Dependencies upgrading from electron 9.x to 10.x [#1392](https://github.com/FredrikNoren/ungit/pull/1392), [#1406](https://github.com/FredrikNoren/ungit/pull/1406) ## [1.5.9](https://github.com/FredrikNoren/ungit/compare/v1.5.8...v1.5.9) ### Fixed - Fix git ignore settings [#1393](https://github.com/FredrikNoren/ungit/pull/1393) ## [1.5.8](https://github.com/FredrikNoren/ungit/compare/v1.5.7...v1.5.8) ### Fixed - Clear git-promise timeout when git command was successful [#1357](https://github.com/FredrikNoren/ungit/pull/1357) - When autoFetch=false don't make remote repo calls automatically [#1381](https://github.com/FredrikNoren/ungit/pull/1381) - Prevent commit message `, closeFunc ); this.promptOptions.push(new PromptOptions('Save', true, 'save', this.closeYes.bind(this))); this.promptOptions.push(new PromptOptions('Cancel', false, 'cancel', this.closeNo.bind(this))); } } ================================================ FILE: components/modals/ungit-plugin.json ================================================ { "exports": { "knockoutTemplates": { "formModal": "formModal.html", "promptModal": "promptModal.html" }, "javascript": "modals.bundle.js" } } ================================================ FILE: components/path/path.html ================================================
Directory "" created.

"" is not a git repository

There is no git repository at the selected path.

Create a new git repository in ""
Clone a git repository into a subfolder of ""

Invalid path

"" doesn't seem to be a valid path.

================================================ FILE: components/path/path.js ================================================ const ko = require('knockout'); const octicons = require('octicons'); const components = require('ungit-components'); const addressParser = require('ungit-address-parser'); const navigation = require('ungit-navigation'); const programEvents = require('ungit-program-events'); const { encodePath } = require('ungit-address-parser'); const storage = require('ungit-storage'); const { ComponentRoot } = require('../ComponentRoot'); const showCreateRepoKey = 'isShowCreateRepo'; components.register('path', (args) => { return new PathViewModel(args.server, args.path); }); class SubRepositoryViewModel { constructor(server, path) { this.path = path; this.title = path; this.link = `${ungit.config.rootPath}/#/repository?path=${encodePath(path)}`; this.arrowIcon = octicons['arrow-right'].toSVG({ height: 24 }); this.remote = ko.observable('...'); server .getPromise(`/remotes/origin?path=${encodePath(this.path)}`) .then((remote) => { this.remote(remote.address.replace(/\/\/.*?@/, '//***@')); }) .catch(() => { this.remote(''); }); } } class PathViewModel extends ComponentRoot { constructor(server, path) { super(); this.server = server; this.repoPath = ko.observable(path); this.dirName = this.repoPath() .replace(/\\/g, '/') .split('/') .filter((s) => s) .slice(-1)[0] || '/'; this.status = ko.observable('loading'); this.cloneUrl = ko.observable(); this.showDirectoryCreatedAlert = ko.observable(false); this.subRepos = ko.observableArray(); this.cloneDestinationImplicit = ko.computed(() => { const defaultText = 'destination folder'; if (!this.cloneUrl()) return defaultText; const parsedAddress = addressParser.parseAddress(this.cloneUrl()); return parsedAddress.shortProject || defaultText; }); this.cloneDestination = ko.observable(); this.repository = ko.observable(); this.expandIcon = ko.observable(); this.isRecursiveSubmodule = ko.observable(true); this.showCreateRepoKey = `${showCreateRepoKey}-${this.repoPath()}`; const storageValue = storage.getItem(this.showCreateRepoKey); this.isShowCreateRepo = ko.observable(storageValue && storageValue === 'false' ? false : true); this.updateShowCreateRepoMetadata(); } toggleShowCreateRepo() { this.isShowCreateRepo(!this.isShowCreateRepo()); storage.setItem(this.showCreateRepoKey, this.isShowCreateRepo() ? 'true' : 'false'); this.updateShowCreateRepoMetadata(); } updateShowCreateRepoMetadata() { if (this.isShowCreateRepo()) { this.expandIcon(octicons['chevron-right'].toSVG({ height: 28 })); } else { this.expandIcon(octicons['chevron-down'].toSVG({ height: 35 })); } } updateNode(parentElement) { ko.renderTemplate('path', this, {}, parentElement); } shown() { this.updateStatus(); } updateAnimationFrame(deltaT) { if (this.repository()) this.repository().updateAnimationFrame(deltaT); } async updateStatus() { ungit.logger.debug('path.updateStatus() triggered'); const status = await this.server.getPromise('/quickstatus', { path: this.repoPath() }); try { if (this.isSamePayload(status)) { return; } if (status.type == 'inited' || status.type == 'bare') { if (this.repoPath() !== status.gitRootPath) { this.repoPath(status.gitRootPath); programEvents.dispatch({ event: 'navigated-to-path', path: this.repoPath() }); programEvents.dispatch({ event: 'working-tree-changed' }); } this.status(status.type); if (!this.repository()) { this.repository(components.create('repository', { server: this.server, path: this })); } } else if (status.type == 'uninited' || status.type == 'no-such-path') { if (status.subRepos && status.subRepos.length > 0) { this.subRepos( status.subRepos.map((subRepo) => new SubRepositoryViewModel(this.server, subRepo)) ); } this.status(status.type); this.repository(null); } } catch (err) { ungit.logger.debug('path.updateStatus() errored', err); } finally { ungit.logger.debug('path.updateStatus() finished'); } } initRepository() { return this.server .postPromise('/init', { path: this.repoPath() }) .catch((e) => this.server.unhandledRejection(e)) .finally(() => this.updateStatus()); } async onProgramEvent(event) { const promises = []; if (event.event == 'working-tree-changed' || event.event == 'request-app-content-refresh') { promises.push(this.updateStatus()); } if (this.repository()) { promises.push(this.repository().onProgramEvent(event)); } await Promise.all(promises); } cloneRepository() { this.status('cloning'); const dest = this.cloneDestination() || this.cloneDestinationImplicit(); return this.server .postPromise('/clone', { path: this.repoPath(), url: this.cloneUrl(), destinationDir: dest, isRecursiveSubmodule: this.isRecursiveSubmodule(), }) .then((res) => navigation.browseTo('repository?path=' + addressParser.encodePath(res.path))) .catch((e) => this.server.unhandledRejection(e)) .finally(() => { programEvents.dispatch({ event: 'working-tree-changed' }); }); } createDir() { this.showDirectoryCreatedAlert(true); return this.server .postPromise('/createDir', { dir: this.repoPath() }) .catch((e) => this.server.unhandledRejection(e)) .then(() => this.updateStatus()); } } ================================================ FILE: components/path/path.less ================================================ .create-dir { margin-top: 50px; } .create-repo-container { background-color: #643a44; box-shadow: 0 -1px 15px #252833; padding: 5px 10px 5px 10px; } .create-repo-toggle { float: right; margin-top: -75px; margin-right: 10px; } ================================================ FILE: components/path/ungit-plugin.json ================================================ { "exports": { "knockoutTemplates": { "path": "path.html" }, "javascript": "path.bundle.js", "css": "path.css" } } ================================================ FILE: components/refreshbutton/refreshbutton.html ================================================ ================================================ FILE: components/refreshbutton/refreshbutton.js ================================================ const ko = require('knockout'); const octicons = require('octicons'); const components = require('ungit-components'); const programEvents = require('ungit-program-events'); components.register('refreshbutton', (args) => new RefreshButton(args.isLarge)); class RefreshButton { constructor(isLarge) { this.isLarge = isLarge; this.refreshIcon = octicons.sync.toSVG({ height: isLarge ? 26 : 18 }); } refresh() { programEvents.dispatch({ event: 'request-app-content-refresh' }); return true; } updateNode(parentElement) { ko.renderTemplate('refreshbutton', this, {}, parentElement); } } ================================================ FILE: components/refreshbutton/refreshbutton.less ================================================ .toolbar { .refresh-button { background: rgba(0, 0, 0, 0.1); border: 0; } } ================================================ FILE: components/refreshbutton/ungit-plugin.json ================================================ { "exports": { "knockoutTemplates": { "refreshbutton": "refreshbutton.html" }, "javascript": "refreshbutton.bundle.js", "css": "refreshbutton.css" } } ================================================ FILE: components/remotes/remotes.html ================================================
================================================ FILE: components/remotes/remotes.js ================================================ const ko = require('knockout'); const _ = require('lodash'); const octicons = require('octicons'); const components = require('ungit-components'); const programEvents = require('ungit-program-events'); components.register('remotes', (args) => new RemotesViewModel(args.server, args.repoPath)); class RemotesViewModel { constructor(server, repoPath) { this.repoPath = repoPath; this.server = server; this.remotes = ko.observable([]); this.currentRemote = ko.observable(null); this.currentRemote.subscribe((value) => { programEvents.dispatch({ event: 'current-remote-changed', newRemote: value }); }); this.fetchLabel = ko.computed(() => { if (this.currentRemote()) return `Fetch from ${this.currentRemote()}`; else return 'No remotes specified'; }); this.remotesIcon = octicons.download.toSVG({ height: 18 }); this.closeIcon = octicons.x.toSVG({ height: 18 }); this.fetchEnabled = ko.computed(() => this.remotes().length > 0); this.shouldAutoFetch = ungit.config.autoFetch; this.updateRemotes(); } updateNode(parentElement) { ko.renderTemplate('remotes', this, {}, parentElement); } clickFetch() { this.fetch({ nodes: true, tags: true }); } async onProgramEvent(event) { if (event.event === 'request-app-content-refresh' || event.event === 'request-fetch-tags') { await this.fetch({ tags: true }); } else if (event.event === 'git-directory-changed' && this.shouldAutoFetch) { await this.fetch({ tags: true }); } else if (event.event === 'update-remote') { await this.updateRemotes(); } } async fetch(options) { if (!this.currentRemote()) return; ungit.logger.debug('remotes.fetch() triggered'); try { const tagPromise = options.tags ? this.server.getPromise('/remote/tags', { path: this.repoPath(), remote: this.currentRemote(), }) : null; const fetchPromise = options.nodes ? this.server.getPromise('/fetch', { path: this.repoPath(), remote: this.currentRemote() }) : null; if (tagPromise) { programEvents.dispatch({ event: 'remote-tags-update', tags: await tagPromise }); } if (fetchPromise) { await fetchPromise; } if (!this.server.isInternetConnected) { this.server.isInternetConnected = true; } } catch (err) { let errorMessage; let stdout; let stderr; try { errorMessage = `Ungit has failed to fetch a remote. ${err.res.body.error}`; stdout = err.res.body.stdout; stderr = err.res.body.stderr; } catch { errorMessage = ''; } if (errorMessage.includes('Could not resolve host')) { if (this.server.isInternetConnected) { this.server.isInternetConnected = false; stdout = ''; stderr = ''; } else { // Message is already seen, just return return; } } programEvents.dispatch({ event: 'git-error', data: { isWarning: true, command: err.res.body.command, error: err.res.body.error, stdout, stderr, repoPath: err.res.body.workingDirectory, }, }); } finally { ungit.logger.debug('remotes.fetch() finished'); } } updateRemotes() { return this.server .getPromise('/remotes', { path: this.repoPath() }) .then((remotes) => { remotes = remotes.map((remote) => ({ name: remote.name, title: remote.fetchUrl == remote.pushUrl ? `Fetch/Push ${remote.fetchUrl || remote.pushUrl || remote.url}` : `Fetch ${remote.fetchUrl || remote.url}\nPush ${remote.pushUrl || remote.url}`, changeRemote: () => { this.currentRemote(remote.name); }, })); this.remotes(remotes); if (!this.currentRemote() && remotes.length > 0) { if (_.find(remotes, { name: 'origin' })) { // default to origin if it exists this.currentRemote('origin'); } else { // otherwise take the first one this.currentRemote(remotes[0].name); } if (this.shouldAutoFetch) { this.shouldAutoFetch = false; return this.fetch({ nodes: true, tags: true }); } } }) .catch((err) => { if (err.errorCode != 'not-a-repository') { this.server.unhandledRejection(err); } else { ungit.logger.warn('updateRemotes failed', err); } }); } showAddRemoteDialog() { components.showModal('addremotemodal', { path: this.repoPath() }); } remoteRemove(remote) { components.showModal('yesnomodal', { title: 'Are you sure?', details: `Deleting ${remote.name} remote cannot be undone with ungit.`, closeFunc: (isYes) => { if (isYes) { this.server .delPromise(`/remotes/${remote.name}`, { path: this.repoPath() }) .then(() => { this.updateRemotes(); }) .catch((e) => this.server.unhandledRejection(e)); } }, }); } } ================================================ FILE: components/remotes/ungit-plugin.json ================================================ { "exports": { "knockoutTemplates": { "remotes": "remotes.html" }, "javascript": "remotes.bundle.js" } } ================================================ FILE: components/repository/repository.html ================================================

This is a submodule

Base repository:

in progress resolve conflicts to continue

================================================ FILE: components/repository/repository.js ================================================ const ko = require('knockout'); const octicons = require('octicons'); const components = require('ungit-components'); const programEvents = require('ungit-program-events'); const { encodePath } = require('ungit-address-parser'); components.register('repository', (args) => new RepositoryViewModel(args.server, args.path)); class RepositoryViewModel { constructor(server, path) { this.server = server; this.isBareDir = path.status() === 'bare'; this.repoPath = path.repoPath; this.gitErrors = components.create('gitErrors', { server, repoPath: this.repoPath }); this.graph = components.create('graph', { server, repoPath: this.repoPath }); this.remotes = components.create('remotes', { server, repoPath: this.repoPath }); this.submodules = components.create('submodules', { server, repoPath: this.repoPath }); this.stash = this.isBareDir ? {} : components.create('stash', { server, repoPath: this.repoPath }); this.staging = this.isBareDir ? {} : components.create('staging', { server, repoPath: this.repoPath, graph: this.graph }); this.branches = components.create('branches', { server, graph: this.graph, repoPath: this.repoPath, }); this.repoPath.subscribe((value) => { this.server.watchRepository(value); }); this.server.watchRepository(this.repoPath()); this.showLog = this.isBareDir ? ko.observable(true) : this.staging.isStageValid; this.parentModulePath = ko.observable(); this.parentModuleLink = ko.observable(); this.isSubmodule = ko.computed(() => this.parentModulePath() && this.parentModuleLink()); this.refreshSubmoduleStatus(); if (window.location.search.includes('noheader=true')) { this.refreshButton = components.create('refreshbutton', { isLarge: false }); } else { this.refreshButton = false; } this.ignoreIcon = octicons.file.toSVG({ height: 18 }); } updateNode(parentElement) { ko.renderTemplate('repository', this, {}, parentElement); } onProgramEvent(event) { if (this.gitErrors.onProgramEvent) this.gitErrors.onProgramEvent(event); if (this.graph.onProgramEvent) this.graph.onProgramEvent(event); if (this.staging.onProgramEvent) this.staging.onProgramEvent(event); if (this.stash.onProgramEvent) this.stash.onProgramEvent(event); if (this.remotes.onProgramEvent) this.remotes.onProgramEvent(event); if (this.submodules.onProgramEvent) this.submodules.onProgramEvent(event); if (this.branches.onProgramEvent) this.branches.onProgramEvent(event); if (event.event == 'connected') this.server.watchRepository(this.repoPath()); // If we get a reconnect event it's usually because the server crashed and then restarted // or something like that, so we need to tell it to start watching the path again } updateAnimationFrame(deltaT) { if (this.graph.updateAnimationFrame) this.graph.updateAnimationFrame(deltaT); } refreshSubmoduleStatus() { return this.server .getPromise('/baserepopath', { path: this.repoPath() }) .then((baseRepoPath) => { if (baseRepoPath.path) { return this.server .getPromise('/submodules', { path: baseRepoPath.path }) .then((submodules) => { const baseName = this.repoPath().substring(baseRepoPath.path.length + 1); for (let n = 0; n < submodules.length; n++) { if (submodules[n].path === baseName) { this.parentModulePath(baseRepoPath.path); this.parentModuleLink(`/#/repository?path=${encodePath(baseRepoPath.path)}`); return; } } }); } }) .catch(() => { this.parentModuleLink(undefined); this.parentModulePath(undefined); }); } editGitignore() { return this.server .getPromise('/gitignore', { path: this.repoPath() }) .then((res) => { return components.showModal('texteditmodal', { title: `${this.repoPath()}${ungit.config.fileSeparator}.gitignore`, content: res.content, closeFunc: (isYes) => { if (isYes) { this.server.putPromise('/gitignore', { path: this.repoPath(), data: document.querySelector('.modal-body .text-area-content').value, }); } }, }); }) .catch((e) => { // Not a git error but we are going to treat like one programEvents.dispatch({ event: 'git-error', data: { command: `fs.write "${this.repoPath()}${ungit.config.fileSeparator}.gitignore"`, error: e.message || e.errorSummary, stdout: '', stderr: e.stack, repoPath: this.repoPath(), }, }); }); } } ================================================ FILE: components/repository/repository.less ================================================ .repository-view { position: relative; height: auto; margin-bottom: 1px; padding-bottom: 1px; .repository-actions { position: absolute; margin-top: 20px; right: 0; z-index: 30; } } ================================================ FILE: components/repository/ungit-plugin.json ================================================ { "exports": { "knockoutTemplates": { "repository": "repository.html" }, "javascript": "repository.bundle.js", "css": "repository.css" } } ================================================ FILE: components/staging/staging.html ================================================
New Removed Modified ConflictsLaunch Merge ToolMark as Resolved
================================================ FILE: components/staging/staging.js ================================================ const ko = require('knockout'); const _ = require('lodash'); const octicons = require('octicons'); const components = require('ungit-components'); const programEvents = require('ungit-program-events'); const filesToDisplayIncrmentBy = 50; const filesToDisplayLimit = filesToDisplayIncrmentBy; const mergeTool = ungit.config.mergeTool; const { ComponentRoot } = require('../ComponentRoot'); components.register( 'staging', (args) => new StagingViewModel(args.server, args.repoPath, args.graph) ); class StagingViewModel extends ComponentRoot { constructor(server, repoPath, graph) { super(); this.server = server; this.repoPath = repoPath; this.refreshContent = _.debounce(this._refreshContent, 250, this.defaultDebounceOption); this.graph = graph; this.filesByPath = {}; this.files = ko.observableArray(); this.commitMessageTitleCount = ko.observable(0); this.commitMessageTitle = ko.observable(); this.commitMessageTitle.subscribe((value) => { this.commitMessageTitleCount(value.length); }); this.commitMessageBody = ko.observable(); this.wordWrap = components.create('textdiff.wordwrap'); this.textDiffType = components.create('textdiff.type'); this.whiteSpace = components.create('textdiff.whitespace'); this.inRebase = ko.observable(false); this.inMerge = ko.observable(false); this.inCherry = ko.observable(false); this.conflictText = ko.computed(() => { if (this.inMerge()) { this.conflictContinue = this.conflictResolution.bind(this, '/merge/continue'); this.conflictAbort = this.conflictResolution.bind(this, '/merge/abort'); return 'Merge'; } else if (this.inRebase()) { this.conflictContinue = this.conflictResolution.bind(this, '/rebase/continue'); this.conflictAbort = this.conflictResolution.bind(this, '/rebase/abort'); return 'Rebase'; } else if (this.inCherry()) { this.conflictContinue = this.commit; this.conflictAbort = this.discardAllChanges; return 'Cherry-pick'; } else { this.conflictContinue = undefined; this.conflictAbort = undefined; return undefined; } }); this.HEAD = ko.observable(); this.isStageValid = ko.computed(() => !this.inRebase() && !this.inMerge() && !this.inCherry()); this.nFiles = ko.computed(() => this.files().length); this.nStagedFiles = ko.computed( () => this.files().filter((f) => f.editState() === 'staged').length ); this.allStageFlag = ko.computed(() => this.nFiles() !== this.nStagedFiles()); this.stats = ko.computed(() => `${this.nFiles()} files, ${this.nStagedFiles()} to be commited`); this.amend = ko.observable(false); this.canAmend = ko.computed( () => this.HEAD() && !this.inRebase() && !this.inMerge() && !this.emptyCommit() ); this.emptyCommit = ko.observable(false); this.canEmptyCommit = ko.computed(() => this.HEAD() && !this.inRebase() && !this.inMerge()); this.canStashAll = ko.computed(() => !this.amend()); this.canPush = ko.computed(() => !!this.graph.currentRemote()); this.showNux = ko.computed( () => this.files().length == 0 && !this.amend() && !this.inRebase() && !this.emptyCommit() ); this.showCancelButton = ko.computed(() => this.amend() || this.emptyCommit()); this.commitValidationError = ko.computed(() => { if (this.conflictText()) { if (this.files().some((file) => file.conflict())) return 'Files in conflict'; } else { if ( !this.emptyCommit() && !this.amend() && !this.files().some( (file) => file.editState() === 'staged' || file.editState() === 'patched' ) ) { return 'No files to commit'; } if (!this.commitMessageTitle()) { return 'Provide a title'; } if (this.textDiffType.value() === 'sidebysidediff') { const patchFiles = this.files().filter((file) => file.editState() === 'patched'); if (patchFiles.length > 0) return 'Cannot patch with side by side view.'; } } return ''; }); this.toggleSelectAllGlyphClass = ko.computed(() => { if (this.allStageFlag()) return 'glyphicon-unchecked'; else return 'glyphicon-check'; }); this.refreshContentThrottled = _.throttle(this.refreshContent.bind(this), 500, { leading: false, trailing: true, }); this.invalidateFilesDiffsThrottled = _.throttle(this.invalidateFilesDiffs.bind(this), 500, { leading: false, trailing: true, }); this.refreshContentThrottled(); this.loadAnyway = false; this.isDiagOpen = false; this.mutedTime = null; this.discardAllIcon = octicons.trash.toSVG({ height: 15 }); this.stashIcon = octicons.pin.toSVG({ height: 15 }); this.discardIcon = octicons.x.toSVG({ height: 18 }); this.ignoreIcon = octicons.skip.toSVG({ height: 18 }); } updateNode(parentElement) { ko.renderTemplate('staging', this, {}, parentElement); } onProgramEvent(event) { if ( event.event == 'request-app-content-refresh' || event.event === 'working-tree-changed' || event.event === 'git-directory-changed' ) { this.refreshContent(); this.invalidateFilesDiffs(); } } async _refreshContent() { ungit.logger.debug('staging.refreshContent() triggered'); try { const headPromise = this.server.getPromise('/head', { path: this.repoPath(), limit: 1 }); const statusPromise = this.server.getPromise('/status', { path: this.repoPath(), fileLimit: filesToDisplayLimit, }); const log = await headPromise; if (log.length > 0) { const array = log[0].message.split('\n'); this.HEAD({ title: array[0], body: array.slice(2).join('\n') }); } else { this.HEAD(null); } const status = await statusPromise; if (this.isSamePayload(status)) { return; } if (Object.keys(status.files).length > filesToDisplayLimit && !this.loadAnyway) { if (this.isDiagOpen) { return; } this.isDiagOpen = true; components.showModal('toomanyfilesmodal', { title: 'Too many unstaged files', details: 'It is recommended to use command line as ungit may be too slow.', closeFunc: (isYes) => { this.isDiagOpen = false; if (isYes) { window.location.href = '/#/'; } else { this.loadAnyway = true; this.loadStatus(status); } }, }); } else { this.loadStatus(status); } } catch (err) { if (err.errorCode != 'must-be-in-working-tree' && err.errorCode != 'no-such-path') { this.server.unhandledRejection(err); } else { ungit.logger.error('error during staging refresh: ', err); } } finally { ungit.logger.debug('staging.refreshContent() finished'); } } loadStatus(status) { this.setFiles(status.files); this.inRebase(!!status.inRebase); this.inMerge(!!status.inMerge); // There are time where '.git/CHERRY_PICK_HEAD' file is created and no files are in conflicts. // in such cases we should ignore exception as no good way to resolve it. this.inCherry(!!status.inCherry && !!status.inConflict); if (this.inRebase()) { this.commitMessageTitle('Rebase conflict'); this.commitMessageBody('Commit messages are not applicable!\n(╯°□°)╯︵ ┻━┻'); } else if (this.inMerge() || this.inCherry()) { const lines = status.commitMessage.split('\n'); if (!this.commitMessageTitle()) { this.commitMessageTitle(lines[0]); this.commitMessageBody(lines.slice(1).join('\n')); } } } setFiles(files) { const newFiles = []; for (const fileStatus of Object.values(files)) { let fileViewModel = this.filesByPath[fileStatus.fileName]; if (!fileViewModel) { this.filesByPath[fileStatus.fileName] = fileViewModel = new FileViewModel( this, fileStatus.fileName, fileStatus.oldFileName, fileStatus.displayName ); } else { // this is mainly for patching and it may not fire due to the fact that // '/commit' triggers working-tree-changed which triggers throttled refresh fileViewModel.diff().invalidateDiff(); } fileViewModel.setState(fileStatus); newFiles.push(fileViewModel); } this.files(newFiles); } toggleAmend() { if (!this.amend() && !this.commitMessageTitle()) { this.commitMessageTitle(this.HEAD().title); this.commitMessageBody(this.HEAD().body); } else if (this.amend()) { const isPrevDefaultMsg = this.commitMessageTitle() == this.HEAD().title && this.commitMessageBody() == this.HEAD().body; if (isPrevDefaultMsg) { this.commitMessageTitle(''); this.commitMessageBody(''); } } this.amend(!this.amend()); } toggleEmptyCommit() { this.commitMessageTitle('Empty commit'); this.commitMessageBody(); this.emptyCommit(true); } resetMessages() { this.commitMessageTitle(''); this.commitMessageBody(''); for (const key in this.filesByPath) { const element = this.filesByPath[key]; element.diff().invalidateDiff(); element.patchLineList.removeAll(); element.isShowingDiffs(false); element.editState(element.editState() === 'patched' ? 'none' : element.editState()); } this.amend(false); this.emptyCommit(false); } commit() { const files = this.files() .filter((file) => file.editState() !== 'none') .map((file) => ({ name: file.name(), patchLineList: file.editState() === 'patched' ? file.patchLineList() : null, })); let commitMessage = this.commitMessageTitle(); if (this.commitMessageBody()) commitMessage += `\n\n${this.commitMessageBody()}`; this.server .postPromise('/commit', { path: this.repoPath(), message: commitMessage, files, amend: this.amend(), emptyCommit: this.emptyCommit(), }) .then(() => { this.resetMessages(); programEvents.dispatch({ event: 'branch-updated' }); }) .catch((e) => this.server.unhandledRejection(e)); } commitnpush() { const files = this.files() .filter((file) => file.editState() !== 'none') .map((file) => ({ name: file.name(), patchLineList: file.editState() === 'patched' ? file.patchLineList() : null, })); let commitMessage = this.commitMessageTitle(); if (this.commitMessageBody()) commitMessage += `\n\n${this.commitMessageBody()}`; this.server .postPromise('/commit', { path: this.repoPath(), message: commitMessage, files, amend: this.amend(), emptyCommit: this.emptyCommit(), }) .then(() => { this.resetMessages(); return this.server.postPromise('/push', { path: this.repoPath(), remote: this.graph.currentRemote(), }); }) .catch((err) => { if (err.errorCode == 'non-fast-forward') { components.showModal('yesnomodal', { title: 'Force push?', details: "The remote branch can't be fast-forwarded.", closeFunc: (isYes) => { if (!isYes) return; this.server.postPromise('/push', { path: this.repoPath(), remote: this.graph.currentRemote(), force: true, }); }, }); } else { this.server.unhandledRejection(err); } }); } conflictResolution(apiPath) { let commitMessage = this.commitMessageTitle(); if (this.commitMessageBody()) commitMessage += `\n\n${this.commitMessageBody()}`; this.server .postPromise(apiPath, { path: this.repoPath(), message: commitMessage }) .catch((e) => this.server.unhandledRejection(e)) .finally(() => { this.resetMessages(); }); } invalidateFilesDiffs() { this.files().forEach((file) => { file.diff().invalidateDiff(); }); } cancelAmendEmpty() { this.resetMessages(); } discardAllChanges() { components.showModal('yesnomodal', { title: 'Are you sure you want to discard all changes?', details: 'This operation cannot be undone.', closeFunc: (isYes) => { if (!isYes) return; this.server .postPromise('/discardchanges', { path: this.repoPath(), all: true }) .catch((e) => this.server.unhandledRejection(e)); }, }); } stashAll() { this.server .postPromise('/stashes', { path: this.repoPath(), message: this.commitMessageTitle() }) .catch((e) => this.server.unhandledRejection(e)); } toggleAllStages() { const allStageFlag = this.allStageFlag(); for (const n in this.files()) { this.files()[n].editState(allStageFlag ? 'staged' : 'none'); } } onEnter(d, e) { if (e.keyCode === 13 && !this.commitValidationError()) { this.commit(); } return true; } onAltEnter(d, e) { if (e.keyCode === 13 && e.altKey && !this.commitValidationError()) { this.commit(); } return true; } } class FileViewModel { constructor(staging, name, oldName, displayName) { this.staging = staging; this.server = staging.server; this.editState = ko.observable('staged'); // staged, patched and none this.name = ko.observable(name); this.oldName = ko.observable(oldName); this.displayName = ko.observable(displayName); this.isNew = ko.observable(false); this.removed = ko.observable(false); this.conflict = ko.observable(false); this.renamed = ko.observable(false); this.isShowingDiffs = ko.observable(false); this.additions = ko.observable(''); this.deletions = ko.observable(''); this.modified = ko.computed(() => { // only show modfied whe not removed, not conflicted, not new, not renamed // and length of additions and deletions is 0. return ( !this.removed() && !this.conflict() && !this.isNew() && this.additions().length === 0 && this.deletions().length === 0 ); }); this.fileType = ko.observable('text'); this.patchLineList = ko.observableArray(); this.diff = ko.observable(); this.isShowPatch = ko.computed( () => // if not new file // and if not merging // and if not rebasing // and if text file // and if diff is showing, display patch button !this.isNew() && !staging.inMerge() && !staging.inRebase() && this.fileType() === 'text' && this.isShowingDiffs() ); this.mergeTool = ko.computed(() => this.conflict() && mergeTool !== false); this.editState.subscribe((value) => { if (value === 'none') { this.patchLineList.removeAll(); } else if (value === 'patched') { if (this.diff().render) this.diff().render(); } }); } getSpecificDiff() { return components.create(!this.name() || `${this.fileType()}diff`, { filename: this.name(), oldFilename: this.oldName(), displayFilename: this.displayName(), repoPath: this.staging.repoPath, server: this.server, textDiffType: this.staging.textDiffType, whiteSpace: this.staging.whiteSpace, isShowingDiffs: this.isShowingDiffs, patchLineList: this.patchLineList, editState: this.editState, wordWrap: this.staging.wordWrap, }); } setState(state) { this.displayName(state.displayName); this.isNew(state.isNew); this.removed(state.removed); this.conflict(state.conflict); this.renamed(state.renamed); this.fileType(state.type); this.additions(state.additions != '-' ? `+${state.additions}` : ''); this.deletions(state.deletions != '-' ? `-${state.deletions}` : ''); if (this.diff()) { this.diff().invalidateDiff(); } else { this.diff(this.getSpecificDiff()); } if (this.diff().isNew) this.diff().isNew(state.isNew); if (this.diff().isRemoved) this.diff().isRemoved(state.removed); } toggleStaged() { if (this.editState() === 'none') { this.editState('staged'); } else { this.editState('none'); } this.patchLineList([]); } discardChanges() { const timeSinceLastMute = new Date().getTime() - this.staging.mutedTime; const isMuteWarning = timeSinceLastMute < ungit.config.disableDiscardMuteTime; ungit.logger.debug( `discard time since mute: ${timeSinceLastMute}, isMuteWarning: ${isMuteWarning}` ); if (ungit.config.disableDiscardWarning || isMuteWarning) { this.server .postPromise('/discardchanges', { path: this.staging.repoPath(), file: this.name() }) .catch((e) => this.server.unhandledRejection(e)); } else { components.showModal('yesnomutemodal', { title: 'Are you sure you want to discard these changes?', details: 'This operation cannot be undone.', closeFunc: (isYes, isMute) => { if (isYes) { this.server .postPromise('/discardchanges', { path: this.staging.repoPath(), file: this.name() }) .catch((e) => this.server.unhandledRejection(e)); } if (isMute) { this.staging.mutedTime = new Date().getTime(); } }, }); } } ignoreFile() { this.server .postPromise('/ignorefile', { path: this.staging.repoPath(), file: this.name() }) .catch((err) => { if (err.errorCode == 'file-already-git-ignored') { // The file was already in the .gitignore, so force an update of the staging area (to hopefully clear away this file) programEvents.dispatch({ event: 'working-tree-changed' }); } else { this.server.unhandledRejection(err); } }); } resolveConflict() { this.server .postPromise('/resolveconflicts', { path: this.staging.repoPath(), files: [this.name()] }) .catch((e) => this.server.unhandledRejection(e)); } launchMergeTool() { this.server .postPromise('/launchmergetool', { path: this.staging.repoPath(), file: this.name(), tool: mergeTool, }) .catch((e) => this.server.unhandledRejection(e)); } toggleDiffs() { this.isShowingDiffs(!this.isShowingDiffs()); } patchClick() { if (!this.isShowingDiffs()) return; if (this.editState() === 'patched') { this.editState('staged'); } else { this.editState('patched'); } } } ================================================ FILE: components/staging/staging.less ================================================ @import 'public/less/variables.less'; .staging { background: #643a44; box-shadow: 0 -1px 15px #252833; color: #b8a5a5; z-index: 5; position: relative; .form-control { &:disabled { background-color: rgba(64, 36, 43, 0.75); } } textarea.commit-body { resize: vertical; } .arrow { border-top-color: #643a44; left: (@log-width-small + 45px); bottom: -30px; } .commitnpush.disabled { pointer-events: none; opacity: 0.5; } .file-area { position: relative; } .validationError { display: none; color: #d6542d; padding: 0.25em; } &:hover .validationError { display: inline-block; } .diffContainer { margin-top: 0; border-radius: 3px; background: rgba(255, 255, 255, 0.1); } .discard { background: transparent; color: rgba(255, 255, 255, 0.3); border-radius: 3px; padding: 3px; padding-left: 5px; padding-right: 5px; cursor: pointer; &:focus, &:hover { background: #000000; color: rgba(255, 255, 255, 0.9); } } .ignore { background: transparent; color: rgba(255, 255, 255, 0.3); border-radius: 3px; padding: 3px; padding-left: 5px; padding-right: 5px; cursor: pointer; font-weight: bold; &:focus, &:hover { background: #5555ff; color: rgba(255, 255, 255, 0.9); } } .patch { background: #279124; color: rgba(255, 255, 255, 0.9); border-radius: 3px; padding: 3px; padding-left: 5px; padding-right: 5px; cursor: pointer; font-weight: bold; &:focus, &:hover { background: #279124; color: rgba(255, 255, 255, 0.9); } } .d2h-code-line-prefix input[type='checkbox'] { margin: 0; margin-right: -5px; vertical-align: sub; } .files { position: relative; .file { padding: 0.3em; &.showingDiffs { .name { background: rgba(255, 255, 255, 0.1); color: #000000; border-bottom-left-radius: 0; border-bottom-right-radius: 0; } } .checkmark { span { top: 5px; } } .name { background: transparent; font-size: 1.3em; cursor: pointer; padding: 3px; border: 0; border-radius: 3px; color: rgba(255, 255, 255, 0.8); } .new, .deleted, .conflict, .markresolved, .launchmergetool { padding: 3px; padding-left: 5px; padding-right: 5px; } .new, .additions { color: #949494; vertical-align: middle; } .deleted, .deletions { color: #7b7b7b; vertical-align: middle; } .conflict { color: #db12c0; .explanation { display: none; } &:hover { .explanation { display: inline; } .temporary { display: none; } } } .markresolved { color: #db12c0; cursor: pointer; .explanation { display: none; } &:hover { background: #a445ed; color: #000000; border-radius: 3px; .explanation { display: inline; } } } .launchmergetool { color: #db55ff; cursor: pointer; .explanation { display: none; } &:hover { background: #a477ff; color: #000000; border-radius: 3px; .explanation { display: inline; } } } } } } @media (min-width: @screen-md-min) { .staging { .arrow { left: (@log-width-large + 45px); } } } .commit-message-title-counter { right: 20px; position: absolute; } .amend-button { padding: 0; &:active, &:focus, &:hover { text-decoration: none; } } .checkmark { color: #ffffff; display: inline-block; opacity: 0.3; cursor: pointer; &.checked { opacity: 0.8; } } ================================================ FILE: components/staging/ungit-plugin.json ================================================ { "exports": { "knockoutTemplates": { "staging": "staging.html" }, "javascript": "staging.bundle.js", "css": "staging.css" } } ================================================ FILE: components/stash/stash.html ================================================
Stash ()

Stashed changes ()

================================================ FILE: components/stash/stash.js ================================================ const ko = require('knockout'); const _ = require('lodash'); const octicons = require('octicons'); const moment = require('moment'); const components = require('ungit-components'); const storage = require('ungit-storage'); const { ComponentRoot } = require('../ComponentRoot'); components.register('stash', (args) => new StashViewModel(args.server, args.repoPath)); class StashItemViewModel { constructor(stash, data) { this.stash = stash; this.server = stash.server; this.id = data.reflogId; this.sha1 = data.sha1; this.title = `${data.reflogName} ${moment(new Date(data.commitDate)).fromNow()}`; this.message = data.message; this.showCommitDiff = ko.observable(false); this.commitDiff = ko.observable( components.create('commitDiff', { fileLineDiffs: data.fileLineDiffs.slice(), sha1: this.sha1, repoPath: stash.repoPath, server: stash.server, showDiffButtons: ko.observable(true), }) ); this.dropIcon = octicons.x.toSVG({ height: 18 }); this.applyIcon = octicons.pencil.toSVG({ height: 20 }); } apply() { this.server .delPromise(`/stashes/${this.id}`, { path: this.stash.repoPath(), apply: true }) .catch((e) => this.server.unhandledRejection(e)); } drop() { components.showModal('yesnomodal', { title: 'Are you sure you want to drop the stash?', details: 'This operation cannot be undone.', closeFunc: (isYes) => { if (!isYes) return; this.server .delPromise(`/stashes/${this.id}`, { path: this.stash.repoPath() }) .catch((e) => this.server.unhandledRejection(e)); }, }); } toggleShowCommitDiffs() { this.showCommitDiff(!this.showCommitDiff()); } } class StashViewModel extends ComponentRoot { constructor(server, repoPath) { super(); this.server = server; this.repoPath = repoPath; this.refresh = _.debounce(this._refresh, 250, this.defaultDebounceOption); this.stashedChanges = ko.observable([]); this.isShow = ko.observable(storage.getItem('showStash') === 'true'); this.visible = ko.computed(() => this.stashedChanges().length > 0 && this.isShow()); this.expandIcon = octicons['chevron-right'].toSVG({ height: 18 }); this.expandedIcon = octicons['chevron-down'].toSVG({ height: 22 }); this.refresh(); } updateNode(parentElement) { ko.renderTemplate('stash', this, {}, parentElement); } onProgramEvent(event) { if (event.event == 'request-app-content-refresh' || event.event == 'git-directory-changed') { this.refresh(); } } async _refresh() { ungit.logger.debug('stash.refresh() triggered'); try { const stashes = await this.server.getPromise('/stashes', { path: this.repoPath() }); if (this.isSamePayload(stashes)) { return; } let changed = this.stashedChanges().length != stashes.length; if (!changed) { changed = !this.stashedChanges().every((item1) => stashes.some((item2) => item1.sha1 == item2.sha1) ); } if (changed) { this.stashedChanges(stashes.map((item) => new StashItemViewModel(this, item))); } } catch (err) { if (err.errorCode != 'no-such-path') { this.server.unhandledRejection(err); } else { ungit.logger.warn('refresh failed: ', err); } } finally { ungit.logger.debug('stash.refresh() finished'); } } toggleShowStash() { this.isShow(!this.isShow()); storage.setItem('showStash', this.isShow()); } } ================================================ FILE: components/stash/stash.less ================================================ .stash { z-index: 4; margin-left: 20px; margin-right: 20px; margin-bottom: -15px; background: #55323c; h4 { margin-top: 0; } .toggle-show-commit-diffs { display: inline-block; } .diff-wrapper { margin-top: 5px; } } .stash-toggle { width: 120px; height: 30px; position: relative; background: #55323c; left: 20px; border-radius: 5px 5px 0 0; padding-top: 5px; text-align: center; } .stash-toggle-text { cursor: pointer; } .expand-icon { opacity: 0.5; } .stash-apply .octicon { vertical-align: middle; } ================================================ FILE: components/stash/ungit-plugin.json ================================================ { "exports": { "knockoutTemplates": { "stash": "stash.html" }, "javascript": "stash.bundle.js", "css": "stash.css" } } ================================================ FILE: components/submodules/submodules.html ================================================
================================================ FILE: components/submodules/submodules.js ================================================ const ko = require('knockout'); const _ = require('lodash'); const octicons = require('octicons'); const components = require('ungit-components'); const programEvents = require('ungit-program-events'); const { ComponentRoot } = require('../ComponentRoot'); components.register('submodules', (args) => new SubmodulesViewModel(args.server, args.repoPath)); class SubmodulesViewModel extends ComponentRoot { constructor(server, repoPath) { super(); this.repoPath = repoPath; this.server = server; this.fetchSubmodules = _.debounce(this._fetchSubmodules, 250, this.defaultDebounceOption); this.submodules = ko.observableArray(); this.submodulesIcon = octicons['file-submodule'].toSVG({ height: 18 }); this.closeIcon = octicons.x.toSVG({ height: 18 }); this.linkIcon = octicons['link-external'].toSVG({ height: 18 }); } onProgramEvent(event) { if (event.event == 'submodule-fetch') { this.fetchSubmodules(); } } updateNode(parentElement) { this.fetchSubmodules(); this.fetchSubmodules.flush().then((submoduleViewModel) => { ko.renderTemplate('submodules', submoduleViewModel, {}, parentElement); }); } async _fetchSubmodules() { try { const submodules = await this.server.getPromise('/submodules', { path: this.repoPath() }); this.submodules(submodules); return this; } catch (e) { ungit.logger.error('error during fetchSubmodules', e); } } updateSubmodules() { return this.server .postPromise('/submodules/update', { path: this.repoPath() }) .catch((e) => this.server.unhandledRejection(e)); } showAddSubmoduleDialog() { components.showModal('addsubmodulemodal', { path: this.repoPath() }); } submoduleLinkClick(submodule) { window.location.href = submodule.url; } submodulePathClick(submodule) { window.location.href = document.URL + ungit.config.fileSeparator + submodule.path; } submoduleRemove(submodule) { components.showModal('yesnomodal', { title: 'Are you sure?', details: `Deleting ${submodule.name} submodule cannot be undone with ungit.`, closeFunc: (isYes) => { if (!isYes) return; this.server .delPromise('/submodules', { path: this.repoPath(), submodulePath: submodule.path, submoduleName: submodule.name, }) .then(() => { programEvents.dispatch({ event: 'submodule-fetch' }); }) .catch((e) => this.server.unhandledRejection(e)); }, }); } } ================================================ FILE: components/submodules/ungit-plugin.json ================================================ { "exports": { "knockoutTemplates": { "submodules": "submodules.html" }, "javascript": "submodules.bundle.js" } } ================================================ FILE: components/textdiff/textdiff.html ================================================
================================================ FILE: components/textdiff/textdiff.js ================================================ const ko = require('knockout'); const components = require('ungit-components'); const diff2html = require('diff2html'); const sideBySideDiff = 'sidebysidediff'; const textDiff = 'textdiff'; components.register('textdiff', (args) => new TextDiffViewModel(args)); components.register('textdiff.type', () => new Type()); components.register('textdiff.wordwrap', () => new WordWrap()); components.register('textdiff.whitespace', () => new WhiteSpace()); const loadLimit = 100; class WordWrap { constructor() { this.value = ko.observable(false); this.toggle = () => { this.value(!this.value()); }; this.text = ko.computed(() => (this.value() ? 'Wrap Lines' : 'No Wrap')); this.isActive = ko.computed(() => this.value()); } } class Type { constructor() { if ( !!ungit.config.diffType && ungit.config.diffType !== textDiff && ungit.config.diffType !== sideBySideDiff ) { ungit.config.diffType = textDiff; console.log('Config "diffType" must be either "textdiff" or "sidebysidediff".'); } this.value = ko.observable(ungit.config.diffType || textDiff); this.toggle = () => { this.value(this.value() === textDiff ? sideBySideDiff : textDiff); }; this.text = ko.computed(() => (this.value() === textDiff ? 'Inline' : 'Side By Side')); this.isActive = ko.computed(() => this.value() === sideBySideDiff); } } class WhiteSpace { constructor() { this.value = ko.observable(ungit.config.ignoreWhiteSpaceDiff); this.toggle = () => { this.value(!this.value()); }; this.text = ko.computed(() => (this.value() ? 'Show Whitespace' : 'Hide Whitespace')); this.isActive = ko.computed(() => this.value()); } } class TextDiffViewModel { constructor(args) { this.filename = args.filename; this.oldFilename = args.oldFilename; this.repoPath = args.repoPath; this.server = args.server; this.sha1 = args.sha1; this.hasMore = ko.observable(false); this.diffJson = null; this.loadCount = loadLimit; this.textDiffType = args.textDiffType; this.whiteSpace = args.whiteSpace; this.isShowingDiffs = args.isShowingDiffs; this.editState = args.editState; this.wordWrap = args.wordWrap; this.patchLineList = args.patchLineList; this.numberOfSelectedPatchLines = 0; this.htmlSrc = undefined; this.isParsed = ko.observable(false); this.isShowingDiffs.subscribe((newValue) => { if (newValue) this.render(); }); this.textDiffType.value.subscribe(() => { if (this.isShowingDiffs()) this.render(); }); this.whiteSpace.value.subscribe(() => { if (this.isShowingDiffs()) this.invalidateDiff(); }); if (this.isShowingDiffs()) { this.render(); } } updateNode(parentElement) { ko.renderTemplate('textdiff', this, {}, parentElement); } getDiffArguments() { return { file: this.filename, oldFile: this.oldFilename, path: this.repoPath(), sha1: this.sha1 ? this.sha1 : '', whiteSpace: this.whiteSpace.value(), }; } invalidateDiff() { this.diffJson = null; if (this.isShowingDiffs()) this.render(); } getDiffJson() { return this.server .getPromise('/diff', this.getDiffArguments()) .then((diffs) => { if (typeof diffs !== 'string') { // Invalid value means there is no changes, show dummy diff without any changes diffs = `diff --git a/${this.filename} b/${this.filename} index aaaaaaaa..bbbbbbbb 111111 --- a/${this.filename} +++ b/${this.filename}`; } this.diffJson = diff2html.parse(diffs); }) .catch((err) => { // The file existed before but has been removed, but we're trying to get a diff for it // Most likely it will just disappear with the next refresh of the staging area // so we just ignore the error here if (err.errorCode != 'no-such-file') { this.server.unhandledRejection(err); } else { ungit.logger.warn('diff, no such file', err); } }); } render() { return (!this.diffJson ? this.getDiffJson() : Promise.resolve()).then(() => { if (!this.diffJson || this.diffJson.length == 0) return; // check if diffs are available (binary files do not support them) if (!this.diffJson[0].allBlocks) { this.diffJson[0].allBlocks = this.diffJson[0].blocks; } const currentLoadCount = Math.max(this.loadCount, loadLimit); let lineCount = 0; let loadCount = 0; this.diffJson[0].blocks = this.diffJson[0].allBlocks.reduce((blocks, block) => { const length = block.lines.length; const remaining = currentLoadCount - lineCount; if (remaining > 0) { loadCount += length; blocks.push(block); } lineCount += length; return blocks; }, []); this.loadCount = loadCount; this.hasMore(lineCount > loadCount); let html = diff2html.html(this.diffJson, { outputFormat: this.textDiffType.value() === sideBySideDiff ? 'side-by-side' : 'line-by-line', drawFileList: false, }); this.numberOfSelectedPatchLines = 0; let index = 0; // ko's binding resolution is not recursive, which means below ko.bind refresh method doesn't work for // data bind at getPatchCheckBox that is rendered with "html" binding. // which is reason why manually updating the html content and refreshing kobinding to have it render... if (this.patchLineList) { html = html.replace(/(\+|-)/g, (match, capture) => { if (this.patchLineList()[index] === undefined) { this.patchLineList()[index] = true; } return this.getPatchCheckBox(capture, index, this.patchLineList()[index++]); }); } if (html !== this.htmlSrc) { // diff has changed since last we displayed and need refresh this.htmlSrc = html; this.isParsed(false); this.isParsed(true); } }); } loadMore() { this.loadCount += loadLimit; this.render(); } getPatchCheckBox(symbol, index, isActive) { if (isActive) { this.numberOfSelectedPatchLines++; } return `${symbol}`; } togglePatchLine(index) { this.patchLineList()[index] = !this.patchLineList()[index]; if (this.patchLineList()[index]) { this.numberOfSelectedPatchLines++; } else { this.numberOfSelectedPatchLines--; } if (this.numberOfSelectedPatchLines === 0) { this.editState('none'); } return true; } } ================================================ FILE: components/textdiff/textdiff.less ================================================ .textdiff { .load-more { padding: 10px 0; text-align: center; } } ================================================ FILE: components/textdiff/ungit-plugin.json ================================================ { "exports": { "knockoutTemplates": { "textdiff": "textdiff.html" }, "javascript": "textdiff.bundle.js", "css": "textdiff.css" } } ================================================ FILE: eslint.config.mjs ================================================ import js from '@eslint/js'; import globals from 'globals'; import mochaPlugin from 'eslint-plugin-mocha'; import nodePlugin from 'eslint-plugin-n'; import prettierPlugin from 'eslint-plugin-prettier/recommended'; export default [ { ignores: ['public/js', '**/*.bundle.js'], }, js.configs.recommended, mochaPlugin.configs.recommended, nodePlugin.configs['flat/recommended'], prettierPlugin, // components { files: ['components/**'], languageOptions: { globals: { ...globals.browser, ungit: 'readonly', }, }, rules: { 'n/no-missing-require': 'off', 'n/no-unsupported-features/node-builtins': 'off', }, }, // public/source { files: ['public/source/**'], languageOptions: { globals: { ...globals.browser, io: 'readonly', jQuery: 'writable', Raven: 'readonly', ungit: 'readonly', }, }, rules: { 'n/no-missing-require': 'off', 'n/no-unsupported-features/node-builtins': 'off', }, }, // public/main.js { files: ['public/main.js'], rules: { 'n/no-unpublished-require': [ 'error', { allowModules: ['electron'], }, ], }, }, // source { files: ['source/**'], rules: { 'no-control-regex': 'off', 'n/no-process-exit': 'off', }, }, // test { files: ['test/**'], languageOptions: { globals: { ...globals.browser, }, }, rules: { 'mocha/no-mocha-arrows': 'off', }, }, // clicktests { files: ['clicktests/**'], languageOptions: { globals: { ...globals.browser, ungit: 'readonly', }, }, rules: { 'mocha/no-mocha-arrows': 'off', 'mocha/no-setup-in-describe': 'off', }, }, // eslint.config.mjs { files: ['eslint.config.mjs'], languageOptions: { sourceType: 'module', }, }, ]; ================================================ FILE: package.json ================================================ { "name": "ungit", "productName": "ungit", "author": "Fredrik Norén ", "description": "Git made easy", "version": "1.5.30", "ungitPluginApiVersion": "0.2.0", "scripts": { "start": "node ./bin/ungit", "inspect": "node --inspect ./source/server.js", "test": "npm run unittest && npm run clicktest", "unittest": "mocha --config .mochatest.json", "clicktest": "mocha --config .mochaclicktest.json", "coverage": "nyc npm run unittest", "lint": "eslint .", "format": "npm run lint -- --fix", "build": "node --trace-deprecation ./scripts/build.js", "watch": "nodemon -C --exec \"npm run build\" -e js,less -w public/source -w public/less -w components/ -i \"*.bundle.js\"", "bumpdependencies": "ncu --upgrade --reject jquery --reject bootstrap", "electronpackage": "node ./scripts/electronpackage.js", "electronzip": "node ./scripts/electronzip.js" }, "repository": { "type": "git", "url": "https://github.com/FredrikNoren/ungit.git" }, "bin": { "ungit": "./bin/ungit", "0ungit-credentials-helper": "./bin/credentials-helper" }, "dependencies": { "@primer/octicons": "~19.22.0", "blueimp-md5": "~2.19.0", "body-parser": "~2.2.2", "bootstrap": "~3.4.1", "chokidar": "~5.0.0", "cookie-parser": "~1.4.7", "crossroads": "~0.12.2", "diff2html": "~3.4.56", "dnd-page-scroll": "0.0.4", "express": "~5.2.1", "express-session": "~1.19.0", "getmac": "~6.6.0", "hasher": "~1.2.0", "ignore": "~7.0.5", "jquery": "~3.7.1", "jquery-ui": "~1.14.2", "just-detect-adblock": "~1.1.0", "knockout": "~3.5.1", "latest-version": "~9.0.0", "lodash": "~4.17.23", "memorystore": "~1.6.7", "mkdirp": "~3.0.1", "moment": "~2.30.1", "node-cache": "~5.1.2", "nprogress": "~0.2.0", "open": "~11.0.0", "p-limit": "~7.3.0", "passport": "~0.7.0", "passport-local": "~1.0.0", "raven-js": "~3.27.2", "rc": "~1.2.8", "rimraf": "~6.1.3", "semver": "~7.7.4", "serve-static": "~2.2.1", "signals": "~1.0.0", "snapsvg": "~0.5.1", "socket.io": "~4.8.3", "temp": "~0.9.4", "tsify": "~5.0.4", "typescript": "~5.9.3", "winston": "~3.19.0", "yargs": "~18.0.0" }, "devDependencies": { "@eslint/js": "~10.0.1", "@homer0/prettier-plugin-jsdoc": "~11.0.2", "archiver": "~7.0.1", "browserify": "~17.0.1", "dedent": "~1.7.1", "electron": "~40.6.1", "electron-packager": "~17.1.2", "eslint": "~10.0.2", "eslint-config-prettier": "~10.1.8", "eslint-plugin-mocha": "~11.2.0", "eslint-plugin-n": "~17.24.0", "eslint-plugin-prettier": "~5.5.5", "exorcist": "~2.0.0", "expect.js": "~0.3.1", "globals": "~17.3.0", "less": "~4.5.1", "mocha": "~11.7.5", "nodemon": "~3.1.14", "npm-check-updates": "~19.6.3", "nyc": "~18.0.0", "portfinder": "~1.0.38", "prettier": "~3.8.1", "puppeteer": "~24.37.5", "superagent": "~10.3.0", "supertest": "~7.2.2" }, "engines": { "node": "^20.19.0 || ^22.12.0 || >=23" }, "license": "MIT", "main": "public/main.js" } ================================================ FILE: public/index.html ================================================ ungit ================================================ FILE: public/less/bootstrap.less ================================================ // Core variables and mixins @import '../../node_modules/bootstrap/less/mixins.less'; // Reset @import '../../node_modules/bootstrap/less/normalize.less'; // Core CSS @import '../../node_modules/bootstrap/less/scaffolding.less'; @import '../../node_modules/bootstrap/less/type.less'; @import '../../node_modules/bootstrap/less/code.less'; @import '../../node_modules/bootstrap/less/grid.less'; @import '../../node_modules/bootstrap/less/forms.less'; @import '../../node_modules/bootstrap/less/buttons.less'; // Components @import '../../node_modules/bootstrap/less/component-animations.less'; @import '../../node_modules/bootstrap/less/glyphicons.less'; @import '../../node_modules/bootstrap/less/dropdowns.less'; @import '../../node_modules/bootstrap/less/button-groups.less'; @import '../../node_modules/bootstrap/less/navbar.less'; @import '../../node_modules/bootstrap/less/labels.less'; @import '../../node_modules/bootstrap/less/alerts.less'; @import '../../node_modules/bootstrap/less/list-group.less'; @import '../../node_modules/bootstrap/less/panels.less'; @import '../../node_modules/bootstrap/less/close.less'; // Components w/ JavaScript @import '../../node_modules/bootstrap/less/modals.less'; @import '../../node_modules/bootstrap/less/tooltip.less'; // Utility classes @import '../../node_modules/bootstrap/less/utilities.less'; @import '../../node_modules/bootstrap/less/responsive-utilities.less'; // Bootstrap styling .form-control { border: none; } .dropdown-menu { max-height: 500px; overflow-y: auto; } .list-group-item { border: none; margin-bottom: 3px; } .panel { border: none; } ================================================ FILE: public/less/d2h.less ================================================ // diff2html style overrides .d2h-file-wrapper { border: none; margin-bottom: 0; } .d2h-file-header { display: none; } .d2h-diff-table { font-family: 'Source Code Pro', monospace; font-size: 12px; tbody > tr > td { padding: 0; &.d2h-code-side-linenumber { padding: 0 0.5em; } } } .d2h-cntx { color: rgba(255, 255, 255, 0.3); } .d2h-info { background-color: transparent; color: rgba(255, 255, 255, 0.3); } .d2h-code-side-emptyplaceholder, .d2h-emptyplaceholder { background-color: transparent; } .d2h-code-linenumber, .d2h-code-side-linenumber { background-color: #727a83; border-left: 1px solid rgba(0, 0, 0, 0.34); border-right: 1px solid rgba(0, 0, 0, 0.34); } .d2h-file-diff .d2h-del.d2h-change, .d2h-del { background-color: #e86756; color: #ffffff; } .d2h-file-diff .d2h-ins.d2h-change, .d2h-ins { background-color: #66f27b; color: #000000; } .d2h-code-line del, .d2h-code-side-line del { background-color: rgba(255, 255, 255, 0.2); } .d2h-code-line ins, .d2h-code-side-line ins { background-color: rgba(255, 255, 255, 0.6); } .d2h-code-line-ctn { vertical-align: initial; } .word-wrap .d2h-code-line-ctn { white-space: pre-wrap; } ================================================ FILE: public/less/styles.less ================================================ @import 'variables.less'; @import 'bootstrap.less'; @import (less) '../../node_modules/nprogress/nprogress.css'; @import (less) '../../node_modules/jquery-ui/themes/base/core.css'; @import (less) '../../node_modules/diff2html/bundles/css/diff2html.min.css'; @import (less) '../../node_modules/@primer/octicons/build/build.css'; @import (less) '../vendor/css/animate.css'; @import 'd2h.less'; // Font face declarations @font-face { font-family: 'Open Sans'; font-style: normal; font-weight: 400; src: local('Open Sans'), local('OpenSans'), url('../fonts/OpenSans.woff') format('woff'); } @font-face { font-family: 'Source Code Pro'; font-style: normal; font-weight: 500; src: local('Source Code Pro Medium'), local('SourceCodePro-Medium'), url('../fonts/SourceCodePro-Medium.woff') format('woff'); } // Custom Scrollbar for Webkit ::-webkit-scrollbar { width: 15px; } ::-webkit-scrollbar-track { background-color: #2b3844; } ::-webkit-scrollbar-thumb { background-color: rgba(255, 255, 255, 0.2); } ::-webkit-scrollbar-button { background-color: rgba(0, 0, 0, 0.2); } ::-webkit-scrollbar-corner { background-color: black; } // Matching jQuery-UI autocomplete style to bootstrap dropdown .ui-autocomplete { .ui-menu-item-wrapper { cursor: pointer; &.ui-state-active { color: @dropdown-link-hover-color; background-color: @dropdown-link-hover-bg; border: none; margin: 0; } } } //New shared styles .list-group-item .list-item-remove { position: absolute; right: 0; top: 0; background: transparent; border: none; opacity: 0.3; &:hover { opacity: 1; } } div.list-group-item { &:hover { background: @list-group-hover-bg; } a:hover { text-decoration: none; } } .arrow { position: absolute; height: 0; width: 0; border: 15px solid transparent; } .octicon-circled { margin-right: 15px; border: 5px solid #686868; border-radius: 100%; display: inline-block; height: 36px; width: 36px; text-align: center; vertical-align: top; } .dropdown-menu { .linked-remove { padding-right: 40px; } .linked-url { padding-right: 60px; } .list-link { float: right; margin-top: -23px; padding: 0 4px; } .list-url { margin-right: 30px; } .list-remove { margin-right: 10px; } } ================================================ FILE: public/less/variables.less ================================================ @import '../../node_modules/bootstrap/less/variables.less'; // Application variables // -------------------------------------------------- @log-width-small: 400px; @log-width-large: 550px; // Bootstrap variables // -------------------------------------------------- // Scaffolding @body-bg: #252833; @text-color: #d8d8d8; // Links @link-hover-color: lighten(@link-color, 15%); // Typography @font-family-sans-serif: 'Open Sans', sans-serif; @font-family-monospace: 'Source Code Pro', monospace; // Buttons @btn-default-color: @text-color; @btn-default-bg: #556666; @btn-default-border: rgba(0, 0, 0, 0.1); // Forms @input-bg: rgba(255, 255, 255, 0.1); @input-color: rgba(255, 255, 255, 0.8); @input-color-placeholder: rgba(255, 255, 255, 0.3); // Navbar @navbar-default-bg: #2b3844; // Tooltips @tooltip-color: @text-color; @tooltip-bg: #3c4653; @tooltip-arrow-width: 8px; // Modals @modal-content-bg: @panel-bg; @modal-header-border-color: @list-group-border; // List group @list-group-bg: rgba(0, 0, 0, 0.09); @list-group-border: #435158; @list-group-hover-bg: rgba(0, 0, 0, 0.2); @list-group-link-color: rgba(255, 255, 255, 0.5); @list-group-link-heading-color: rgba(255, 255, 255, 0.8); // Panels @panel-bg: #3c4653; @panel-default-text: @text-color; @panel-default-border: #362c36; @panel-default-heading-bg: #2c3541; // Type @text-muted: #707a85; ================================================ FILE: public/main.js ================================================ var startLaunchTime = Date.now(); var child_process = require('child_process'); var path = require('path'); const { encodePath } = require('../source/address-parser'); var config = require('../source/config'); var BugTracker = require('../source/bugtracker'); var bugtracker = new BugTracker('electron'); var { app, dialog, shell, BrowserWindow, Menu } = require('electron'); process.on('uncaughtException', function (err) { console.error(err.stack.toString()); bugtracker.notify(err, 'ungit-launcher'); app.quit(); }); function openUngitBrowser(pathToNavigateTo) { console.log(`Navigate to ${pathToNavigateTo}`); mainWindow.loadURL(pathToNavigateTo); } function launch(callback) { var url = config.urlBase + ':' + config.port; if (config.forcedLaunchPath === undefined) { url += '/#/repository?path=' + encodePath(process.cwd()); } else if (config.forcedLaunchPath !== null && config.forcedLaunchPath !== '') { url += '/#/repository?path=' + encodePath(config.forcedLaunchPath); } if (config.launchCommand) { var command = config.launchCommand.replace(/%U/g, url); console.log('Running custom launch command: ' + command); child_process.exec(command, function (err) { if (err) { callback(err); return; } if (config.launchBrowser) { openUngitBrowser(url); } }); } else if (config.launchBrowser) { openUngitBrowser(url); } } function checkIfUngitIsRunning(callback) { // Fastest way to find out if a port is used or not/i.e. if ungit is running var net = require('net'); var server = net.createServer(); server.on('error', function (e) { if (e.code == 'EADDRINUSE') { callback(true); } }); server.listen({ port: config.port, host: config.ungitBindIp }, function () { server.close(function () { callback(false); }); }); } var mainWindow = null; var appPath = app.getAppPath(); if (!appPath.endsWith('.asar')) { appPath = path.resolve(appPath, '..'); } var menuTemplate = [ { label: 'File', submenu: [{ role: 'quit' }], }, { label: 'Edit', submenu: [ { role: 'undo' }, { role: 'redo' }, { type: 'separator' }, { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'delete' }, { type: 'separator' }, { role: 'selectAll' }, ], }, { label: 'View', submenu: [ { role: 'reload' }, { role: 'forcereload' }, { role: 'toggledevtools' }, { type: 'separator' }, { role: 'resetzoom' }, { role: 'zoomin' }, { role: 'zoomout' }, { type: 'separator' }, { role: 'togglefullscreen' }, ], }, { role: 'help', submenu: [ { label: 'Learn More', click: async () => { await shell.openExternal('https://github.com/FredrikNoren/ungit'); }, }, ], }, ]; app.on('window-all-closed', function () { app.quit(); }); app.on('ready', function () { checkIfUngitIsRunning(function (ungitRunning) { if (ungitRunning) { dialog.showMessageBoxSync({ type: 'error', title: 'Ungit', message: 'Ungit instance is already running', }); app.quit(); } else { var server = require('../source/server'); server.started.add(function () { launch(function (err) { if (err) console.log(err); }); var launchTime = Date.now() - startLaunchTime; console.log('Took ' + launchTime + 'ms to start server.'); }); Menu.setApplicationMenu(Menu.buildFromTemplate(menuTemplate)); mainWindow = new BrowserWindow({ width: 1366, height: 768, icon: path.join(appPath, 'public/images/icon.png'), }); mainWindow.on('closed', function () { mainWindow = null; }); } }); }); ================================================ FILE: public/source/bootstrap.js ================================================ /* * Import the Bootstrap components individually. */ require('bootstrap/js/dropdown'); require('bootstrap/js/modal'); require('bootstrap/js/tooltip'); ================================================ FILE: public/source/components.js ================================================ const components = {}; module.exports = components; ungit.components = components; components.registered = {}; components.register = function (name, creator) { components.registered[name] = creator; }; components.create = function (name, args) { var componentConstructor = components.registered[name]; if (!componentConstructor) throw new Error('No component found: ' + name); return componentConstructor(args); }; components.showModal = (name, args) => { const modal = components.create(name, args); ungit.programEvents.dispatch({ event: 'modal-show-dialog', modal: modal }); return modal; }; ================================================ FILE: public/source/jquery-ui.js ================================================ /* * Import the autocomplete widget and its dependencies. * The current order of the imports is required. */ // All files require version, has to go first require('jquery-ui/ui/version'); // Shared files, used by menu and autocomplete, in alphabetical order require('jquery-ui/ui/keycode'); require('jquery-ui/ui/position'); require('jquery-ui/ui/unique-id'); require('jquery-ui/ui/widget'); // Required by autocomplete, so has to go before require('jquery-ui/ui/widgets/menu'); // The autocomplete widget we use require('jquery-ui/ui/widgets/autocomplete'); ================================================ FILE: public/source/knockout-bindings.js ================================================ /* eslint no-unused-vars: "off" */ var _ = require('lodash'); var ko = require('knockout'); var $ = require('jquery'); var { encodePath } = require('ungit-address-parser'); var navigation = require('ungit-navigation'); var storage = require('ungit-storage'); ko.bindingHandlers.debug = { init: function (element, valueAccessor) { var value = ko.utils.unwrapObservable(valueAccessor()); console.log('DEBUG INIT', value); }, update: function (element, valueAccessor, allBindingsAccessor, viewModel) { var value = ko.utils.unwrapObservable(valueAccessor()); console.log('DEBUG UPDATE', value); }, }; ko.bindingHandlers.component = { init: function (element, valueAccessor, allBindingsAccessor, viewModel) { ko.virtualElements.emptyNode(element); return { controlsDescendantBindings: true }; }, update: function (element, valueAccessor, allBindings, viewModel, bindingContext) { var component = ko.utils.unwrapObservable(valueAccessor()); if (!component || !component.updateNode) { ko.virtualElements.emptyNode(element); return; } var node = component.updateNode(element); if (node) ko.virtualElements.setDomNodeChildren(element, [node]); }, }; ko.virtualElements.allowedBindings.component = true; ko.bindingHandlers.editableText = { init: function (element, valueAccessor) { $(element).on('blur', function () { var observable = valueAccessor(); observable($(this).text()); }); }, update: function (element, valueAccessor) { var value = ko.utils.unwrapObservable(valueAccessor()); $(element).text(value); }, }; var currentlyDraggingViewModel = null; ko.bindingHandlers.dragStart = { init: function (element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) { var value = valueAccessor(); element.addEventListener('dragstart', function (e) { e.dataTransfer.setData('Text', 'ungit'); currentlyDraggingViewModel = viewModel; var valueUnwrapped = ko.utils.unwrapObservable(value); valueUnwrapped.call(viewModel, true); }); }, }; ko.bindingHandlers.dragEnd = { init: function (element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) { var value = valueAccessor(); element.addEventListener('dragend', function () { currentlyDraggingViewModel = null; var valueUnwrapped = ko.utils.unwrapObservable(value); valueUnwrapped.call(viewModel, false); }); }, }; ko.bindingHandlers.dropOver = { init: function (element, valueAccessor) { element.addEventListener('dragover', function (e) { var value = valueAccessor(); var valueUnwrapped = ko.utils.unwrapObservable(value); if (!valueUnwrapped) return; if (e.preventDefault) e.preventDefault(); e.dataTransfer.dropEffect = 'move'; return false; }); }, }; ko.bindingHandlers.dragEnter = { init: function (element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) { element.addEventListener('dragenter', function (e) { var value = valueAccessor(); var valueUnwrapped = ko.utils.unwrapObservable(value); valueUnwrapped.call(viewModel, currentlyDraggingViewModel); }); }, }; ko.bindingHandlers.dragLeave = { init: function (element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) { element.addEventListener('dragleave', function (e) { var value = valueAccessor(); var valueUnwrapped = ko.utils.unwrapObservable(value); valueUnwrapped.call(viewModel, currentlyDraggingViewModel); }); }, }; ko.bindingHandlers.drop = { init: function (element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) { var value = valueAccessor(); element.addEventListener('drop', function (e) { if (e.preventDefault) e.preventDefault(); var valueUnwrapped = ko.utils.unwrapObservable(value); valueUnwrapped.call(viewModel, currentlyDraggingViewModel); }); }, }; ko.bindingHandlers.shown = { init: function (element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) { var value = valueAccessor(); var valueUnwrapped = ko.utils.unwrapObservable(value); valueUnwrapped.call(viewModel); }, }; ko.bindingHandlers.element = { init: function (element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) { var observable = valueAccessor(); observable(element); }, }; (function scrollToEndBinding() { ko.bindingHandlers.scrolledToEnd = { init: function (element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) { element.valueAccessor = valueAccessor; element.viewModel = viewModel; element.setAttribute('data-scroll-to-end-listener', true); }, }; var checkAtEnd = function (element) { var elementEndY = $(element).offset().top + $(element).height(); var windowEndY = $(document).scrollTop() + document.documentElement.clientHeight; if (windowEndY > elementEndY - document.documentElement.clientHeight / 2) { var value = element.valueAccessor(); var valueUnwrapped = ko.utils.unwrapObservable(value); valueUnwrapped.call(element.viewModel); } }; function scrollToEndCheck() { var elems = document.querySelectorAll('[data-scroll-to-end-listener]'); for (var i = 0; i < elems.length; i++) checkAtEnd(elems[i]); } $(window).scroll(scrollToEndCheck); $(window).resize(scrollToEndCheck); })(); // handle focus for this element and all children. only when this element or all of its chlidren have lost focus set the value to false. ko.bindingHandlers.hasfocus2 = { init: function (element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) { var hasFocus = false; var timeout; ko.utils.registerEventHandler(element, 'focusin', handleElementFocusIn); ko.utils.registerEventHandler(element, 'focusout', handleElementFocusOut); function handleElementFocusIn() { hasFocus = true; valueAccessor()(true); } function handleElementFocusOut() { hasFocus = false; clearTimeout(timeout); timeout = setTimeout(function () { if (!hasFocus) { valueAccessor()(false); } }, 50); } }, }; ko.bindingHandlers.autocomplete = { init: (element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) => { const setAutoCompleteOptions = (sources) => { $(element) .autocomplete({ classes: { 'ui-autocomplete': 'dropdown-menu', }, source: sources, minLength: 0, messages: { noResults: '', results: () => {}, }, }) .data('ui-autocomplete')._renderItem = (ul, item) => { return $('
  • ').append($('').text(item.label)).appendTo(ul); }; }; const handleKeyEvent = (event) => { const value = $(element).val(); const lastChar = value.slice(-1); if (lastChar == ungit.config.fileSeparator) { // When file separator is entered, list what is in given path, and rest auto complete options ungit.server .getPromise('/fs/listDirectories', { term: value }) .then((directoryList) => { const currentDir = directoryList.shift(); $(element).val( currentDir.endsWith(ungit.config.fileSeparator) ? currentDir : currentDir + ungit.config.fileSeparator ); setAutoCompleteOptions(directoryList); $(element).autocomplete('search', value); }) .catch((err) => { if ( !err.errorSummary.startsWith('ENOENT: no such file or directory') && err.errorCode !== 'read-dir-failed' ) { throw err; } }); } else if (event.keyCode === 13) { // enter key is struck, navigate to the path event.preventDefault(); navigation.browseTo(`repository?path=${encodePath(value)}`); } else if (value === '' && storage.getItem('repositories')) { // if path is emptied out, show save path options const folderNames = JSON.parse(storage.getItem('repositories')).map((value) => { return { value: value, label: value.substring(value.lastIndexOf(ungit.config.fileSeparator) + 1), }; }); setAutoCompleteOptions(folderNames); $(element).autocomplete('search', ''); } return true; }; ko.utils.registerEventHandler(element, 'keyup', _.debounce(handleKeyEvent, 100)); }, }; ================================================ FILE: public/source/main.js ================================================ var $ = require('jquery'); jQuery = $; // this is for old backward compatability of bootrap modules var ko = require('knockout'); var dndPageScroll = require('dnd-page-scroll'); require('./bootstrap'); require('./jquery-ui'); require('./knockout-bindings'); const winston = require('winston'); ungit.logger = winston.createLogger({ level: ungit.config.logLevel || 'error', format: winston.format.combine( winston.format.timestamp(), winston.format.colorize(), winston.format.printf((info) => { const splat = info[Symbol.for('splat')]; if (splat) { const splatStr = splat.map((arg) => JSON.stringify(arg)).join('\n'); return `${info.timestamp} - ${info.level}: ${info.message} ${splatStr}`; } return `${info.timestamp} - ${info.level}: ${info.message}`; }) ), transports: [new winston.transports.Console()], }); var components = require('ungit-components'); var Server = require('./server'); var programEvents = require('ungit-program-events'); var navigation = require('ungit-navigation'); var adBlocker = require('just-detect-adblock'); // Request animation frame polyfill and init tooltips (function () { var lastTime = 0; var vendors = ['ms', 'moz', 'webkit', 'o']; for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { window.requestAnimationFrame = window[vendors[x] + 'RequestAnimationFrame']; window.cancelAnimationFrame = window[vendors[x] + 'CancelAnimationFrame'] || window[vendors[x] + 'CancelRequestAnimationFrame']; } if (!window.requestAnimationFrame) window.requestAnimationFrame = function (callback) { var currTime = new Date().getTime(); var timeToCall = Math.max(0, 16 - (currTime - lastTime)); var id = window.setTimeout(function () { callback(currTime + timeToCall); }, timeToCall); lastTime = currTime + timeToCall; return id; }; if (!window.cancelAnimationFrame) window.cancelAnimationFrame = function (id) { clearTimeout(id); }; $(document).tooltip({ selector: '[data-toggle="tooltip"]', }); })(); function WindowTitle() { this.path = 'ungit'; this.crash = false; } WindowTitle.prototype.update = function () { var title = this.path .replace(/\\/g, '/') .split('/') .filter(function (x) { return x; }) .reverse() .join(' < '); if (this.crash) title = ':( ungit crash ' + title; document.title = title; }; var windowTitle = new WindowTitle(); windowTitle.update(); var AppContainerViewModel = function () { this.content = ko.observable(); }; exports.AppContainerViewModel = AppContainerViewModel; AppContainerViewModel.prototype.templateChooser = function (data) { if (!data) return ''; return data.template; }; var app, appContainer, server; exports.start = function () { server = new Server(); appContainer = new AppContainerViewModel(); ungit.server = server; app = components.create('app', { appContainer: appContainer, server: server }); ungit.__app = app; programEvents.add(async (event) => { ungit.logger.info(`received event: ${event.event}`); if (event.event == 'disconnected' || event.event == 'git-crash-error') { console.error(`ungit crash: ${event.event}`, event.error, event.stacktrace); const err = event.event == 'disconnected' && (await adBlocker.detectAnyAdblocker()) ? 'adblocker' : event.event; appContainer.content(components.create('crash', err)); windowTitle.crash = true; windowTitle.update(); } else if (event.event == 'connected') { appContainer.content(app); windowTitle.crash = false; windowTitle.update(); } app.onProgramEvent(event); }); if (ungit.config.authentication) { var authenticationScreen = components.create('login', { server: server }); appContainer.content(authenticationScreen); authenticationScreen.loggedIn.add(function () { server.initSocket(); }); } else { server.initSocket(); } Raven.TraceKit.report.subscribe(function (event, err) { programEvents.dispatch({ event: 'raven-crash', error: err || event.event }); }); var prevTimestamp = 0; var updateAnimationFrame = function (timestamp) { var delta = timestamp - prevTimestamp; prevTimestamp = timestamp; if (app.updateAnimationFrame) app.updateAnimationFrame(delta); window.requestAnimationFrame(updateAnimationFrame); }; window.requestAnimationFrame(updateAnimationFrame); ko.applyBindings(appContainer); // routing navigation.crossroads.addRoute('/', function () { app.content(components.create('home', { app: app })); windowTitle.path = 'ungit'; windowTitle.update(); }); navigation.crossroads.addRoute('/repository{?query}', function (query) { programEvents.dispatch({ event: 'navigated-to-path', path: query.path }); app.content(components.create('path', { server: server, path: query.path })); windowTitle.path = query.path; windowTitle.update(); }); navigation.init(); }; $(document).ready(function () { dndPageScroll.default(); // Automatic page scrolling on drag-n-drop: http://www.planbox.com/blog/news/updates/html5-drag-and-drop-scrolling-the-page.html }); ================================================ FILE: public/source/navigation.js ================================================ var programEvents = require('ungit-program-events'); var navigation = {}; module.exports = navigation; var hasher = (navigation.hasher = require('hasher')); var crossroads = (navigation.crossroads = require('crossroads')); navigation.browseTo = function (path) { hasher.setHash(path); }; navigation.init = function () { //setup hasher function parseHash(newHash, oldHash) { crossroads.parse(newHash); programEvents.dispatch({ event: 'navigation-changed', path: newHash, oldPath: oldHash }); } hasher.initialized.add(parseHash); //parse initial hash hasher.changed.add(parseHash); //parse hash changes hasher.raw = true; hasher.init(); }; ================================================ FILE: public/source/program-events.js ================================================ const signals = require('signals'); const programEvents = new signals.Signal(); module.exports = programEvents; ungit.programEvents = programEvents; programEvents.add(function (event) { console.log('Event:', event.event); }); ================================================ FILE: public/source/server.js ================================================ var programEvents = require('ungit-program-events'); var rootPath = (ungit.config && ungit.config.rootPath) || ''; var nprogress; if (ungit.config.isDisableProgressBar) { nprogress = { start: () => {}, done: () => {}, }; } else { nprogress = require('nprogress'); nprogress.configure({ trickleRate: 0.06, trickleSpeed: 200, showSpinner: false, }); } function Server() { this.isInternetConnected = true; this.isUnloading = false; window.addEventListener('beforeunload', () => (this.isUnloading = true)); } module.exports = Server; Server.prototype.initSocket = function () { var self = this; this.socket = io('', { path: rootPath + '/socket.io', }); this.socket.on('connect_error', function (err) { self._isConnected(function (connected) { if (connected) throw err; else self._onDisconnect(err); }); }); this.socket.on('disconnect', function () { self._onDisconnect(); }); this.socket.on('connected', function (data) { self.socketId = data.socketId; programEvents.dispatch({ event: 'connected' }); }); this.socket.on('working-tree-changed', function () { programEvents.dispatch({ event: 'working-tree-changed' }); }); this.socket.on('git-directory-changed', function () { programEvents.dispatch({ event: 'git-directory-changed' }); }); this.socket.on('request-credentials', function (args) { self._getCredentials(function (credentials) { self.socket.emit('credentials', credentials); }, args); }); }; Server.prototype._queryToString = function (query) { var str = []; for (var p in query) if (Object.prototype.hasOwnProperty.call(query, p)) { str.push(encodeURIComponent(p) + '=' + encodeURIComponent(query[p])); } return str.join('&'); }; Server.prototype._httpJsonRequest = function (request, callback) { var httpRequest = new XMLHttpRequest(); httpRequest.onreadystatechange = function () { // It seems like you can get both readyState == 0, and readyState == 4 && status == 0 when you lose connection to the server if (httpRequest.readyState === 0) { callback({ error: 'connection-lost' }); } else if (httpRequest.readyState === 4) { var body; try { body = JSON.parse(httpRequest.responseText); } catch { body = null; } if (httpRequest.status == 0) callback({ error: 'connection-lost' }); else if (httpRequest.status != 200) callback({ status: httpRequest.status, body: body, httpRequest: httpRequest }); else callback(null, body); } }; var url = request.url; if (request.query) { url += '?' + this._queryToString(request.query); } httpRequest.open(request.method, url, true); httpRequest.setRequestHeader('Accept', 'application/json'); if (request.body) { httpRequest.setRequestHeader('Content-Type', 'application/json'); httpRequest.send(JSON.stringify(request.body)); } else { httpRequest.send(null); } }; // Check if the server is still alive Server.prototype._isConnected = function (callback) { this._httpJsonRequest({ method: 'GET', url: rootPath + '/api/ping' }, function (err, res) { callback(!err && res); }); }; Server.prototype._onDisconnect = function (err) { if (!this.isUnloading) { const stacktrace = Error().stack; console.warn('disconnecting...', err, stacktrace); programEvents.dispatch({ event: 'disconnected', stacktrace: stacktrace, error: err }); } }; Server.prototype._getCredentials = function (callback, args) { // Push out a program event, hoping someone will respond! (Which the app component will) programEvents.dispatch({ event: 'request-credentials', remote: args.remote }); var credentialsBinding = programEvents.add(function (event) { if (event.event != 'request-credentials-response') return; credentialsBinding.detach(); callback({ username: event.username, password: event.password }); }); }; Server.prototype.watchRepository = function (repositoryPath, callback) { this.socket.emit('watch', { path: repositoryPath }, callback); }; Server.prototype.queryPromise = function (method, path, body) { var self = this; if (body) body.socketId = this.socketId; var request = { method: method, url: rootPath + '/api' + path, }; if (method == 'GET' || method == 'DELETE') request.query = body; else request.body = body; nprogress.start(); return new Promise(function (resolve, reject) { self._httpJsonRequest(request, function (error, res) { if (error) { if (error.error == 'connection-lost') { return self._isConnected(function (connected) { if (connected) { reject({ errorCode: 'cross-domain-error', error: error }); } else { self._onDisconnect(error); resolve(); } }); } var errorSummary; if (error.body) { if (error.body.errorCode && error.body.errorCode != 'unknown') errorSummary = error.body.errorCode; else if (typeof error.body.error == 'string') errorSummary = error.body.error.split('\n')[0]; else if (typeof error.body.message == 'string') errorSummary = error.body.message; else errorSummary = JSON.stringify(error.body.error); } else { errorSummary = error.httpRequest.statusText + ' ' + error.status; } reject({ errorSummary: errorSummary, error: error, path: path, res: error, errorCode: error && error.body ? error.body.errorCode : 'unknown', }); } else { resolve(res); } }); }).finally(() => nprogress.done(true)); }; Server.prototype.getPromise = function (url, arg) { return this.queryPromise('GET', url, arg); }; Server.prototype.postPromise = function (url, arg) { return this.queryPromise('POST', url, arg); }; Server.prototype.delPromise = function (url, arg) { return this.queryPromise('DELETE', url, arg); }; Server.prototype.putPromise = function (url, arg) { return this.queryPromise('PUT', url, arg); }; Server.prototype.unhandledRejection = function (err) { // Show a error screen for git errors (so that people have a chance to debug them) if (err.res && err.res.body && err.res.body.isGitError) { programEvents.dispatch({ event: 'git-error', data: { command: err.res.body.command, error: err.res.body.error, stdout: err.res.body.stdout, stderr: err.res.body.stderr, repoPath: err.res.body.workingDirectory, }, }); } else { // Everything else is handled as a pure error, using the precreated error (to get a better stacktrace) console.trace('Unhandled Promise ERROR: ', err, JSON.stringify(err)); programEvents.dispatch({ event: 'git-crash-error', error: err }); Raven.captureException(err); } }; ================================================ FILE: public/source/storage.js ================================================ /** * A wrapper around LocalStorage to support environments where LocalStorage is not available. * Stores and retrieves items from LocalStorage if available and uses a non-persistent cache otherwise. */ var storage; try { storage = { getItem: localStorage.getItem.bind(localStorage), setItem: localStorage.setItem.bind(localStorage), }; } catch { /* Ignore Exception, use fallback implementation. */ } if (!storage) { var cache = Object.create(null); storage = { getItem: function (key) { return cache[key] || null; }, setItem: function (key, value) { cache[key] = value; }, }; } module.exports = storage; ================================================ FILE: public/vendor/css/animate.css ================================================ @charset "UTF-8"; /* Animate.css - http://daneden.me/animate Licensed under the MIT license Copyright (c) 2013 Daniel Eden Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ body { /* Addresses a small issue in webkit: http://bit.ly/NEdoDq */ -webkit-backface-visibility: hidden; } .animated { -webkit-animation-duration: 1s; -moz-animation-duration: 1s; -o-animation-duration: 1s; animation-duration: 1s; -webkit-animation-fill-mode: both; -moz-animation-fill-mode: both; -o-animation-fill-mode: both; animation-fill-mode: both; } .animated.hinge { -webkit-animation-duration: 2s; -moz-animation-duration: 2s; -o-animation-duration: 2s; animation-duration: 2s; } @-webkit-keyframes flash { 0%, 50%, 100% {opacity: 1;} 25%, 75% {opacity: 0;} } @-moz-keyframes flash { 0%, 50%, 100% {opacity: 1;} 25%, 75% {opacity: 0;} } @-o-keyframes flash { 0%, 50%, 100% {opacity: 1;} 25%, 75% {opacity: 0;} } @keyframes flash { 0%, 50%, 100% {opacity: 1;} 25%, 75% {opacity: 0;} } .flash { -webkit-animation-name: flash; -moz-animation-name: flash; -o-animation-name: flash; animation-name: flash; } @-webkit-keyframes shake { 0%, 100% {-webkit-transform: translateX(0);} 10%, 30%, 50%, 70%, 90% {-webkit-transform: translateX(-10px);} 20%, 40%, 60%, 80% {-webkit-transform: translateX(10px);} } @-moz-keyframes shake { 0%, 100% {-moz-transform: translateX(0);} 10%, 30%, 50%, 70%, 90% {-moz-transform: translateX(-10px);} 20%, 40%, 60%, 80% {-moz-transform: translateX(10px);} } @-o-keyframes shake { 0%, 100% {-o-transform: translateX(0);} 10%, 30%, 50%, 70%, 90% {-o-transform: translateX(-10px);} 20%, 40%, 60%, 80% {-o-transform: translateX(10px);} } @keyframes shake { 0%, 100% {transform: translateX(0);} 10%, 30%, 50%, 70%, 90% {transform: translateX(-10px);} 20%, 40%, 60%, 80% {transform: translateX(10px);} } .shake { -webkit-animation-name: shake; -moz-animation-name: shake; -o-animation-name: shake; animation-name: shake; } @-webkit-keyframes bounce { 0%, 20%, 50%, 80%, 100% {-webkit-transform: translateY(0);} 40% {-webkit-transform: translateY(-30px);} 60% {-webkit-transform: translateY(-15px);} } @-moz-keyframes bounce { 0%, 20%, 50%, 80%, 100% {-moz-transform: translateY(0);} 40% {-moz-transform: translateY(-30px);} 60% {-moz-transform: translateY(-15px);} } @-o-keyframes bounce { 0%, 20%, 50%, 80%, 100% {-o-transform: translateY(0);} 40% {-o-transform: translateY(-30px);} 60% {-o-transform: translateY(-15px);} } @keyframes bounce { 0%, 20%, 50%, 80%, 100% {transform: translateY(0);} 40% {transform: translateY(-30px);} 60% {transform: translateY(-15px);} } .bounce { -webkit-animation-name: bounce; -moz-animation-name: bounce; -o-animation-name: bounce; animation-name: bounce; } @-webkit-keyframes tada { 0% {-webkit-transform: scale(1);} 10%, 20% {-webkit-transform: scale(0.9) rotate(-3deg);} 30%, 50%, 70%, 90% {-webkit-transform: scale(1.1) rotate(3deg);} 40%, 60%, 80% {-webkit-transform: scale(1.1) rotate(-3deg);} 100% {-webkit-transform: scale(1) rotate(0);} } @-moz-keyframes tada { 0% {-moz-transform: scale(1);} 10%, 20% {-moz-transform: scale(0.9) rotate(-3deg);} 30%, 50%, 70%, 90% {-moz-transform: scale(1.1) rotate(3deg);} 40%, 60%, 80% {-moz-transform: scale(1.1) rotate(-3deg);} 100% {-moz-transform: scale(1) rotate(0);} } @-o-keyframes tada { 0% {-o-transform: scale(1);} 10%, 20% {-o-transform: scale(0.9) rotate(-3deg);} 30%, 50%, 70%, 90% {-o-transform: scale(1.1) rotate(3deg);} 40%, 60%, 80% {-o-transform: scale(1.1) rotate(-3deg);} 100% {-o-transform: scale(1) rotate(0);} } @keyframes tada { 0% {transform: scale(1);} 10%, 20% {transform: scale(0.9) rotate(-3deg);} 30%, 50%, 70%, 90% {transform: scale(1.1) rotate(3deg);} 40%, 60%, 80% {transform: scale(1.1) rotate(-3deg);} 100% {transform: scale(1) rotate(0);} } .tada { -webkit-animation-name: tada; -moz-animation-name: tada; -o-animation-name: tada; animation-name: tada; } @-webkit-keyframes swing { 20%, 40%, 60%, 80%, 100% { -webkit-transform-origin: top center; } 20% { -webkit-transform: rotate(15deg); } 40% { -webkit-transform: rotate(-10deg); } 60% { -webkit-transform: rotate(5deg); } 80% { -webkit-transform: rotate(-5deg); } 100% { -webkit-transform: rotate(0deg); } } @-moz-keyframes swing { 20% { -moz-transform: rotate(15deg); } 40% { -moz-transform: rotate(-10deg); } 60% { -moz-transform: rotate(5deg); } 80% { -moz-transform: rotate(-5deg); } 100% { -moz-transform: rotate(0deg); } } @-o-keyframes swing { 20% { -o-transform: rotate(15deg); } 40% { -o-transform: rotate(-10deg); } 60% { -o-transform: rotate(5deg); } 80% { -o-transform: rotate(-5deg); } 100% { -o-transform: rotate(0deg); } } @keyframes swing { 20% { transform: rotate(15deg); } 40% { transform: rotate(-10deg); } 60% { transform: rotate(5deg); } 80% { transform: rotate(-5deg); } 100% { transform: rotate(0deg); } } .swing { -webkit-transform-origin: top center; -moz-transform-origin: top center; -o-transform-origin: top center; transform-origin: top center; -webkit-animation-name: swing; -moz-animation-name: swing; -o-animation-name: swing; animation-name: swing; } /* originally authored by Nick Pettit - https://github.com/nickpettit/glide */ @-webkit-keyframes wobble { 0% { -webkit-transform: translateX(0%); } 15% { -webkit-transform: translateX(-25%) rotate(-5deg); } 30% { -webkit-transform: translateX(20%) rotate(3deg); } 45% { -webkit-transform: translateX(-15%) rotate(-3deg); } 60% { -webkit-transform: translateX(10%) rotate(2deg); } 75% { -webkit-transform: translateX(-5%) rotate(-1deg); } 100% { -webkit-transform: translateX(0%); } } @-moz-keyframes wobble { 0% { -moz-transform: translateX(0%); } 15% { -moz-transform: translateX(-25%) rotate(-5deg); } 30% { -moz-transform: translateX(20%) rotate(3deg); } 45% { -moz-transform: translateX(-15%) rotate(-3deg); } 60% { -moz-transform: translateX(10%) rotate(2deg); } 75% { -moz-transform: translateX(-5%) rotate(-1deg); } 100% { -moz-transform: translateX(0%); } } @-o-keyframes wobble { 0% { -o-transform: translateX(0%); } 15% { -o-transform: translateX(-25%) rotate(-5deg); } 30% { -o-transform: translateX(20%) rotate(3deg); } 45% { -o-transform: translateX(-15%) rotate(-3deg); } 60% { -o-transform: translateX(10%) rotate(2deg); } 75% { -o-transform: translateX(-5%) rotate(-1deg); } 100% { -o-transform: translateX(0%); } } @keyframes wobble { 0% { transform: translateX(0%); } 15% { transform: translateX(-25%) rotate(-5deg); } 30% { transform: translateX(20%) rotate(3deg); } 45% { transform: translateX(-15%) rotate(-3deg); } 60% { transform: translateX(10%) rotate(2deg); } 75% { transform: translateX(-5%) rotate(-1deg); } 100% { transform: translateX(0%); } } .wobble { -webkit-animation-name: wobble; -moz-animation-name: wobble; -o-animation-name: wobble; animation-name: wobble; } /* originally authored by Nick Pettit - https://github.com/nickpettit/glide */ @-webkit-keyframes pulse { 0% { -webkit-transform: scale(1); } 50% { -webkit-transform: scale(1.1); } 100% { -webkit-transform: scale(1); } } @-moz-keyframes pulse { 0% { -moz-transform: scale(1); } 50% { -moz-transform: scale(1.1); } 100% { -moz-transform: scale(1); } } @-o-keyframes pulse { 0% { -o-transform: scale(1); } 50% { -o-transform: scale(1.1); } 100% { -o-transform: scale(1); } } @keyframes pulse { 0% { transform: scale(1); } 50% { transform: scale(1.1); } 100% { transform: scale(1); } } .pulse { -webkit-animation-name: pulse; -moz-animation-name: pulse; -o-animation-name: pulse; animation-name: pulse; } @-webkit-keyframes flip { 0% { -webkit-transform: perspective(400px) rotateY(0); -webkit-animation-timing-function: ease-out; } 40% { -webkit-transform: perspective(400px) translateZ(150px) rotateY(170deg); -webkit-animation-timing-function: ease-out; } 50% { -webkit-transform: perspective(400px) translateZ(150px) rotateY(190deg) scale(1); -webkit-animation-timing-function: ease-in; } 80% { -webkit-transform: perspective(400px) rotateY(360deg) scale(.95); -webkit-animation-timing-function: ease-in; } 100% { -webkit-transform: perspective(400px) scale(1); -webkit-animation-timing-function: ease-in; } } @-moz-keyframes flip { 0% { -moz-transform: perspective(400px) rotateY(0); -moz-animation-timing-function: ease-out; } 40% { -moz-transform: perspective(400px) translateZ(150px) rotateY(170deg); -moz-animation-timing-function: ease-out; } 50% { -moz-transform: perspective(400px) translateZ(150px) rotateY(190deg) scale(1); -moz-animation-timing-function: ease-in; } 80% { -moz-transform: perspective(400px) rotateY(360deg) scale(.95); -moz-animation-timing-function: ease-in; } 100% { -moz-transform: perspective(400px) scale(1); -moz-animation-timing-function: ease-in; } } @-o-keyframes flip { 0% { -o-transform: perspective(400px) rotateY(0); -o-animation-timing-function: ease-out; } 40% { -o-transform: perspective(400px) translateZ(150px) rotateY(170deg); -o-animation-timing-function: ease-out; } 50% { -o-transform: perspective(400px) translateZ(150px) rotateY(190deg) scale(1); -o-animation-timing-function: ease-in; } 80% { -o-transform: perspective(400px) rotateY(360deg) scale(.95); -o-animation-timing-function: ease-in; } 100% { -o-transform: perspective(400px) scale(1); -o-animation-timing-function: ease-in; } } @keyframes flip { 0% { transform: perspective(400px) rotateY(0); animation-timing-function: ease-out; } 40% { transform: perspective(400px) translateZ(150px) rotateY(170deg); animation-timing-function: ease-out; } 50% { transform: perspective(400px) translateZ(150px) rotateY(190deg) scale(1); animation-timing-function: ease-in; } 80% { transform: perspective(400px) rotateY(360deg) scale(.95); animation-timing-function: ease-in; } 100% { transform: perspective(400px) scale(1); animation-timing-function: ease-in; } } .flip { -webkit-backface-visibility: visible !important; -webkit-animation-name: flip; -moz-backface-visibility: visible !important; -moz-animation-name: flip; -o-backface-visibility: visible !important; -o-animation-name: flip; backface-visibility: visible !important; animation-name: flip; } @-webkit-keyframes flipInX { 0% { -webkit-transform: perspective(400px) rotateX(90deg); opacity: 0; } 40% { -webkit-transform: perspective(400px) rotateX(-10deg); } 70% { -webkit-transform: perspective(400px) rotateX(10deg); } 100% { -webkit-transform: perspective(400px) rotateX(0deg); opacity: 1; } } @-moz-keyframes flipInX { 0% { -moz-transform: perspective(400px) rotateX(90deg); opacity: 0; } 40% { -moz-transform: perspective(400px) rotateX(-10deg); } 70% { -moz-transform: perspective(400px) rotateX(10deg); } 100% { -moz-transform: perspective(400px) rotateX(0deg); opacity: 1; } } @-o-keyframes flipInX { 0% { -o-transform: perspective(400px) rotateX(90deg); opacity: 0; } 40% { -o-transform: perspective(400px) rotateX(-10deg); } 70% { -o-transform: perspective(400px) rotateX(10deg); } 100% { -o-transform: perspective(400px) rotateX(0deg); opacity: 1; } } @keyframes flipInX { 0% { transform: perspective(400px) rotateX(90deg); opacity: 0; } 40% { transform: perspective(400px) rotateX(-10deg); } 70% { transform: perspective(400px) rotateX(10deg); } 100% { transform: perspective(400px) rotateX(0deg); opacity: 1; } } .flipInX { -webkit-backface-visibility: visible !important; -webkit-animation-name: flipInX; -moz-backface-visibility: visible !important; -moz-animation-name: flipInX; -o-backface-visibility: visible !important; -o-animation-name: flipInX; backface-visibility: visible !important; animation-name: flipInX; } @-webkit-keyframes flipOutX { 0% { -webkit-transform: perspective(400px) rotateX(0deg); opacity: 1; } 100% { -webkit-transform: perspective(400px) rotateX(90deg); opacity: 0; } } @-moz-keyframes flipOutX { 0% { -moz-transform: perspective(400px) rotateX(0deg); opacity: 1; } 100% { -moz-transform: perspective(400px) rotateX(90deg); opacity: 0; } } @-o-keyframes flipOutX { 0% { -o-transform: perspective(400px) rotateX(0deg); opacity: 1; } 100% { -o-transform: perspective(400px) rotateX(90deg); opacity: 0; } } @keyframes flipOutX { 0% { transform: perspective(400px) rotateX(0deg); opacity: 1; } 100% { transform: perspective(400px) rotateX(90deg); opacity: 0; } } .flipOutX { -webkit-animation-name: flipOutX; -webkit-backface-visibility: visible !important; -moz-animation-name: flipOutX; -moz-backface-visibility: visible !important; -o-animation-name: flipOutX; -o-backface-visibility: visible !important; animation-name: flipOutX; backface-visibility: visible !important; } @-webkit-keyframes flipInY { 0% { -webkit-transform: perspective(400px) rotateY(90deg); opacity: 0; } 40% { -webkit-transform: perspective(400px) rotateY(-10deg); } 70% { -webkit-transform: perspective(400px) rotateY(10deg); } 100% { -webkit-transform: perspective(400px) rotateY(0deg); opacity: 1; } } @-moz-keyframes flipInY { 0% { -moz-transform: perspective(400px) rotateY(90deg); opacity: 0; } 40% { -moz-transform: perspective(400px) rotateY(-10deg); } 70% { -moz-transform: perspective(400px) rotateY(10deg); } 100% { -moz-transform: perspective(400px) rotateY(0deg); opacity: 1; } } @-o-keyframes flipInY { 0% { -o-transform: perspective(400px) rotateY(90deg); opacity: 0; } 40% { -o-transform: perspective(400px) rotateY(-10deg); } 70% { -o-transform: perspective(400px) rotateY(10deg); } 100% { -o-transform: perspective(400px) rotateY(0deg); opacity: 1; } } @keyframes flipInY { 0% { transform: perspective(400px) rotateY(90deg); opacity: 0; } 40% { transform: perspective(400px) rotateY(-10deg); } 70% { transform: perspective(400px) rotateY(10deg); } 100% { transform: perspective(400px) rotateY(0deg); opacity: 1; } } .flipInY { -webkit-backface-visibility: visible !important; -webkit-animation-name: flipInY; -moz-backface-visibility: visible !important; -moz-animation-name: flipInY; -o-backface-visibility: visible !important; -o-animation-name: flipInY; backface-visibility: visible !important; animation-name: flipInY; } @-webkit-keyframes flipOutY { 0% { -webkit-transform: perspective(400px) rotateY(0deg); opacity: 1; } 100% { -webkit-transform: perspective(400px) rotateY(90deg); opacity: 0; } } @-moz-keyframes flipOutY { 0% { -moz-transform: perspective(400px) rotateY(0deg); opacity: 1; } 100% { -moz-transform: perspective(400px) rotateY(90deg); opacity: 0; } } @-o-keyframes flipOutY { 0% { -o-transform: perspective(400px) rotateY(0deg); opacity: 1; } 100% { -o-transform: perspective(400px) rotateY(90deg); opacity: 0; } } @keyframes flipOutY { 0% { transform: perspective(400px) rotateY(0deg); opacity: 1; } 100% { transform: perspective(400px) rotateY(90deg); opacity: 0; } } .flipOutY { -webkit-backface-visibility: visible !important; -webkit-animation-name: flipOutY; -moz-backface-visibility: visible !important; -moz-animation-name: flipOutY; -o-backface-visibility: visible !important; -o-animation-name: flipOutY; backface-visibility: visible !important; animation-name: flipOutY; } @-webkit-keyframes fadeIn { 0% {opacity: 0;} 100% {opacity: 1;} } @-moz-keyframes fadeIn { 0% {opacity: 0;} 100% {opacity: 1;} } @-o-keyframes fadeIn { 0% {opacity: 0;} 100% {opacity: 1;} } @keyframes fadeIn { 0% {opacity: 0;} 100% {opacity: 1;} } .fadeIn { -webkit-animation-name: fadeIn; -moz-animation-name: fadeIn; -o-animation-name: fadeIn; animation-name: fadeIn; } @-webkit-keyframes fadeInUp { 0% { opacity: 0; -webkit-transform: translateY(20px); } 100% { opacity: 1; -webkit-transform: translateY(0); } } @-moz-keyframes fadeInUp { 0% { opacity: 0; -moz-transform: translateY(20px); } 100% { opacity: 1; -moz-transform: translateY(0); } } @-o-keyframes fadeInUp { 0% { opacity: 0; -o-transform: translateY(20px); } 100% { opacity: 1; -o-transform: translateY(0); } } @keyframes fadeInUp { 0% { opacity: 0; transform: translateY(20px); } 100% { opacity: 1; transform: translateY(0); } } .fadeInUp { -webkit-animation-name: fadeInUp; -moz-animation-name: fadeInUp; -o-animation-name: fadeInUp; animation-name: fadeInUp; } @-webkit-keyframes fadeInDown { 0% { opacity: 0; -webkit-transform: translateY(-20px); } 100% { opacity: 1; -webkit-transform: translateY(0); } } @-moz-keyframes fadeInDown { 0% { opacity: 0; -moz-transform: translateY(-20px); } 100% { opacity: 1; -moz-transform: translateY(0); } } @-o-keyframes fadeInDown { 0% { opacity: 0; -o-transform: translateY(-20px); } 100% { opacity: 1; -o-transform: translateY(0); } } @keyframes fadeInDown { 0% { opacity: 0; transform: translateY(-20px); } 100% { opacity: 1; transform: translateY(0); } } .fadeInDown { -webkit-animation-name: fadeInDown; -moz-animation-name: fadeInDown; -o-animation-name: fadeInDown; animation-name: fadeInDown; } @-webkit-keyframes fadeInLeft { 0% { opacity: 0; -webkit-transform: translateX(-20px); } 100% { opacity: 1; -webkit-transform: translateX(0); } } @-moz-keyframes fadeInLeft { 0% { opacity: 0; -moz-transform: translateX(-20px); } 100% { opacity: 1; -moz-transform: translateX(0); } } @-o-keyframes fadeInLeft { 0% { opacity: 0; -o-transform: translateX(-20px); } 100% { opacity: 1; -o-transform: translateX(0); } } @keyframes fadeInLeft { 0% { opacity: 0; transform: translateX(-20px); } 100% { opacity: 1; transform: translateX(0); } } .fadeInLeft { -webkit-animation-name: fadeInLeft; -moz-animation-name: fadeInLeft; -o-animation-name: fadeInLeft; animation-name: fadeInLeft; } @-webkit-keyframes fadeInRight { 0% { opacity: 0; -webkit-transform: translateX(20px); } 100% { opacity: 1; -webkit-transform: translateX(0); } } @-moz-keyframes fadeInRight { 0% { opacity: 0; -moz-transform: translateX(20px); } 100% { opacity: 1; -moz-transform: translateX(0); } } @-o-keyframes fadeInRight { 0% { opacity: 0; -o-transform: translateX(20px); } 100% { opacity: 1; -o-transform: translateX(0); } } @keyframes fadeInRight { 0% { opacity: 0; transform: translateX(20px); } 100% { opacity: 1; transform: translateX(0); } } .fadeInRight { -webkit-animation-name: fadeInRight; -moz-animation-name: fadeInRight; -o-animation-name: fadeInRight; animation-name: fadeInRight; } @-webkit-keyframes fadeInUpBig { 0% { opacity: 0; -webkit-transform: translateY(2000px); } 100% { opacity: 1; -webkit-transform: translateY(0); } } @-moz-keyframes fadeInUpBig { 0% { opacity: 0; -moz-transform: translateY(2000px); } 100% { opacity: 1; -moz-transform: translateY(0); } } @-o-keyframes fadeInUpBig { 0% { opacity: 0; -o-transform: translateY(2000px); } 100% { opacity: 1; -o-transform: translateY(0); } } @keyframes fadeInUpBig { 0% { opacity: 0; transform: translateY(2000px); } 100% { opacity: 1; transform: translateY(0); } } .fadeInUpBig { -webkit-animation-name: fadeInUpBig; -moz-animation-name: fadeInUpBig; -o-animation-name: fadeInUpBig; animation-name: fadeInUpBig; } @-webkit-keyframes fadeInDownBig { 0% { opacity: 0; -webkit-transform: translateY(-2000px); } 100% { opacity: 1; -webkit-transform: translateY(0); } } @-moz-keyframes fadeInDownBig { 0% { opacity: 0; -moz-transform: translateY(-2000px); } 100% { opacity: 1; -moz-transform: translateY(0); } } @-o-keyframes fadeInDownBig { 0% { opacity: 0; -o-transform: translateY(-2000px); } 100% { opacity: 1; -o-transform: translateY(0); } } @keyframes fadeInDownBig { 0% { opacity: 0; transform: translateY(-2000px); } 100% { opacity: 1; transform: translateY(0); } } .fadeInDownBig { -webkit-animation-name: fadeInDownBig; -moz-animation-name: fadeInDownBig; -o-animation-name: fadeInDownBig; animation-name: fadeInDownBig; } @-webkit-keyframes fadeInLeftBig { 0% { opacity: 0; -webkit-transform: translateX(-2000px); } 100% { opacity: 1; -webkit-transform: translateX(0); } } @-moz-keyframes fadeInLeftBig { 0% { opacity: 0; -moz-transform: translateX(-2000px); } 100% { opacity: 1; -moz-transform: translateX(0); } } @-o-keyframes fadeInLeftBig { 0% { opacity: 0; -o-transform: translateX(-2000px); } 100% { opacity: 1; -o-transform: translateX(0); } } @keyframes fadeInLeftBig { 0% { opacity: 0; transform: translateX(-2000px); } 100% { opacity: 1; transform: translateX(0); } } .fadeInLeftBig { -webkit-animation-name: fadeInLeftBig; -moz-animation-name: fadeInLeftBig; -o-animation-name: fadeInLeftBig; animation-name: fadeInLeftBig; } @-webkit-keyframes fadeInRightBig { 0% { opacity: 0; -webkit-transform: translateX(2000px); } 100% { opacity: 1; -webkit-transform: translateX(0); } } @-moz-keyframes fadeInRightBig { 0% { opacity: 0; -moz-transform: translateX(2000px); } 100% { opacity: 1; -moz-transform: translateX(0); } } @-o-keyframes fadeInRightBig { 0% { opacity: 0; -o-transform: translateX(2000px); } 100% { opacity: 1; -o-transform: translateX(0); } } @keyframes fadeInRightBig { 0% { opacity: 0; transform: translateX(2000px); } 100% { opacity: 1; transform: translateX(0); } } .fadeInRightBig { -webkit-animation-name: fadeInRightBig; -moz-animation-name: fadeInRightBig; -o-animation-name: fadeInRightBig; animation-name: fadeInRightBig; } @-webkit-keyframes fadeOut { 0% {opacity: 1;} 100% {opacity: 0;} } @-moz-keyframes fadeOut { 0% {opacity: 1;} 100% {opacity: 0;} } @-o-keyframes fadeOut { 0% {opacity: 1;} 100% {opacity: 0;} } @keyframes fadeOut { 0% {opacity: 1;} 100% {opacity: 0;} } .fadeOut { -webkit-animation-name: fadeOut; -moz-animation-name: fadeOut; -o-animation-name: fadeOut; animation-name: fadeOut; } @-webkit-keyframes fadeOutUp { 0% { opacity: 1; -webkit-transform: translateY(0); } 100% { opacity: 0; -webkit-transform: translateY(-20px); } } @-moz-keyframes fadeOutUp { 0% { opacity: 1; -moz-transform: translateY(0); } 100% { opacity: 0; -moz-transform: translateY(-20px); } } @-o-keyframes fadeOutUp { 0% { opacity: 1; -o-transform: translateY(0); } 100% { opacity: 0; -o-transform: translateY(-20px); } } @keyframes fadeOutUp { 0% { opacity: 1; transform: translateY(0); } 100% { opacity: 0; transform: translateY(-20px); } } .fadeOutUp { -webkit-animation-name: fadeOutUp; -moz-animation-name: fadeOutUp; -o-animation-name: fadeOutUp; animation-name: fadeOutUp; } @-webkit-keyframes fadeOutDown { 0% { opacity: 1; -webkit-transform: translateY(0); } 100% { opacity: 0; -webkit-transform: translateY(20px); } } @-moz-keyframes fadeOutDown { 0% { opacity: 1; -moz-transform: translateY(0); } 100% { opacity: 0; -moz-transform: translateY(20px); } } @-o-keyframes fadeOutDown { 0% { opacity: 1; -o-transform: translateY(0); } 100% { opacity: 0; -o-transform: translateY(20px); } } @keyframes fadeOutDown { 0% { opacity: 1; transform: translateY(0); } 100% { opacity: 0; transform: translateY(20px); } } .fadeOutDown { -webkit-animation-name: fadeOutDown; -moz-animation-name: fadeOutDown; -o-animation-name: fadeOutDown; animation-name: fadeOutDown; } @-webkit-keyframes fadeOutLeft { 0% { opacity: 1; -webkit-transform: translateX(0); } 100% { opacity: 0; -webkit-transform: translateX(-20px); } } @-moz-keyframes fadeOutLeft { 0% { opacity: 1; -moz-transform: translateX(0); } 100% { opacity: 0; -moz-transform: translateX(-20px); } } @-o-keyframes fadeOutLeft { 0% { opacity: 1; -o-transform: translateX(0); } 100% { opacity: 0; -o-transform: translateX(-20px); } } @keyframes fadeOutLeft { 0% { opacity: 1; transform: translateX(0); } 100% { opacity: 0; transform: translateX(-20px); } } .fadeOutLeft { -webkit-animation-name: fadeOutLeft; -moz-animation-name: fadeOutLeft; -o-animation-name: fadeOutLeft; animation-name: fadeOutLeft; } @-webkit-keyframes fadeOutRight { 0% { opacity: 1; -webkit-transform: translateX(0); } 100% { opacity: 0; -webkit-transform: translateX(20px); } } @-moz-keyframes fadeOutRight { 0% { opacity: 1; -moz-transform: translateX(0); } 100% { opacity: 0; -moz-transform: translateX(20px); } } @-o-keyframes fadeOutRight { 0% { opacity: 1; -o-transform: translateX(0); } 100% { opacity: 0; -o-transform: translateX(20px); } } @keyframes fadeOutRight { 0% { opacity: 1; transform: translateX(0); } 100% { opacity: 0; transform: translateX(20px); } } .fadeOutRight { -webkit-animation-name: fadeOutRight; -moz-animation-name: fadeOutRight; -o-animation-name: fadeOutRight; animation-name: fadeOutRight; } @-webkit-keyframes fadeOutUpBig { 0% { opacity: 1; -webkit-transform: translateY(0); } 100% { opacity: 0; -webkit-transform: translateY(-2000px); } } @-moz-keyframes fadeOutUpBig { 0% { opacity: 1; -moz-transform: translateY(0); } 100% { opacity: 0; -moz-transform: translateY(-2000px); } } @-o-keyframes fadeOutUpBig { 0% { opacity: 1; -o-transform: translateY(0); } 100% { opacity: 0; -o-transform: translateY(-2000px); } } @keyframes fadeOutUpBig { 0% { opacity: 1; transform: translateY(0); } 100% { opacity: 0; transform: translateY(-2000px); } } .fadeOutUpBig { -webkit-animation-name: fadeOutUpBig; -moz-animation-name: fadeOutUpBig; -o-animation-name: fadeOutUpBig; animation-name: fadeOutUpBig; } @-webkit-keyframes fadeOutDownBig { 0% { opacity: 1; -webkit-transform: translateY(0); } 100% { opacity: 0; -webkit-transform: translateY(2000px); } } @-moz-keyframes fadeOutDownBig { 0% { opacity: 1; -moz-transform: translateY(0); } 100% { opacity: 0; -moz-transform: translateY(2000px); } } @-o-keyframes fadeOutDownBig { 0% { opacity: 1; -o-transform: translateY(0); } 100% { opacity: 0; -o-transform: translateY(2000px); } } @keyframes fadeOutDownBig { 0% { opacity: 1; transform: translateY(0); } 100% { opacity: 0; transform: translateY(2000px); } } .fadeOutDownBig { -webkit-animation-name: fadeOutDownBig; -moz-animation-name: fadeOutDownBig; -o-animation-name: fadeOutDownBig; animation-name: fadeOutDownBig; } @-webkit-keyframes fadeOutLeftBig { 0% { opacity: 1; -webkit-transform: translateX(0); } 100% { opacity: 0; -webkit-transform: translateX(-2000px); } } @-moz-keyframes fadeOutLeftBig { 0% { opacity: 1; -moz-transform: translateX(0); } 100% { opacity: 0; -moz-transform: translateX(-2000px); } } @-o-keyframes fadeOutLeftBig { 0% { opacity: 1; -o-transform: translateX(0); } 100% { opacity: 0; -o-transform: translateX(-2000px); } } @keyframes fadeOutLeftBig { 0% { opacity: 1; transform: translateX(0); } 100% { opacity: 0; transform: translateX(-2000px); } } .fadeOutLeftBig { -webkit-animation-name: fadeOutLeftBig; -moz-animation-name: fadeOutLeftBig; -o-animation-name: fadeOutLeftBig; animation-name: fadeOutLeftBig; } @-webkit-keyframes fadeOutRightBig { 0% { opacity: 1; -webkit-transform: translateX(0); } 100% { opacity: 0; -webkit-transform: translateX(2000px); } } @-moz-keyframes fadeOutRightBig { 0% { opacity: 1; -moz-transform: translateX(0); } 100% { opacity: 0; -moz-transform: translateX(2000px); } } @-o-keyframes fadeOutRightBig { 0% { opacity: 1; -o-transform: translateX(0); } 100% { opacity: 0; -o-transform: translateX(2000px); } } @keyframes fadeOutRightBig { 0% { opacity: 1; transform: translateX(0); } 100% { opacity: 0; transform: translateX(2000px); } } .fadeOutRightBig { -webkit-animation-name: fadeOutRightBig; -moz-animation-name: fadeOutRightBig; -o-animation-name: fadeOutRightBig; animation-name: fadeOutRightBig; } @-webkit-keyframes bounceIn { 0% { opacity: 0; -webkit-transform: scale(.3); } 50% { opacity: 1; -webkit-transform: scale(1.05); } 70% { -webkit-transform: scale(.9); } 100% { -webkit-transform: scale(1); } } @-moz-keyframes bounceIn { 0% { opacity: 0; -moz-transform: scale(.3); } 50% { opacity: 1; -moz-transform: scale(1.05); } 70% { -moz-transform: scale(.9); } 100% { -moz-transform: scale(1); } } @-o-keyframes bounceIn { 0% { opacity: 0; -o-transform: scale(.3); } 50% { opacity: 1; -o-transform: scale(1.05); } 70% { -o-transform: scale(.9); } 100% { -o-transform: scale(1); } } @keyframes bounceIn { 0% { opacity: 0; transform: scale(.3); } 50% { opacity: 1; transform: scale(1.05); } 70% { transform: scale(.9); } 100% { transform: scale(1); } } .bounceIn { -webkit-animation-name: bounceIn; -moz-animation-name: bounceIn; -o-animation-name: bounceIn; animation-name: bounceIn; } @-webkit-keyframes bounceInUp { 0% { opacity: 0; -webkit-transform: translateY(2000px); } 60% { opacity: 1; -webkit-transform: translateY(-30px); } 80% { -webkit-transform: translateY(10px); } 100% { -webkit-transform: translateY(0); } } @-moz-keyframes bounceInUp { 0% { opacity: 0; -moz-transform: translateY(2000px); } 60% { opacity: 1; -moz-transform: translateY(-30px); } 80% { -moz-transform: translateY(10px); } 100% { -moz-transform: translateY(0); } } @-o-keyframes bounceInUp { 0% { opacity: 0; -o-transform: translateY(2000px); } 60% { opacity: 1; -o-transform: translateY(-30px); } 80% { -o-transform: translateY(10px); } 100% { -o-transform: translateY(0); } } @keyframes bounceInUp { 0% { opacity: 0; transform: translateY(2000px); } 60% { opacity: 1; transform: translateY(-30px); } 80% { transform: translateY(10px); } 100% { transform: translateY(0); } } .bounceInUp { -webkit-animation-name: bounceInUp; -moz-animation-name: bounceInUp; -o-animation-name: bounceInUp; animation-name: bounceInUp; } @-webkit-keyframes bounceInDown { 0% { opacity: 0; -webkit-transform: translateY(-2000px); } 60% { opacity: 1; -webkit-transform: translateY(30px); } 80% { -webkit-transform: translateY(-10px); } 100% { -webkit-transform: translateY(0); } } @-moz-keyframes bounceInDown { 0% { opacity: 0; -moz-transform: translateY(-2000px); } 60% { opacity: 1; -moz-transform: translateY(30px); } 80% { -moz-transform: translateY(-10px); } 100% { -moz-transform: translateY(0); } } @-o-keyframes bounceInDown { 0% { opacity: 0; -o-transform: translateY(-2000px); } 60% { opacity: 1; -o-transform: translateY(30px); } 80% { -o-transform: translateY(-10px); } 100% { -o-transform: translateY(0); } } @keyframes bounceInDown { 0% { opacity: 0; transform: translateY(-2000px); } 60% { opacity: 1; transform: translateY(30px); } 80% { transform: translateY(-10px); } 100% { transform: translateY(0); } } .bounceInDown { -webkit-animation-name: bounceInDown; -moz-animation-name: bounceInDown; -o-animation-name: bounceInDown; animation-name: bounceInDown; } @-webkit-keyframes bounceInLeft { 0% { opacity: 0; -webkit-transform: translateX(-2000px); } 60% { opacity: 1; -webkit-transform: translateX(30px); } 80% { -webkit-transform: translateX(-10px); } 100% { -webkit-transform: translateX(0); } } @-moz-keyframes bounceInLeft { 0% { opacity: 0; -moz-transform: translateX(-2000px); } 60% { opacity: 1; -moz-transform: translateX(30px); } 80% { -moz-transform: translateX(-10px); } 100% { -moz-transform: translateX(0); } } @-o-keyframes bounceInLeft { 0% { opacity: 0; -o-transform: translateX(-2000px); } 60% { opacity: 1; -o-transform: translateX(30px); } 80% { -o-transform: translateX(-10px); } 100% { -o-transform: translateX(0); } } @keyframes bounceInLeft { 0% { opacity: 0; transform: translateX(-2000px); } 60% { opacity: 1; transform: translateX(30px); } 80% { transform: translateX(-10px); } 100% { transform: translateX(0); } } .bounceInLeft { -webkit-animation-name: bounceInLeft; -moz-animation-name: bounceInLeft; -o-animation-name: bounceInLeft; animation-name: bounceInLeft; } @-webkit-keyframes bounceInRight { 0% { opacity: 0; -webkit-transform: translateX(2000px); } 60% { opacity: 1; -webkit-transform: translateX(-30px); } 80% { -webkit-transform: translateX(10px); } 100% { -webkit-transform: translateX(0); } } @-moz-keyframes bounceInRight { 0% { opacity: 0; -moz-transform: translateX(2000px); } 60% { opacity: 1; -moz-transform: translateX(-30px); } 80% { -moz-transform: translateX(10px); } 100% { -moz-transform: translateX(0); } } @-o-keyframes bounceInRight { 0% { opacity: 0; -o-transform: translateX(2000px); } 60% { opacity: 1; -o-transform: translateX(-30px); } 80% { -o-transform: translateX(10px); } 100% { -o-transform: translateX(0); } } @keyframes bounceInRight { 0% { opacity: 0; transform: translateX(2000px); } 60% { opacity: 1; transform: translateX(-30px); } 80% { transform: translateX(10px); } 100% { transform: translateX(0); } } .bounceInRight { -webkit-animation-name: bounceInRight; -moz-animation-name: bounceInRight; -o-animation-name: bounceInRight; animation-name: bounceInRight; } @-webkit-keyframes bounceOut { 0% { -webkit-transform: scale(1); } 25% { -webkit-transform: scale(.95); } 50% { opacity: 1; -webkit-transform: scale(1.1); } 100% { opacity: 0; -webkit-transform: scale(.3); } } @-moz-keyframes bounceOut { 0% { -moz-transform: scale(1); } 25% { -moz-transform: scale(.95); } 50% { opacity: 1; -moz-transform: scale(1.1); } 100% { opacity: 0; -moz-transform: scale(.3); } } @-o-keyframes bounceOut { 0% { -o-transform: scale(1); } 25% { -o-transform: scale(.95); } 50% { opacity: 1; -o-transform: scale(1.1); } 100% { opacity: 0; -o-transform: scale(.3); } } @keyframes bounceOut { 0% { transform: scale(1); } 25% { transform: scale(.95); } 50% { opacity: 1; transform: scale(1.1); } 100% { opacity: 0; transform: scale(.3); } } .bounceOut { -webkit-animation-name: bounceOut; -moz-animation-name: bounceOut; -o-animation-name: bounceOut; animation-name: bounceOut; } @-webkit-keyframes bounceOutUp { 0% { -webkit-transform: translateY(0); } 20% { opacity: 1; -webkit-transform: translateY(20px); } 100% { opacity: 0; -webkit-transform: translateY(-2000px); } } @-moz-keyframes bounceOutUp { 0% { -moz-transform: translateY(0); } 20% { opacity: 1; -moz-transform: translateY(20px); } 100% { opacity: 0; -moz-transform: translateY(-2000px); } } @-o-keyframes bounceOutUp { 0% { -o-transform: translateY(0); } 20% { opacity: 1; -o-transform: translateY(20px); } 100% { opacity: 0; -o-transform: translateY(-2000px); } } @keyframes bounceOutUp { 0% { transform: translateY(0); } 20% { opacity: 1; transform: translateY(20px); } 100% { opacity: 0; transform: translateY(-2000px); } } .bounceOutUp { -webkit-animation-name: bounceOutUp; -moz-animation-name: bounceOutUp; -o-animation-name: bounceOutUp; animation-name: bounceOutUp; } @-webkit-keyframes bounceOutDown { 0% { -webkit-transform: translateY(0); } 20% { opacity: 1; -webkit-transform: translateY(-20px); } 100% { opacity: 0; -webkit-transform: translateY(2000px); } } @-moz-keyframes bounceOutDown { 0% { -moz-transform: translateY(0); } 20% { opacity: 1; -moz-transform: translateY(-20px); } 100% { opacity: 0; -moz-transform: translateY(2000px); } } @-o-keyframes bounceOutDown { 0% { -o-transform: translateY(0); } 20% { opacity: 1; -o-transform: translateY(-20px); } 100% { opacity: 0; -o-transform: translateY(2000px); } } @keyframes bounceOutDown { 0% { transform: translateY(0); } 20% { opacity: 1; transform: translateY(-20px); } 100% { opacity: 0; transform: translateY(2000px); } } .bounceOutDown { -webkit-animation-name: bounceOutDown; -moz-animation-name: bounceOutDown; -o-animation-name: bounceOutDown; animation-name: bounceOutDown; } @-webkit-keyframes bounceOutLeft { 0% { -webkit-transform: translateX(0); } 20% { opacity: 1; -webkit-transform: translateX(20px); } 100% { opacity: 0; -webkit-transform: translateX(-2000px); } } @-moz-keyframes bounceOutLeft { 0% { -moz-transform: translateX(0); } 20% { opacity: 1; -moz-transform: translateX(20px); } 100% { opacity: 0; -moz-transform: translateX(-2000px); } } @-o-keyframes bounceOutLeft { 0% { -o-transform: translateX(0); } 20% { opacity: 1; -o-transform: translateX(20px); } 100% { opacity: 0; -o-transform: translateX(-2000px); } } @keyframes bounceOutLeft { 0% { transform: translateX(0); } 20% { opacity: 1; transform: translateX(20px); } 100% { opacity: 0; transform: translateX(-2000px); } } .bounceOutLeft { -webkit-animation-name: bounceOutLeft; -moz-animation-name: bounceOutLeft; -o-animation-name: bounceOutLeft; animation-name: bounceOutLeft; } @-webkit-keyframes bounceOutRight { 0% { -webkit-transform: translateX(0); } 20% { opacity: 1; -webkit-transform: translateX(-20px); } 100% { opacity: 0; -webkit-transform: translateX(2000px); } } @-moz-keyframes bounceOutRight { 0% { -moz-transform: translateX(0); } 20% { opacity: 1; -moz-transform: translateX(-20px); } 100% { opacity: 0; -moz-transform: translateX(2000px); } } @-o-keyframes bounceOutRight { 0% { -o-transform: translateX(0); } 20% { opacity: 1; -o-transform: translateX(-20px); } 100% { opacity: 0; -o-transform: translateX(2000px); } } @keyframes bounceOutRight { 0% { transform: translateX(0); } 20% { opacity: 1; transform: translateX(-20px); } 100% { opacity: 0; transform: translateX(2000px); } } .bounceOutRight { -webkit-animation-name: bounceOutRight; -moz-animation-name: bounceOutRight; -o-animation-name: bounceOutRight; animation-name: bounceOutRight; } @-webkit-keyframes rotateIn { 0% { -webkit-transform-origin: center center; -webkit-transform: rotate(-200deg); opacity: 0; } 100% { -webkit-transform-origin: center center; -webkit-transform: rotate(0); opacity: 1; } } @-moz-keyframes rotateIn { 0% { -moz-transform-origin: center center; -moz-transform: rotate(-200deg); opacity: 0; } 100% { -moz-transform-origin: center center; -moz-transform: rotate(0); opacity: 1; } } @-o-keyframes rotateIn { 0% { -o-transform-origin: center center; -o-transform: rotate(-200deg); opacity: 0; } 100% { -o-transform-origin: center center; -o-transform: rotate(0); opacity: 1; } } @keyframes rotateIn { 0% { transform-origin: center center; transform: rotate(-200deg); opacity: 0; } 100% { transform-origin: center center; transform: rotate(0); opacity: 1; } } .rotateIn { -webkit-animation-name: rotateIn; -moz-animation-name: rotateIn; -o-animation-name: rotateIn; animation-name: rotateIn; } @-webkit-keyframes rotateInUpLeft { 0% { -webkit-transform-origin: left bottom; -webkit-transform: rotate(90deg); opacity: 0; } 100% { -webkit-transform-origin: left bottom; -webkit-transform: rotate(0); opacity: 1; } } @-moz-keyframes rotateInUpLeft { 0% { -moz-transform-origin: left bottom; -moz-transform: rotate(90deg); opacity: 0; } 100% { -moz-transform-origin: left bottom; -moz-transform: rotate(0); opacity: 1; } } @-o-keyframes rotateInUpLeft { 0% { -o-transform-origin: left bottom; -o-transform: rotate(90deg); opacity: 0; } 100% { -o-transform-origin: left bottom; -o-transform: rotate(0); opacity: 1; } } @keyframes rotateInUpLeft { 0% { transform-origin: left bottom; transform: rotate(90deg); opacity: 0; } 100% { transform-origin: left bottom; transform: rotate(0); opacity: 1; } } .rotateInUpLeft { -webkit-animation-name: rotateInUpLeft; -moz-animation-name: rotateInUpLeft; -o-animation-name: rotateInUpLeft; animation-name: rotateInUpLeft; } @-webkit-keyframes rotateInDownLeft { 0% { -webkit-transform-origin: left bottom; -webkit-transform: rotate(-90deg); opacity: 0; } 100% { -webkit-transform-origin: left bottom; -webkit-transform: rotate(0); opacity: 1; } } @-moz-keyframes rotateInDownLeft { 0% { -moz-transform-origin: left bottom; -moz-transform: rotate(-90deg); opacity: 0; } 100% { -moz-transform-origin: left bottom; -moz-transform: rotate(0); opacity: 1; } } @-o-keyframes rotateInDownLeft { 0% { -o-transform-origin: left bottom; -o-transform: rotate(-90deg); opacity: 0; } 100% { -o-transform-origin: left bottom; -o-transform: rotate(0); opacity: 1; } } @keyframes rotateInDownLeft { 0% { transform-origin: left bottom; transform: rotate(-90deg); opacity: 0; } 100% { transform-origin: left bottom; transform: rotate(0); opacity: 1; } } .rotateInDownLeft { -webkit-animation-name: rotateInDownLeft; -moz-animation-name: rotateInDownLeft; -o-animation-name: rotateInDownLeft; animation-name: rotateInDownLeft; } @-webkit-keyframes rotateInUpRight { 0% { -webkit-transform-origin: right bottom; -webkit-transform: rotate(-90deg); opacity: 0; } 100% { -webkit-transform-origin: right bottom; -webkit-transform: rotate(0); opacity: 1; } } @-moz-keyframes rotateInUpRight { 0% { -moz-transform-origin: right bottom; -moz-transform: rotate(-90deg); opacity: 0; } 100% { -moz-transform-origin: right bottom; -moz-transform: rotate(0); opacity: 1; } } @-o-keyframes rotateInUpRight { 0% { -o-transform-origin: right bottom; -o-transform: rotate(-90deg); opacity: 0; } 100% { -o-transform-origin: right bottom; -o-transform: rotate(0); opacity: 1; } } @keyframes rotateInUpRight { 0% { transform-origin: right bottom; transform: rotate(-90deg); opacity: 0; } 100% { transform-origin: right bottom; transform: rotate(0); opacity: 1; } } .rotateInUpRight { -webkit-animation-name: rotateInUpRight; -moz-animation-name: rotateInUpRight; -o-animation-name: rotateInUpRight; animation-name: rotateInUpRight; } @-webkit-keyframes rotateInDownRight { 0% { -webkit-transform-origin: right bottom; -webkit-transform: rotate(90deg); opacity: 0; } 100% { -webkit-transform-origin: right bottom; -webkit-transform: rotate(0); opacity: 1; } } @-moz-keyframes rotateInDownRight { 0% { -moz-transform-origin: right bottom; -moz-transform: rotate(90deg); opacity: 0; } 100% { -moz-transform-origin: right bottom; -moz-transform: rotate(0); opacity: 1; } } @-o-keyframes rotateInDownRight { 0% { -o-transform-origin: right bottom; -o-transform: rotate(90deg); opacity: 0; } 100% { -o-transform-origin: right bottom; -o-transform: rotate(0); opacity: 1; } } @keyframes rotateInDownRight { 0% { transform-origin: right bottom; transform: rotate(90deg); opacity: 0; } 100% { transform-origin: right bottom; transform: rotate(0); opacity: 1; } } .rotateInDownRight { -webkit-animation-name: rotateInDownRight; -moz-animation-name: rotateInDownRight; -o-animation-name: rotateInDownRight; animation-name: rotateInDownRight; } @-webkit-keyframes rotateOut { 0% { -webkit-transform-origin: center center; -webkit-transform: rotate(0); opacity: 1; } 100% { -webkit-transform-origin: center center; -webkit-transform: rotate(200deg); opacity: 0; } } @-moz-keyframes rotateOut { 0% { -moz-transform-origin: center center; -moz-transform: rotate(0); opacity: 1; } 100% { -moz-transform-origin: center center; -moz-transform: rotate(200deg); opacity: 0; } } @-o-keyframes rotateOut { 0% { -o-transform-origin: center center; -o-transform: rotate(0); opacity: 1; } 100% { -o-transform-origin: center center; -o-transform: rotate(200deg); opacity: 0; } } @keyframes rotateOut { 0% { transform-origin: center center; transform: rotate(0); opacity: 1; } 100% { transform-origin: center center; transform: rotate(200deg); opacity: 0; } } .rotateOut { -webkit-animation-name: rotateOut; -moz-animation-name: rotateOut; -o-animation-name: rotateOut; animation-name: rotateOut; } @-webkit-keyframes rotateOutUpLeft { 0% { -webkit-transform-origin: left bottom; -webkit-transform: rotate(0); opacity: 1; } 100% { -webkit-transform-origin: left bottom; -webkit-transform: rotate(-90deg); opacity: 0; } } @-moz-keyframes rotateOutUpLeft { 0% { -moz-transform-origin: left bottom; -moz-transform: rotate(0); opacity: 1; } 100% { -moz-transform-origin: left bottom; -moz-transform: rotate(-90deg); opacity: 0; } } @-o-keyframes rotateOutUpLeft { 0% { -o-transform-origin: left bottom; -o-transform: rotate(0); opacity: 1; } 100% { -o-transform-origin: left bottom; -o-transform: rotate(-90deg); opacity: 0; } } @keyframes rotateOutUpLeft { 0% { transform-origin: left bottom; transform: rotate(0); opacity: 1; } 100% { transform-origin: left bottom; transform: rotate(-90deg); opacity: 0; } } .rotateOutUpLeft { -webkit-animation-name: rotateOutUpLeft; -moz-animation-name: rotateOutUpLeft; -o-animation-name: rotateOutUpLeft; animation-name: rotateOutUpLeft; } @-webkit-keyframes rotateOutDownLeft { 0% { -webkit-transform-origin: left bottom; -webkit-transform: rotate(0); opacity: 1; } 100% { -webkit-transform-origin: left bottom; -webkit-transform: rotate(90deg); opacity: 0; } } @-moz-keyframes rotateOutDownLeft { 0% { -moz-transform-origin: left bottom; -moz-transform: rotate(0); opacity: 1; } 100% { -moz-transform-origin: left bottom; -moz-transform: rotate(90deg); opacity: 0; } } @-o-keyframes rotateOutDownLeft { 0% { -o-transform-origin: left bottom; -o-transform: rotate(0); opacity: 1; } 100% { -o-transform-origin: left bottom; -o-transform: rotate(90deg); opacity: 0; } } @keyframes rotateOutDownLeft { 0% { transform-origin: left bottom; transform: rotate(0); opacity: 1; } 100% { transform-origin: left bottom; transform: rotate(90deg); opacity: 0; } } .rotateOutDownLeft { -webkit-animation-name: rotateOutDownLeft; -moz-animation-name: rotateOutDownLeft; -o-animation-name: rotateOutDownLeft; animation-name: rotateOutDownLeft; } @-webkit-keyframes rotateOutUpRight { 0% { -webkit-transform-origin: right bottom; -webkit-transform: rotate(0); opacity: 1; } 100% { -webkit-transform-origin: right bottom; -webkit-transform: rotate(90deg); opacity: 0; } } @-moz-keyframes rotateOutUpRight { 0% { -moz-transform-origin: right bottom; -moz-transform: rotate(0); opacity: 1; } 100% { -moz-transform-origin: right bottom; -moz-transform: rotate(90deg); opacity: 0; } } @-o-keyframes rotateOutUpRight { 0% { -o-transform-origin: right bottom; -o-transform: rotate(0); opacity: 1; } 100% { -o-transform-origin: right bottom; -o-transform: rotate(90deg); opacity: 0; } } @keyframes rotateOutUpRight { 0% { transform-origin: right bottom; transform: rotate(0); opacity: 1; } 100% { transform-origin: right bottom; transform: rotate(90deg); opacity: 0; } } .rotateOutUpRight { -webkit-animation-name: rotateOutUpRight; -moz-animation-name: rotateOutUpRight; -o-animation-name: rotateOutUpRight; animation-name: rotateOutUpRight; } @-webkit-keyframes rotateOutDownRight { 0% { -webkit-transform-origin: right bottom; -webkit-transform: rotate(0); opacity: 1; } 100% { -webkit-transform-origin: right bottom; -webkit-transform: rotate(-90deg); opacity: 0; } } @-moz-keyframes rotateOutDownRight { 0% { -moz-transform-origin: right bottom; -moz-transform: rotate(0); opacity: 1; } 100% { -moz-transform-origin: right bottom; -moz-transform: rotate(-90deg); opacity: 0; } } @-o-keyframes rotateOutDownRight { 0% { -o-transform-origin: right bottom; -o-transform: rotate(0); opacity: 1; } 100% { -o-transform-origin: right bottom; -o-transform: rotate(-90deg); opacity: 0; } } @keyframes rotateOutDownRight { 0% { transform-origin: right bottom; transform: rotate(0); opacity: 1; } 100% { transform-origin: right bottom; transform: rotate(-90deg); opacity: 0; } } .rotateOutDownRight { -webkit-animation-name: rotateOutDownRight; -moz-animation-name: rotateOutDownRight; -o-animation-name: rotateOutDownRight; animation-name: rotateOutDownRight; } @-webkit-keyframes hinge { 0% { -webkit-transform: rotate(0); -webkit-transform-origin: top left; -webkit-animation-timing-function: ease-in-out; } 20%, 60% { -webkit-transform: rotate(80deg); -webkit-transform-origin: top left; -webkit-animation-timing-function: ease-in-out; } 40% { -webkit-transform: rotate(60deg); -webkit-transform-origin: top left; -webkit-animation-timing-function: ease-in-out; } 80% { -webkit-transform: rotate(60deg) translateY(0); opacity: 1; -webkit-transform-origin: top left; -webkit-animation-timing-function: ease-in-out; } 100% { -webkit-transform: translateY(700px); opacity: 0; } } @-moz-keyframes hinge { 0% { -moz-transform: rotate(0); -moz-transform-origin: top left; -moz-animation-timing-function: ease-in-out; } 20%, 60% { -moz-transform: rotate(80deg); -moz-transform-origin: top left; -moz-animation-timing-function: ease-in-out; } 40% { -moz-transform: rotate(60deg); -moz-transform-origin: top left; -moz-animation-timing-function: ease-in-out; } 80% { -moz-transform: rotate(60deg) translateY(0); opacity: 1; -moz-transform-origin: top left; -moz-animation-timing-function: ease-in-out; } 100% { -moz-transform: translateY(700px); opacity: 0; } } @-o-keyframes hinge { 0% { -o-transform: rotate(0); -o-transform-origin: top left; -o-animation-timing-function: ease-in-out; } 20%, 60% { -o-transform: rotate(80deg); -o-transform-origin: top left; -o-animation-timing-function: ease-in-out; } 40% { -o-transform: rotate(60deg); -o-transform-origin: top left; -o-animation-timing-function: ease-in-out; } 80% { -o-transform: rotate(60deg) translateY(0); opacity: 1; -o-transform-origin: top left; -o-animation-timing-function: ease-in-out; } 100% { -o-transform: translateY(700px); opacity: 0; } } @keyframes hinge { 0% { transform: rotate(0); transform-origin: top left; animation-timing-function: ease-in-out; } 20%, 60% { transform: rotate(80deg); transform-origin: top left; animation-timing-function: ease-in-out; } 40% { transform: rotate(60deg); transform-origin: top left; animation-timing-function: ease-in-out; } 80% { transform: rotate(60deg) translateY(0); opacity: 1; transform-origin: top left; animation-timing-function: ease-in-out; } 100% { transform: translateY(700px); opacity: 0; } } .hinge { -webkit-animation-name: hinge; -moz-animation-name: hinge; -o-animation-name: hinge; animation-name: hinge; } /* originally authored by Nick Pettit - https://github.com/nickpettit/glide */ @-webkit-keyframes rollIn { 0% { opacity: 0; -webkit-transform: translateX(-100%) rotate(-120deg); } 100% { opacity: 1; -webkit-transform: translateX(0px) rotate(0deg); } } @-moz-keyframes rollIn { 0% { opacity: 0; -moz-transform: translateX(-100%) rotate(-120deg); } 100% { opacity: 1; -moz-transform: translateX(0px) rotate(0deg); } } @-o-keyframes rollIn { 0% { opacity: 0; -o-transform: translateX(-100%) rotate(-120deg); } 100% { opacity: 1; -o-transform: translateX(0px) rotate(0deg); } } @keyframes rollIn { 0% { opacity: 0; transform: translateX(-100%) rotate(-120deg); } 100% { opacity: 1; transform: translateX(0px) rotate(0deg); } } .rollIn { -webkit-animation-name: rollIn; -moz-animation-name: rollIn; -o-animation-name: rollIn; animation-name: rollIn; } /* originally authored by Nick Pettit - https://github.com/nickpettit/glide */ @-webkit-keyframes rollOut { 0% { opacity: 1; -webkit-transform: translateX(0px) rotate(0deg); } 100% { opacity: 0; -webkit-transform: translateX(100%) rotate(120deg); } } @-moz-keyframes rollOut { 0% { opacity: 1; -moz-transform: translateX(0px) rotate(0deg); } 100% { opacity: 0; -moz-transform: translateX(100%) rotate(120deg); } } @-o-keyframes rollOut { 0% { opacity: 1; -o-transform: translateX(0px) rotate(0deg); } 100% { opacity: 0; -o-transform: translateX(100%) rotate(120deg); } } @keyframes rollOut { 0% { opacity: 1; transform: translateX(0px) rotate(0deg); } 100% { opacity: 0; transform: translateX(100%) rotate(120deg); } } .rollOut { -webkit-animation-name: rollOut; -moz-animation-name: rollOut; -o-animation-name: rollOut; animation-name: rollOut; } /* originally authored by Angelo Rohit - https://github.com/angelorohit */ @-webkit-keyframes lightSpeedIn { 0% { -webkit-transform: translateX(100%) skewX(-30deg); opacity: 0; } 60% { -webkit-transform: translateX(-20%) skewX(30deg); opacity: 1; } 80% { -webkit-transform: translateX(0%) skewX(-15deg); opacity: 1; } 100% { -webkit-transform: translateX(0%) skewX(0deg); opacity: 1; } } @-moz-keyframes lightSpeedIn { 0% { -moz-transform: translateX(100%) skewX(-30deg); opacity: 0; } 60% { -moz-transform: translateX(-20%) skewX(30deg); opacity: 1; } 80% { -moz-transform: translateX(0%) skewX(-15deg); opacity: 1; } 100% { -moz-transform: translateX(0%) skewX(0deg); opacity: 1; } } @-o-keyframes lightSpeedIn { 0% { -o-transform: translateX(100%) skewX(-30deg); opacity: 0; } 60% { -o-transform: translateX(-20%) skewX(30deg); opacity: 1; } 80% { -o-transform: translateX(0%) skewX(-15deg); opacity: 1; } 100% { -o-transform: translateX(0%) skewX(0deg); opacity: 1; } } @keyframes lightSpeedIn { 0% { transform: translateX(100%) skewX(-30deg); opacity: 0; } 60% { transform: translateX(-20%) skewX(30deg); opacity: 1; } 80% { transform: translateX(0%) skewX(-15deg); opacity: 1; } 100% { transform: translateX(0%) skewX(0deg); opacity: 1; } } .lightSpeedIn { -webkit-animation-name: lightSpeedIn; -moz-animation-name: lightSpeedIn; -o-animation-name: lightSpeedIn; animation-name: lightSpeedIn; -webkit-animation-timing-function: ease-out; -moz-animation-timing-function: ease-out; -o-animation-timing-function: ease-out; animation-timing-function: ease-out; } .animated.lightSpeedIn { -webkit-animation-duration: 0.5s; -moz-animation-duration: 0.5s; -o-animation-duration: 0.5s; animation-duration: 0.5s; } /* originally authored by Angelo Rohit - https://github.com/angelorohit */ @-webkit-keyframes lightSpeedOut { 0% { -webkit-transform: translateX(0%) skewX(0deg); opacity: 1; } 100% { -webkit-transform: translateX(100%) skewX(-30deg); opacity: 0; } } @-moz-keyframes lightSpeedOut { 0% { -moz-transform: translateX(0%) skewX(0deg); opacity: 1; } 100% { -moz-transform: translateX(100%) skewX(-30deg); opacity: 0; } } @-o-keyframes lightSpeedOut { 0% { -o-transform: translateX(0%) skewX(0deg); opacity: 1; } 100% { -o-transform: translateX(100%) skewX(-30deg); opacity: 0; } } @keyframes lightSpeedOut { 0% { transform: translateX(0%) skewX(0deg); opacity: 1; } 100% { transform: translateX(100%) skewX(-30deg); opacity: 0; } } .lightSpeedOut { -webkit-animation-name: lightSpeedOut; -moz-animation-name: lightSpeedOut; -o-animation-name: lightSpeedOut; animation-name: lightSpeedOut; -webkit-animation-timing-function: ease-in; -moz-animation-timing-function: ease-in; -o-animation-timing-function: ease-in; animation-timing-function: ease-in; } .animated.lightSpeedOut { -webkit-animation-duration: 0.25s; -moz-animation-duration: 0.25s; -o-animation-duration: 0.25s; animation-duration: 0.25s; } /* originally authored by Angelo Rohit - https://github.com/angelorohit */ @-webkit-keyframes wiggle { 0% { -webkit-transform: skewX(9deg); } 10% { -webkit-transform: skewX(-8deg); } 20% { -webkit-transform: skewX(7deg); } 30% { -webkit-transform: skewX(-6deg); } 40% { -webkit-transform: skewX(5deg); } 50% { -webkit-transform: skewX(-4deg); } 60% { -webkit-transform: skewX(3deg); } 70% { -webkit-transform: skewX(-2deg); } 80% { -webkit-transform: skewX(1deg); } 90% { -webkit-transform: skewX(0deg); } 100% { -webkit-transform: skewX(0deg); } } @-moz-keyframes wiggle { 0% { -moz-transform: skewX(9deg); } 10% { -moz-transform: skewX(-8deg); } 20% { -moz-transform: skewX(7deg); } 30% { -moz-transform: skewX(-6deg); } 40% { -moz-transform: skewX(5deg); } 50% { -moz-transform: skewX(-4deg); } 60% { -moz-transform: skewX(3deg); } 70% { -moz-transform: skewX(-2deg); } 80% { -moz-transform: skewX(1deg); } 90% { -moz-transform: skewX(0deg); } 100% { -moz-transform: skewX(0deg); } } @-o-keyframes wiggle { 0% { -o-transform: skewX(9deg); } 10% { -o-transform: skewX(-8deg); } 20% { -o-transform: skewX(7deg); } 30% { -o-transform: skewX(-6deg); } 40% { -o-transform: skewX(5deg); } 50% { -o-transform: skewX(-4deg); } 60% { -o-transform: skewX(3deg); } 70% { -o-transform: skewX(-2deg); } 80% { -o-transform: skewX(1deg); } 90% { -o-transform: skewX(0deg); } 100% { -o-transform: skewX(0deg); } } @keyframes wiggle { 0% { transform: skewX(9deg); } 10% { transform: skewX(-8deg); } 20% { transform: skewX(7deg); } 30% { transform: skewX(-6deg); } 40% { transform: skewX(5deg); } 50% { transform: skewX(-4deg); } 60% { transform: skewX(3deg); } 70% { transform: skewX(-2deg); } 80% { transform: skewX(1deg); } 90% { transform: skewX(0deg); } 100% { transform: skewX(0deg); } } .wiggle { -webkit-animation-name: wiggle; -moz-animation-name: wiggle; -o-animation-name: wiggle; animation-name: wiggle; -webkit-animation-timing-function: ease-in; -moz-animation-timing-function: ease-in; -o-animation-timing-function: ease-in; animation-timing-function: ease-in; } .animated.wiggle { -webkit-animation-duration: 0.75s; -moz-animation-duration: 0.75s; -o-animation-duration: 0.75s; animation-duration: 0.75s; } ================================================ FILE: scripts/build.js ================================================ const fsSync = require('fs'); const fs = fsSync.promises; const path = require('path'); const browserify = require('browserify'); const exorcist = require('exorcist'); const less = require('less'); const mkdirp = require('mkdirp').mkdirp; const tsify = require('tsify'); const baseDir = path.join(__dirname, '..'); (async () => { await mkdirp(path.join(baseDir, 'public', 'css')); await mkdirp(path.join(baseDir, 'public', 'js')); const dir = await fs.readdir('components', { withFileTypes: true }); const components = dir .filter((component) => component.isDirectory()) .map((component) => component.name); // less console.log('less:common'); await lessFile( path.join(baseDir, 'public/less/styles.less'), path.join(baseDir, 'public/css/styles.css') ); console.log('less:components'); await Promise.all( components.map(async (component) => { const componentPath = path.join(baseDir, `components/${component}/${component}`); try { await fs.access(`${componentPath}.less`); } catch { /* ignore */ return; } return lessFile(`${componentPath}.less`, `${componentPath}.css`); }) ); // browserify console.log('browserify:common'); const publicSourceDir = path.join(baseDir, 'public/source'); const b = browserify(path.join(baseDir, 'public/source/main.js'), { noParse: ['dnd-page-scroll', 'jquery', 'knockout'], debug: true, }); b.require(path.join(publicSourceDir, 'components.js'), { expose: 'ungit-components' }); b.require(path.join(publicSourceDir, 'main.js'), { expose: 'ungit-main' }); b.require(path.join(publicSourceDir, 'navigation.js'), { expose: 'ungit-navigation' }); b.require(path.join(publicSourceDir, 'program-events.js'), { expose: 'ungit-program-events' }); b.require(path.join(publicSourceDir, 'storage.js'), { expose: 'ungit-storage' }); b.require(path.join(baseDir, 'source/address-parser.js'), { expose: 'ungit-address-parser' }); b.require('bluebird', { expose: 'bluebird' }); b.require('blueimp-md5', { expose: 'blueimp-md5' }); b.require('diff2html', { expose: 'diff2html' }); b.require('jquery', { expose: 'jquery' }); b.require('knockout', { expose: 'knockout' }); b.require('lodash', { expose: 'lodash' }); b.require(path.join(baseDir, 'node_modules/snapsvg/src/mina.js'), { expose: 'mina' }); b.require('moment', { expose: 'moment' }); b.require('@primer/octicons', { expose: 'octicons' }); b.require('signals', { expose: 'signals' }); b.require('winston', { expose: 'winston' }); const ungitjsFile = path.join(baseDir, 'public/js/ungit.js'); const mapFile = path.join(baseDir, 'public/js/ungit.js.map'); await new Promise((resolve) => { const outFile = fsSync.createWriteStream(ungitjsFile); outFile.on('close', () => resolve()); b.bundle().pipe(exorcist(mapFile)).pipe(outFile); }); console.log(`browserify ${path.relative(baseDir, ungitjsFile)}`); console.log('browserify:components'); for (const component of components) { console.log(`browserify:components:${component}`); const sourcePrefix = path.join(baseDir, `components/${component}/${component}`); const destination = path.join(baseDir, `components/${component}/${component}.bundle.js`); const jsSource = `${sourcePrefix}.js`; try { await fs.access(jsSource); await browserifyFile(jsSource, destination); } catch { const tsSource = `${sourcePrefix}.ts`; try { await fs.access(tsSource); await browserifyFile(tsSource, destination); } catch { console.warn( `${sourcePrefix} does not exist. If this component is obsolete, please remove that directory or perform a clean build.` ); } } } // copy console.log('copy bootstrap fonts'); await Promise.all( [ 'node_modules/bootstrap/fonts/glyphicons-halflings-regular.eot', 'node_modules/bootstrap/fonts/glyphicons-halflings-regular.svg', 'node_modules/bootstrap/fonts/glyphicons-halflings-regular.ttf', 'node_modules/bootstrap/fonts/glyphicons-halflings-regular.woff', 'node_modules/bootstrap/fonts/glyphicons-halflings-regular.woff2', ].map(async (file) => { await copyToFolder(file, 'public/fonts'); }) ); console.log('copy raven'); await Promise.all( ['node_modules/raven-js/dist/raven.min.js', 'node_modules/raven-js/dist/raven.min.js.map'].map( async (file) => { await copyToFolder(file, 'public/js'); } ) ); })(); async function lessFile(source, destination) { const input = await fs.readFile(source, { encoding: 'utf8' }); const output = await less.render(input, { filename: source, sourceMap: { outputSourceFiles: true, sourceMapURL: `${path.basename(destination)}.map`, }, }); await fs.writeFile(destination, output.css); await fs.writeFile(`${destination}.map`, output.map); console.log(`less ${path.relative(baseDir, destination)}`); } async function browserifyFile(source, destination) { const mapDestination = `${destination}.map`; await new Promise((resolve) => { const b = browserify(source, { bundleExternal: false, debug: true, }).plugin(tsify, {}); const outFile = fsSync.createWriteStream(destination); outFile.on('close', () => resolve()); b.bundle().pipe(exorcist(mapDestination)).pipe(outFile); }); console.log(`browserify ${path.relative(baseDir, destination)}`); } async function copyToFolder(source, destination) { source = path.join(baseDir, source); destination = path.join(baseDir, destination, path.basename(source)); await fs.copyFile(source, destination); console.log(`copy ${path.relative(baseDir, destination)}`); } ================================================ FILE: scripts/electronpackage.js ================================================ const process = require('process'); const path = require('path'); const fs = require('fs').promises; const electronPackager = require('electron-packager'); const baseDir = path.join(__dirname, '..'); const outDir = path.join(baseDir, 'build'); const builds = process.argv.includes('--all') // keep in sync with ci.yml (https://github.com/electron/electron-packager/blob/af334e33c9228493597afcc3931336124d6180c6/src/targets.js#L9-L14) ? { darwin: ['x64', 'arm64'], linux: ['x64', 'armv7l', 'arm64'], win32: ['ia32', 'x64', 'arm64'], } : { current: undefined }; (async () => { try { await fs.mkdir(outDir); } catch (e) { if (e.code != 'EEXIST') { throw e; } } for (const platform of Object.keys(builds)) { await electronPackager({ dir: baseDir, out: outDir, icon: path.join(baseDir, 'public/images/icon'), platform: platform == 'current' ? undefined : platform, arch: builds[platform], asar: true, overwrite: platform == 'current', appCopyright: 'Copyright (c) 2013-2026 Fredrik Norén', ignore: [ /^\/(?:[^/]+?\/)*(?:\..+|.+\.less)$/, // dot-files and less files anywhere /^\/(?:\..+|assets|clicktests|coverage|dist|scripts|test)\//, // folders in root /^\/[^/]+?\.(?:js|md|png|tgz|yml)$/, // files in root /^\/public\/(?:source|vendor)\//, // folders in /public ], }); } })(); ================================================ FILE: scripts/electronzip.js ================================================ const fsSync = require('fs'); const fs = fsSync.promises; const path = require('path'); const archiver = require('archiver'); const baseDir = path.join(__dirname, '..'); const buildDir = path.join(baseDir, 'build'); const distDir = path.join(baseDir, 'dist'); (async () => { let distFiles = []; try { distFiles = await fs.readdir(distDir); } catch { await fs.mkdir(distDir); } for (const distFile of distFiles) { await fs.unlink(path.join(distDir, distFile)); } let buildFolders; try { buildFolders = await fs.readdir(buildDir); } catch (e) { console.error('Run "npm run electronpackage" before zipping'); throw e; } return Promise.all( buildFolders.map((folder) => { const source = path.join(buildDir, folder); const destination = path.join(distDir, `${folder}.zip`); return zipDirectory(source, destination); }) ); })(); async function zipDirectory(source, destination) { console.log(`start zip ${path.relative(baseDir, destination)}`); await new Promise((resolve, reject) => { const archive = archiver('zip'); const stream = fsSync.createWriteStream(destination); archive .directory(source, path.basename(source)) .on('error', (err) => reject(err)) .pipe(stream); stream.on('close', () => resolve()); archive.finalize(); }); console.log(`finish zip ${path.relative(baseDir, destination)}`); } ================================================ FILE: scripts/npmpublish.js ================================================ const fs = require('fs').promises; const path = require('path'); module.exports = async ({ github, context, core, exec }) => { core.info('Preparing npm publish'); const hash = context.sha.substring(0, 8); const packageJson = JSON.parse(await fs.readFile('package.json', { encoding: 'utf8' })); const version = packageJson.version; const tag = `v${version}`; packageJson.version += `+${hash}`; await fs.writeFile('package.json', `${JSON.stringify(packageJson, null, 2)}\n`); core.info(`Publish ${packageJson.version} to npm`); try { if ((await exec.exec('npm publish', ['--provenance', '--access public'])) != 0) { core.info('npm publish failed.'); return; } } catch (e) { core.info(`npm publish failed: ${e}`); return; } core.info(`Creating release ${tag}`); const release = await github.rest.repos.createRelease({ owner: context.repo.owner, repo: context.repo.repo, name: tag, tag_name: tag, body: `[Changelog](https://github.com/FredrikNoren/ungit/blob/master/CHANGELOG.md#${version.replace( /\./g, '' )})`, }); const filePaths = await fs.readdir('dist'); for (const file of filePaths) { const filePath = path.join('dist', file); core.info(`Uploading release asset ${filePath}`); await github.rest.repos.uploadReleaseAsset({ owner: context.repo.owner, repo: context.repo.repo, release_id: release.data.id, name: file.replace('ungit', `ungit-${version}`), data: await fs.readFile(filePath), }); } }; ================================================ FILE: scripts/teststabilitytester.js ================================================ // This repeatedly runs the click and unit tests to verify their stability var childProcess = require('child_process'); var moment = require('moment'); var count = 0; var clickTestErrors = 0; var unitTestErrors = 0; var startTime = Date.now(); var run = function () { var testTime = Date.now(); count++; console.log('Round ' + count + '...'); childProcess.exec('npm run clicktest', function (err, stdout, stderr) { if (err) { clickTestErrors++; console.log(stdout); console.log(stderr); console.log('Clicktest failed!'); } childProcess.exec('npm run unittest', function (err, stdout, stderr) { if (err) { unitTestErrors++; console.log(stdout); console.log(stderr); console.log('Unittest failed!'); } console.log( count + ' test run, ' + clickTestErrors + ' clicktest errors (' + Math.floor((100 * clickTestErrors) / count) + '%), ' + unitTestErrors + ' unittest errors (' + Math.floor((100 * unitTestErrors) / count) + '%) ' + '(this round: ' + moment.duration(Date.now() - testTime).asSeconds() + 'sec, total: ' + moment.duration(Date.now() - startTime).humanize() + ')' ); run(); }); }); }; run(); ================================================ FILE: source/address-parser.js ================================================ 'use strict'; // USED BY FRONT END // DO NOT GO ES6 const addressWindowsLocalRegexp = /[a-zA-Z]:\\([^\\]+\\?)*/; const addressSshWithPortRegexp = /ssh:\/\/(.*):(\d*)\/(.*)/; const addressSshWithoutPortRegexp = /ssh:\/\/([^/]*)\/(.*)/; const addressGitWithoutPortWithUsernamePortRegexp = /([^@]*)@([^:]*):([^.]*)(\.git)?$/; const addressGitWithoutPortWithoutUsernameRegexp = /([^:]*):([^.]*)(\.git)?$/; const addressHttpsRegexp = /https:\/\/([^/]*)\/([^.]*)(\.git)?$/; const addressUnixLocalRegexp = /.*\/([^/]+)/; /** * Show slashes in path parameter. * * @param {string} path */ exports.encodePath = (path) => encodeURIComponent(path).replace(/%2F/g, '/'); exports.parseAddress = (remote) => { let match = addressWindowsLocalRegexp.exec(remote); if (match) { let project = match[1]; if (project[project.length - 1] == '\\') project = project.slice(0, project.length - 1); return { address: remote, host: 'localhost', project: project, shortProject: project }; } match = addressSshWithPortRegexp.exec(remote); if (match) return { address: remote, host: match[1], port: match[2], project: match[3], shortProject: match[3].split('/').pop(), }; match = addressSshWithoutPortRegexp.exec(remote); if (match) return { address: remote, host: match[1], project: match[2], shortProject: match[2].split('/').pop(), }; match = addressGitWithoutPortWithUsernamePortRegexp.exec(remote); if (match) return { address: remote, username: match[1], host: match[2], project: match[3], shortProject: match[3].split('/').pop(), }; match = addressGitWithoutPortWithoutUsernameRegexp.exec(remote); if (match) return { address: remote, host: match[1], project: match[2], shortProject: match[2].split('/').pop(), }; match = addressHttpsRegexp.exec(remote); if (match) return { address: remote, host: match[1], project: match[2], shortProject: match[2].split('/').pop(), }; match = addressUnixLocalRegexp.exec(remote); if (match) return { address: remote, host: 'localhost', project: match[1], shortProject: match[1] }; return { address: remote }; }; ================================================ FILE: source/bugtracker.js ================================================ 'use strict'; const logger = require('./utils/logger'); const sysinfo = require('./sysinfo'); const config = require('./config'); const raven = require('raven-js'); const client = new raven.Client( 'https://58f16d6f010d4c77900bb1de9c02185f:84b7432f56674fbc8522bc84cc7b30f4@app.getsentry.com/12434' ); class BugTracker { constructor(subsystem) { if (!config.bugtracking) return; this.subsystem = subsystem; this.appVersion = 'unknown'; this.userHash = sysinfo.getUserHash(); this.appVersion = config.ungitDevVersion; logger.info(`BugTracker set version: ${this.appVersion}`); } notify(exception) { if (!config.bugtracking) return; const options = { user: { id: this.userHash }, tags: { version: this.appVersion, subsystem: this.subsystem, deployment: config.desktopMode ? 'desktop' : 'web', }, }; client.captureException(exception, options); } } module.exports = BugTracker; ================================================ FILE: source/config.js ================================================ 'use strict'; const rc = require('rc'); const path = require('path'); const fs = require('fs'); const process = require('process'); const yargs = require('yargs/yargs')(process.argv.slice(2)); const homedir = require('os').homedir(); const child_process = require('child_process'); const semver = require('semver'); const defaultConfig = { // The port ungit is exposed on. port: 8448, // The base URL ungit will be accessible from. urlBase: 'http://localhost', // The URL root path under which ungit will be accesible. rootPath: '', // Directory to output log files. logDirectory: null, // Write REST requests to the log logRESTRequests: true, // Write git commands issued to the log logGitCommands: false, // Write the result of git commands issued to the log logGitOutput: false, // This will automatically send anonymous bug reports. bugtracking: false, // True to enable authentication. Users are defined in the users configuration property. authentication: false, // Map of username/passwords which are granted access. users: {}, // Set to false to show rebase and merge on drag and drop on all nodes. showRebaseAndMergeOnlyOnRefs: true, // Maximum number of concurrent git operations maxConcurrentGitOperations: 4, // Launch a browser window with ungit when ungit is started launchBrowser: true, // Instead of launching ungit with the current folder force a different path to be used. Can be set to null to force the home screen. forcedLaunchPath: undefined, // Closes the server after x ms of inactivity. Mainly used by the clicktesting. autoShutdownTimeout: undefined, // Don't fast forward git mergers. See git merge --no-ff documentation noFFMerge: true, // Automatically fetch from remote when entering a repository using ungit, periodically on activity detection, or on directory change autoFetch: true, // Used for development purposes. dev: false, // Assigns the log level. Possible values, in order from quietest to loudest, are // "none", "error", "warn", "info", "verbose", "debug", and "silly" logLevel: 'warn', // Specify a custom command to launch. `%U` will be replaced with the URL // that corresponds with the working git directory. // // NOTE: This will execute *before* opening the browser window if the // `launchBrowser` option is `true`. // Example: // # Override the browser launch command; use chrome's "app" // # argument to get a new, chromeless window for that "native feel" // $ ungit --launchBrowser=0 --launchCommand "chrome --app=%U" launchCommand: undefined, // Allow checking out nodes (which results in a detached head) allowCheckoutNodes: false, // An array of ip addresses that can connect to ungit. All others are denied. // null indicates all IPs are allowed. // Example (only allow localhost): allowedIPs: ["127.0.0.1"] allowedIPs: null, // Automatically remove remote tracking branches that have been removed on the // server when fetching. (git fetch -p) autoPruneOnFetch: true, // Directory to look for plugins pluginDirectory: path.join(homedir, '.ungit', 'plugins'), // Name-object pairs of configurations for plugins. To disable a plugin, use "disabled": true, for example: // "pluginConfigs": { "gerrit": { "disabled": true } } pluginConfigs: {}, // Don't show errors when the user is using a bad or undecidable git version gitVersionCheckOverride: false, // Don't show upgrade message when the user is using an older version of ungit ungitVersionCheckOverride: false, // Automatically does stash -> operation -> stash pop when you checkout, reset or cherry pick. This makes it // possible to perform those actions even when you have a dirty working directory. autoStashAndPop: true, fileSeparator: path.sep, // disable warning popup at discard disableDiscardWarning: false, // Duration of discard warning dialog mute time should it be muted. disableDiscardMuteTime: 60 * 1000 * 5, // 5 mins // Allowed number of retry for git "index.lock" conflict lockConflictRetryCount: 3, // Auto checkout the created branch on creation autoCheckoutOnBranchCreate: false, // Always load with active checkout branch (deprecated: use `maxActiveBranchSearchIteration`) alwaysLoadActiveBranch: false, // Max search iterations for active branch. ( value means not searching for active branch) maxActiveBranchSearchIteration: -1, // number of nodes to load for each git.log call numberOfNodesPerLoad: 25, // Specifies a custom git merge tool to use when resolving conflicts. Your git configuration must be set up to use this! // A true value will use the default tool while a string value will use the tool of that specified name. mergeTool: false, // Preferred default diff type used. Can be `"textdiff"` or `"sidebysidediff"`. diffType: undefined, // Specify whether to Ignore or Show white space diff ignoreWhiteSpaceDiff: false, // Specify tab size as number of spaces tabSize: null, // Number of refs to show on git commit bubbles to limit too many refs to appear. numRefsToShow: 5, // Force gpg sign for tags and commits. (additionally one can set up `git config commit.gpgsign true` // instead of this flag) more on this: https://help.github.com/articles/signing-commits-using-gpg/ isForceGPGSign: false, // Array of local git repo paths to display at the ungit home page defaultRepositories: [], // a string of ip to bind to, default is `127.0.0.1` ungitBindIp: '127.0.0.1', // is front end animation enabled isAnimate: true, // disable progress bar (front end api) isDisableProgressBar: false, // git binary path, not including git binary path. (i.e. /bin or /usr/bin/) gitBinPath: null, // when false, disable numstats durin status for performance. see #1193 isEnableNumStat: true, }; // Works for now but should be moved to bin/ungit const argv = yargs .usage('$0 [-v] [-b] [--cliconfigonly] [--gitVersionCheckOverride]') .example('$0 --port=8888', 'Run Ungit on port 8888') .example( '$0 --no-logRESTRequests --logGitCommands', 'Turn off REST logging but turn on git command log' ) .help('help') .version() .alias('b', 'launchBrowser') .boolean('launchBrowser') .alias('h', 'help') .alias('o', 'gitVersionCheckOverride') .boolean('gitVersionCheckOverride') .alias('v', 'version') .describe( 'o', 'Ignore git version check and allow ungit to run with possibly lower versions of git' ) .boolean('o') .describe('ungitVersionCheckOverride', 'Ignore check for older version of ungit') .boolean('ungitVersionCheckOverride') .describe( 'b', 'Launch a browser window with ungit when the ungit server is started. --no-b or --no-launchBrowser disables this' ) .boolean('b') .describe( 'cliconfigonly', 'Ignore the default configuration points and only use parameters sent on the command line' ) .boolean('cliconfigonly') .describe('port', 'The port ungit is exposed on') .describe('urlBase', 'The base URL ungit will be accessible from') .describe('rootPath', 'The root path ungit will be accessible from') .describe('logDirectory', 'Directory to output log files') .describe('logRESTRequests', 'Write REST requests to the log') .boolean('logRESTRequests') .describe('logGitCommands', 'Write git commands issued to the log') .boolean('logGitCommands') .describe('logGitOutput', 'Write the result of git commands issued to the log') .boolean('logGitOutput') .describe('bugtracking', 'This will automatically send anonymous bug reports') .boolean('bugtracking') .describe( 'authentication', 'True to enable authentication. Users are defined in the users configuration property' ) .boolean('authentication') .describe('users', 'Map of username/passwords which are granted access') .describe( 'showRebaseAndMergeOnlyOnRefs', 'Set to false to show rebase and merge on drag and drop on all nodes' ) .boolean('showRebaseAndMergeOnlyOnRefs') .describe('maxConcurrentGitOperations', 'Maximum number of concurrent git operations') .describe( 'forcedLaunchPath', 'Define path to be used on open. Can be set to null to force the home screen' ) .describe( 'autoShutdownTimeout', 'Closes the server after x ms of inactivity. Mainly used by the clicktesting' ) .describe('noFFMerge', "Don't fast forward git mergers. See git merge --no-ff documentation") .boolean('noFFMerge') .describe( 'autoFetch', 'Automatically fetch from remote when entering a repository using ungit, periodically on activity detection, or on directory change' ) .boolean('autoFetch') .describe('dev', 'Used for development purposes') .boolean('dev') .describe( 'logLevel', 'The logging level, possible values are none, error, warn, info, verbose, debug, and silly.' ) .describe( 'launchCommand', 'Specify a custom command to launch. `%U` will be replaced with the URL that corresponds with the working git directory.' ) .describe('allowCheckoutNodes', 'Allow checking out nodes (which results in a detached head)') .boolean('allowCheckoutNodes') .describe( 'allowedIPs', 'An array of ip addresses that can connect to ungit. All others are denied' ) .describe( 'autoPruneOnFetch', 'Automatically remove remote tracking branches that have been removed on the server when fetching. (git fetch -p)' ) .boolean('autoPruneOnFetch') .describe('pluginDirectory', 'Directory to look for plugins') // --pluginConfigs doesn't work... Probably only works in .ungitrc as a json file .describe( 'pluginConfigs', 'No supported as a command line argument, use ungitrc config file. See README.md' ) .describe('autoStashAndPop', 'Used for development purposes') .boolean('autoStashAndPop') .describe('fileSeparator', 'OS dependent file separator') .describe('disableDiscardWarning', 'disable warning popup at discard') .boolean('disableDiscardWarning') .describe( 'disableDiscardMuteTime', 'duration of discard warning dialog mute time should it be muted' ) .describe('lockConflictRetryCount', 'Allowed number of retry for git "index.lock" conflict') .describe('autoCheckoutOnBranchCreate', 'Auto checkout the created branch on creation') .boolean('autoCheckoutOnBranchCreate') .describe( 'alwaysLoadActiveBranch', 'Always load with active checkout branch (DEPRECATED, use `maxActiveBranchSearchIteration`)' ) .boolean('alwaysLoadActiveBranch') .describe( 'maxActiveBranchSearchIteration', 'Max search iterations for active branch. (-1 means not searching for active branch)' ) .describe('numberOfNodesPerLoad', 'number of nodes to load for each git.log call') .describe('mergeTool', 'the git merge tool to use when resolving conflicts') .describe( 'diffType', 'Prefered default diff type used. Can be `"textdiff"` or `"sidebysidediff"`.' ) .describe('ignoreWhiteSpaceDiff', 'Specify whether to Ignore or Show white space diff') .boolean('ignoreWhiteSpaceDiff') .describe( 'numRefsToShow', 'Number of refs to show on git commit bubbles to limit too many refs to appear.' ) .describe('tabSize', 'Specify tab size as number of spaces') .describe('isForceGPGSign', 'Force gpg sign for tags and commits.') .boolean('isForceGPGSign') .describe( 'defaultRepositories', 'Array of local git repo paths to display at the ungit home page' ) .describe('ungitBindIp', 'a string of ip to bind to, default is `127.0.0.1`') .describe('isAnimate', 'is front end animation enabled') .boolean('isAnimate') .describe('isDisableProgressBar', 'disable progress bar (front end api)') .boolean('isDisableProgressBar') .describe( 'gitBinPath', 'git binary path, not including git binary path. (i.e. /bin or /usr/bin/)' ) .describe( 'isEnableNumStat', 'when false, disables numstats during git status for performance. see #1193' ) .boolean('isEnableNumStat'); const argvConfig = argv.argv; // When ungit is started normally, $0 == ungit, and non-hyphenated options exists, show help and exit. if (argvConfig.$0.endsWith('ungit') && argvConfig._ && argvConfig._.length > 0) { yargs.showHelp(); process.exit(1); } let rcConfig = {}; if (!argvConfig.cliconfigonly) { try { rcConfig = rc('ungit'); // rc return additional options that must be ignored delete rcConfig['config']; delete rcConfig['configs']; } catch (err) { console.error(`Stop at reading ~/.ungitrc because ${err}`); throw err; } } module.exports = argv.default(defaultConfig).default(rcConfig).argv; module.exports.homedir = homedir; let currentRootPath = module.exports.rootPath; if (typeof currentRootPath !== 'string') { currentRootPath = ''; } else if (currentRootPath !== '') { // must start with a slash if (currentRootPath.charAt(0) !== '/') { currentRootPath = '/' + currentRootPath; } // can not end with a trailing slash if (currentRootPath.charAt(currentRootPath.length - 1) === '/') { currentRootPath = currentRootPath.substring(0, currentRootPath.length - 1); } } module.exports.rootPath = currentRootPath; // Errors can not be serialized with JSON.stringify without this fix // http://stackoverflow.com/a/18391400 Object.defineProperty(Error.prototype, 'toJSON', { value: function () { const alt = {}; Object.getOwnPropertyNames(this).forEach((key) => { alt[key] = this[key]; }); return alt; }, configurable: true, }); try { module.exports.gitVersion = /.*?(\d+[.]\d+[.]\d+).*/.exec( child_process.execSync('git --version').toString() )[1]; } catch (e) { console.error( 'Can\'t run "git --version". Is git installed and available in your path?', e.stderr ); throw e; } module.exports.ungitPackageVersion = require('../package.json').version; let devVersion = module.exports.ungitPackageVersion; if (fs.existsSync(path.join(__dirname, '..', '.git'))) { const revision = child_process .execSync('git rev-parse --short HEAD', { cwd: path.join(__dirname, '..') }) .toString() .replace('\n', ' ') .trim(); devVersion = `dev-${module.exports.ungitPackageVersion}-${revision}`; } module.exports.ungitDevVersion = devVersion; if (module.exports.alwaysLoadActiveBranch) { module.exports.maxActiveBranchSearchIteration = 25; } module.exports.isGitOptionalLocks = semver.satisfies(module.exports.gitVersion, '2.15.0'); if (argvConfig.$0.endsWith('mocha')) { console.warn('Running mocha test run, overriding few test variables...'); module.exports.logLevel = 'debug'; module.exports.dev = true; } ================================================ FILE: source/git-api.js ================================================ const path = require('path'); const temp = require('temp'); const gitParser = require('./git-parser'); const logger = require('./utils/logger'); const os = require('os'); const mkdirp = require('mkdirp').mkdirp; const rimraf = require('rimraf').rimraf; const _ = require('lodash'); const gitPromise = require('./git-promise'); const fs = require('fs').promises; const chokidar = require('chokidar'); const ignore = require('ignore'); const { EventEmitter } = require('events'); const tenMinTimeoutMs = 10 * 60 * 1000; exports.pathPrefix = ''; exports.registerApi = (env) => { const app = env.app; const ensureAuthenticated = env.ensureAuthenticated || ((req, res, next) => next()); const config = env.config; const io = env.socketIO; const socketsById = env.socketsById || {}; if (config.dev) temp.track(); if (io) { io.on('connection', (socket) => { socket.on('disconnect', () => { stopDirectoryWatch(socket); }); socket.on('watch', async (data) => { stopDirectoryWatch(socket); // clean possibly lingering connections socket.watcherPath = path.normalize(data.path); socket.join(socket.watcherPath); // join room for this path const watcher = await watchRepo(socket.watcherPath); watcher.on('workdir', (changedPath) => { logger.info(`${changedPath} triggered workdir refresh for ${socket.watcherPath}`); emitWorkingTreeChanged(socket.watcherPath); }); watcher.on('git', (changedPath) => { logger.info(`${changedPath} triggered git refresh for ${socket.watcherPath}`); emitGitDirectoryChanged(socket.watcherPath); }); watcher.on('error', (err) => { logger.warn(`Error watching ${socket.watcherPath}: `, JSON.stringify(err)); }); socket.watcher = watcher; }); }); } let watcherId = 1; class RepoWatcher extends EventEmitter { constructor() { super(); this.watcherId = watcherId++; this.watchers = []; } async watchItem(name, item, filter) { if ((await fs.access(item).catch(() => false)) === false) { logger.debug(`[${this.watcherId}] path does not exist`, item); return; } const watcher = chokidar.watch(item, { ignored: filter }); const changed = (changedPath) => { logger.silly(`[${this.watcherId}] ${name}`, changedPath); this.emit(name, changedPath); }; watcher.on('change', changed); watcher.on('add', changed); watcher.on('unlink', changed); watcher.on('addDir', changed); watcher.on('unlinkDir', changed); this.watchers.push(watcher); } addWorkdir(item, filter) { return this.watchItem('workdir', item, filter); } addGit(item, filter) { return this.watchItem('git', item, filter); } close() { this.watchers.forEach((w) => w.close()); } } const readIgnore = async (pathToWatch) => { logger.debug(`Parsing .gitignore for ${pathToWatch}`); const out = ignore(); const ignoreContent = await fs .readFile(path.join(pathToWatch, '.gitignore'), { encoding: 'utf8' }) .catch(() => null); if (ignoreContent) out.add(ignoreContent); return out; }; // TODO move to nodegit const watchRepo = async (pathToWatch) => { logger.info(`Start watching ${pathToWatch}`); const watcher = new RepoWatcher(); let repoPath = path.join(pathToWatch, '.git'); if ((await fs.access(repoPath).catch(() => false)) === undefined) { // Looks like a repo, let's watch workdir let gitIgnore = await readIgnore(pathToWatch); await watcher.addWorkdir(pathToWatch, (watch_path) => { const ignore = true, watch = false; const filePath = path.relative(pathToWatch, watch_path); if (!filePath) return watch; // root if (filePath === '.gitignore') { readIgnore(pathToWatch).then( (ign) => (gitIgnore = ign), (err) => logger.error('Could not parse .gitignore for', pathToWatch, err) ); } // We monitor the repo separately if (filePath === '.git' || filePath.startsWith('.git' + path.sep)) return ignore; // We add / to test for directories, we can't have a file named like a directory // and otherwise directory `foo` won't match ignore `foo/` if (gitIgnore.ignores(filePath) || gitIgnore.ignores(`${filePath}/`)) { // TODO https://github.com/kaelzhang/node-ignore/issues/78 // optimization: assume these are permanent skips if (filePath.includes('node_modules')) return ignore; return ignore; } return watch; }); } else { // Could be bare repoPath = pathToWatch; } // Here we watch the git state await watcher.addGit(path.join(repoPath, 'refs'), (watch_path) => { const ignore = true, watch = false; if (watch_path.endsWith('.lock')) { return ignore; } return watch; }); await watcher.addGit(path.join(repoPath, 'HEAD')); await watcher.addGit(path.join(repoPath, 'index')); return watcher; }; const stopDirectoryWatch = (socket) => { if (!socket.watcherPath) return; logger.info(`Stop watching ${socket.watcherPath}`); socket.leave(socket.watcherPath); socket.watcherPath = undefined; socket.ignore = undefined; socket.watcher && socket.watcher.close(); }; const ensurePathExists = (req, res, next) => { fs.access(req.query.path || req.body.path) .then(() => { next(); }) .catch(() => { res.status(400).json({ error: `'No such path: ${path}`, errorCode: 'no-such-path' }); }); }; const ensureValidSocketId = (req, res, next) => { const socketId = req.query.socketId || req.body.socketId; if (socketId == 'ignore') return next(); // Used in unit tests const socket = socketsById[socketId]; if (!socket) { res .status(400) .json({ error: `No such socket: ${socketId}`, errorCode: 'invalid-socket-id' }); } else { next(); } }; const emitWorkingTreeChanged = _.debounce( (repoPath) => { if (io && repoPath) { io.in(path.normalize(repoPath)).emit('working-tree-changed', { repository: repoPath }); logger.info('emitting working-tree-changed to sockets, manually triggered'); } }, 500, { maxWait: 1000 } ); const emitGitDirectoryChanged = _.debounce( (repoPath) => { if (io && repoPath) { io.in(path.normalize(repoPath)).emit('git-directory-changed', { repository: repoPath }); logger.info('emitting git-directory-changed to sockets, manually triggered'); } }, 500, { maxWait: 1000 } ); const autoStashExecuteAndPop = (commands, repoPath, allowedCodes, outPipe, inPipe, timeout) => { if (config.autoStashAndPop) { return gitPromise.stashExecuteAndPop( commands, repoPath, allowedCodes, outPipe, inPipe, timeout ); } else { return gitPromise(commands, repoPath, allowedCodes, outPipe, inPipe, timeout); } }; const jsonResultOrFailProm = (res, promise) => { return promise .then((result) => { res.json(result || {}); }) .catch((err) => { logger.warn('Responding with ERROR: ', JSON.stringify(err)); res.status(400).json(err); }); }; const credentialsOption = (socketId, remote) => { let portAndRootPath = `${config.port}`; if (config.rootPath) { portAndRootPath = `${config.port}${config.rootPath}`; } const credentialsHelperPath = path .resolve(__dirname, '..', 'bin', 'credentials-helper') .replace(/\\/g, '/'); return [ '-c', `credential.helper=${credentialsHelperPath} ${socketId} ${portAndRootPath} ${remote}`, ]; }; const getNumber = (value, nullValue) => { const finalValue = parseInt(value ? value : nullValue); if (finalValue || finalValue === 0) { return finalValue; } else { throw { error: 'invalid number' }; } }; app.get(`${exports.pathPrefix}/status`, ensureAuthenticated, ensurePathExists, (req, res) => { jsonResultOrFailProm(res, gitPromise.status(req.query.path, null)); }); app.post(`${exports.pathPrefix}/init`, ensureAuthenticated, ensurePathExists, (req, res) => { jsonResultOrFailProm( res, gitPromise(req.body.bare ? ['init', '--bare', '--shared'] : ['init'], req.body.path) ); }); app.post( `${exports.pathPrefix}/clone`, ensureAuthenticated, ensurePathExists, ensureValidSocketId, (req, res) => { // Default timeout is 2min but clone can take much longer than that (allows up to 2h) const timeoutMs = 2 * 60 * 60 * 1000; if (res.setTimeout) res.setTimeout(timeoutMs); let url = req.body.url.trim(); if (url.indexOf('git clone ') == 0) url = url.slice('git clone '.length); const commands = ['clone', url, req.body.destinationDir.trim()]; if (req.body.isRecursiveSubmodule) { commands.push('--recurse-submodules'); } const task = gitPromise({ commands: credentialsOption(req.body.socketId, url).concat(commands), repoPath: req.body.path, timeout: timeoutMs, }).then(() => { return { path: path.resolve(req.body.path, req.body.destinationDir) }; }); jsonResultOrFailProm(res, task).finally(emitGitDirectoryChanged.bind(null, req.body.path)); } ); app.get( `${exports.pathPrefix}/fetch`, ensureAuthenticated, ensurePathExists, ensureValidSocketId, (req, res) => { // Allow a little longer timeout on fetch (10min) if (res.setTimeout) res.setTimeout(tenMinTimeoutMs); const task = gitPromise({ commands: credentialsOption(req.query.socketId, req.query.remote).concat([ 'fetch', config.autoPruneOnFetch ? '--prune' : '', '--', req.query.remote, req.query.ref ? req.query.ref : '', ]), repoPath: req.query.path, timeout: tenMinTimeoutMs, }); jsonResultOrFailProm(res, task).finally(emitGitDirectoryChanged.bind(null, req.query.path)); } ); app.post( `${exports.pathPrefix}/push`, ensureAuthenticated, ensurePathExists, ensureValidSocketId, (req, res) => { // Allow a little longer timeout on push (10min) if (res.setTimeout) res.setTimeout(tenMinTimeoutMs); const task = gitPromise({ commands: credentialsOption(req.body.socketId, req.body.remote).concat([ 'push', req.body.remote, (req.body.refSpec ? req.body.refSpec : 'HEAD') + (req.body.remoteBranch ? `:${req.body.remoteBranch}` : ''), req.body.force ? '-f' : '', ]), repoPath: req.body.path, timeout: tenMinTimeoutMs, }); jsonResultOrFailProm(res, task).finally(emitGitDirectoryChanged.bind(null, req.body.path)); } ); app.post(`${exports.pathPrefix}/reset`, ensureAuthenticated, ensurePathExists, (req, res) => { jsonResultOrFailProm( res, autoStashExecuteAndPop(['reset', `--${req.body.mode}`, req.body.to], req.body.path) ) .then(emitGitDirectoryChanged.bind(null, req.body.path)) .then(emitWorkingTreeChanged.bind(null, req.body.path)); }); app.get(`${exports.pathPrefix}/diff`, ensureAuthenticated, ensurePathExists, (req, res) => { const isIgnoreWhiteSpace = req.query.whiteSpace === 'true' ? true : false; jsonResultOrFailProm( res, gitPromise.diffFile( req.query.path, req.query.file, req.query.oldFile, req.query.sha1, isIgnoreWhiteSpace ) ); }); app.get(`${exports.pathPrefix}/diff/image`, ensureAuthenticated, ensurePathExists, (req, res) => { res.type(path.extname(req.query.filename)); if (req.query.version !== 'current') { gitPromise.binaryFileContent(req.query.path, req.query.filename, req.query.version, res); } else { res.sendFile(path.join(req.query.path, req.query.filename)); } }); app.post( `${exports.pathPrefix}/discardchanges`, ensureAuthenticated, ensurePathExists, (req, res) => { const task = req.body.all ? gitPromise.discardAllChanges(req.body.path) : gitPromise.discardChangesInFile(req.body.path, req.body.file.trim()); jsonResultOrFailProm(res, task.then(emitWorkingTreeChanged.bind(null, req.body.path))); } ); app.post( `${exports.pathPrefix}/ignorefile`, ensureAuthenticated, ensurePathExists, (req, res) => { const currentPath = req.body.path.trim(); const gitIgnoreFile = `${currentPath}/.gitignore`; const ignoreFile = req.body.file.trim(); const task = fs.appendFile(gitIgnoreFile, os.EOL + ignoreFile).catch(() => { throw { errorCode: 'error-appending-ignore', error: 'Error while appending to .gitignore file.', }; }); jsonResultOrFailProm(res, task).finally(emitWorkingTreeChanged.bind(null, req.body.path)); } ); app.post(`${exports.pathPrefix}/commit`, ensureAuthenticated, ensurePathExists, (req, res) => { jsonResultOrFailProm( res, gitPromise.commit( req.body.path, req.body.amend, req.body.emptyCommit, req.body.message, req.body.files ) ) .then(emitGitDirectoryChanged.bind(null, req.body.path)) .then(emitWorkingTreeChanged.bind(null, req.body.path)); }); app.post(`${exports.pathPrefix}/revert`, ensureAuthenticated, ensurePathExists, (req, res) => { const task = gitPromise(['revert', req.body.commit], req.body.path).catch((e) => { if (e.message.indexOf('is a merge but no -m option was given.') > 0) { return gitPromise(['revert', '-m', 1, req.body.commit], req.body.path); } else { throw e; } }); jsonResultOrFailProm(res, task) .finally(emitGitDirectoryChanged.bind(null, req.body.path)) .finally(emitWorkingTreeChanged.bind(null, req.body.path)); }); app.get(`${exports.pathPrefix}/gitlog`, ensureAuthenticated, ensurePathExists, (req, res) => { const limit = getNumber(req.query.limit, config.numberOfNodesPerLoad || 25); const skip = getNumber(req.query.skip, 0); const task = gitPromise .log(req.query.path, limit, skip, config.maxActiveBranchSearchIteration) .catch((err) => { if ( err.errorCode === 'no-head' || err.errorCode === 'no-commits' || err.errorCode === 'not-a-repository' ) return { limit: limit, skip: skip, nodes: [] }; throw err; }); jsonResultOrFailProm(res, task); }); app.get(`${exports.pathPrefix}/show`, ensureAuthenticated, (req, res) => { jsonResultOrFailProm( res, gitPromise(['show', '--numstat', '-z', req.query.sha1], req.query.path).then( gitParser.parseGitLog ) ); }); app.get(`${exports.pathPrefix}/head`, ensureAuthenticated, ensurePathExists, (req, res) => { const task = gitPromise( ['log', '--decorate=full', '--pretty=fuller', '-z', '--parents', '--max-count=1'], req.query.path ) .then(gitParser.parseGitLog) .catch((err) => { if ( err.errorCode === 'no-head' || err.errorCode === 'no-commits' || err.errorCode === 'not-a-repository' ) return []; throw err; }); jsonResultOrFailProm(res, task); }); app.get(`${exports.pathPrefix}/refs`, ensureAuthenticated, ensurePathExists, (req, res) => { if (res.setTimeout) res.setTimeout(tenMinTimeoutMs); let task = Promise.resolve(); if (req.query.remoteFetch) { task = task.then(() => gitPromise(['remote'], req.query.path).then((remoteText) => { const remotes = remoteText.trim().split('\n'); // making calls serially as credential helpers may get confused to which cred to get. return remotes.reduce((promise, remote) => { if (!remote || remote === '') return promise; return promise.then(() => { return gitPromise({ commands: credentialsOption(req.query.socketId, remote).concat(['fetch', remote]), repoPath: req.query.path, timeout: tenMinTimeoutMs, }).catch((e) => logger.warn('err during remote fetch for /refs', e)); // ignore fetch err as it is most likely credential }); }, Promise.resolve()); }) ); } task = task .then(() => gitPromise(['show-ref', '-d'], req.query.path)) // On new fresh repos, empty string is returned but has status code of error, simply ignoring them .catch((e) => { if (e.message !== '') throw e; }) .then((refs) => { const results = []; if (refs) { refs .trim() .split('\n') .forEach((n) => { const splitted = n.split(' '); const sha1 = splitted[0]; const name = splitted[1]; if (name.indexOf('refs/tags') > -1 && name.indexOf('^{}') > -1) { results[results.length - 1].sha1 = sha1; } else { results.push({ name: name, sha1: sha1, }); } }); } return results; }); jsonResultOrFailProm(res, task); }); app.get(`${exports.pathPrefix}/branches`, ensureAuthenticated, ensurePathExists, (req, res) => { const isLocalBranchOnly = req.query.isLocalBranchOnly == 'false'; jsonResultOrFailProm( res, gitPromise(['branch', isLocalBranchOnly ? '-a' : ''], req.query.path).then( gitParser.parseGitBranches ) ); }); app.post(`${exports.pathPrefix}/branches`, ensureAuthenticated, ensurePathExists, (req, res) => { const commands = [ 'branch', req.body.force ? '-f' : '', req.body.name.trim(), (req.body.sha1 || 'HEAD').trim(), ]; jsonResultOrFailProm(res, gitPromise(commands, req.body.path)).finally( emitGitDirectoryChanged.bind(null, req.body.path) ); }); app.delete( `${exports.pathPrefix}/branches`, ensureAuthenticated, ensurePathExists, (req, res) => { jsonResultOrFailProm( res, gitPromise(['branch', '-D', req.query.name.trim()], req.query.path) ).finally(emitGitDirectoryChanged.bind(null, req.query.path)); } ); app.delete( `${exports.pathPrefix}/remote/branches`, ensureAuthenticated, ensurePathExists, ensureValidSocketId, (req, res) => { const commands = credentialsOption(req.query.socketId, req.query.remote).concat([ 'push', req.query.remote, `:${req.query.name.trim()}`, ]); const task = gitPromise(commands, req.query.path).catch((err) => { if (!(err.stderr && err.stderr.indexOf('remote ref does not exist') > -1)) { throw err; } }); jsonResultOrFailProm(res, task).finally(emitGitDirectoryChanged.bind(null, req.query.path)); } ); app.get(`${exports.pathPrefix}/tags`, ensureAuthenticated, ensurePathExists, (req, res) => { const task = gitPromise(['tag', '-l'], req.query.path).then(gitParser.parseGitTags); jsonResultOrFailProm(res, task); }); app.get( `${exports.pathPrefix}/remote/tags`, ensureAuthenticated, ensurePathExists, ensureValidSocketId, (req, res) => { const task = gitPromise( credentialsOption(req.query.socketId, req.query.remote).concat([ 'ls-remote', '--tags', req.query.remote, ]), req.query.path ) .then(gitParser.parseGitLsRemote) .then((result) => { result.forEach((r) => { r.remote = req.query.remote; }); return result; }); jsonResultOrFailProm(res, task); } ); app.post(`${exports.pathPrefix}/tags`, ensureAuthenticated, ensurePathExists, (req, res) => { const annotateFlag = config.isForceGPGSign ? '-s' : '-a'; const forceFlag = req.body.force ? '-f' : ''; const sha1 = (req.body.sha1 || 'HEAD').trim(); const commands = [ 'tag', forceFlag, annotateFlag, req.body.name.trim(), '-m', req.body.name.trim(), sha1, ]; jsonResultOrFailProm(res, gitPromise(commands, req.body.path)).finally( emitGitDirectoryChanged.bind(null, req.body.path) ); }); app.delete(`${exports.pathPrefix}/tags`, ensureAuthenticated, ensurePathExists, (req, res) => { jsonResultOrFailProm( res, gitPromise(['tag', '-d', req.query.name.trim()], req.query.path) ).finally(emitGitDirectoryChanged.bind(null, req.query.path)); }); app.delete( `${exports.pathPrefix}/remote/tags`, ensureAuthenticated, ensurePathExists, (req, res) => { const commands = credentialsOption(req.query.socketId, req.query.remote).concat([ 'push', req.query.remote, `:refs/tags/${req.query.name.trim()}`, ]); const task = gitPromise(['tag', '-d', req.query.name.trim()], req.query.path) .catch(() => {}) // might have already deleted, so ignoring error .then(() => gitPromise(commands, req.query.path)); jsonResultOrFailProm(res, task).finally(emitGitDirectoryChanged.bind(null, req.query.path)); } ); app.post(`${exports.pathPrefix}/checkout`, ensureAuthenticated, ensurePathExists, (req, res) => { const arg = req.body.sha1 ? ['checkout', '-b', req.body.name.trim(), req.body.sha1] : ['checkout', req.body.name.trim()]; jsonResultOrFailProm(res, autoStashExecuteAndPop(arg, req.body.path)) .then(emitGitDirectoryChanged.bind(null, req.body.path)) .then(emitWorkingTreeChanged.bind(null, req.body.path)); }); app.post( `${exports.pathPrefix}/cherrypick`, ensureAuthenticated, ensurePathExists, (req, res) => { jsonResultOrFailProm( res, autoStashExecuteAndPop(['cherry-pick', req.body.name.trim()], req.body.path) ) .then(emitGitDirectoryChanged.bind(null, req.body.path)) .then(emitWorkingTreeChanged.bind(null, req.body.path)); } ); app.get(`${exports.pathPrefix}/checkout`, ensureAuthenticated, ensurePathExists, (req, res) => { jsonResultOrFailProm(res, gitPromise.getCurrentBranch(req.query.path)); }); app.get(`${exports.pathPrefix}/remotes`, ensureAuthenticated, ensurePathExists, (req, res) => { jsonResultOrFailProm( res, gitPromise(['remote', '-v'], req.query.path).then(gitParser.parseGitRemotes) ); }); app.get( `${exports.pathPrefix}/remotes/:name`, ensureAuthenticated, ensurePathExists, (req, res) => { jsonResultOrFailProm(res, gitPromise.getRemoteAddress(req.query.path, req.params.name)); } ); app.post( `${exports.pathPrefix}/remotes/:name`, ensureAuthenticated, ensurePathExists, (req, res) => { jsonResultOrFailProm( res, gitPromise(['remote', 'add', req.params.name, req.body.url], req.body.path) ); } ); app.delete( `${exports.pathPrefix}/remotes/:name`, ensureAuthenticated, ensurePathExists, (req, res) => { jsonResultOrFailProm(res, gitPromise(['remote', 'remove', req.params.name], req.query.path)); } ); app.post(`${exports.pathPrefix}/merge`, ensureAuthenticated, ensurePathExists, (req, res) => { jsonResultOrFailProm( res, gitPromise(['merge', config.noFFMerge ? '--no-ff' : '', req.body.with.trim()], req.body.path) ) .finally(emitGitDirectoryChanged.bind(null, req.body.path)) .finally(emitWorkingTreeChanged.bind(null, req.body.path)); }); app.post( `${exports.pathPrefix}/merge/continue`, ensureAuthenticated, ensurePathExists, (req, res) => { const args = { commands: ['commit', '--file=-'], repoPath: req.body.path, inPipe: req.body.message, }; jsonResultOrFailProm(res, gitPromise(args)) .finally(emitGitDirectoryChanged.bind(null, req.body.path)) .finally(emitWorkingTreeChanged.bind(null, req.body.path)); } ); app.post( `${exports.pathPrefix}/merge/abort`, ensureAuthenticated, ensurePathExists, (req, res) => { jsonResultOrFailProm(res, gitPromise(['merge', '--abort'], req.body.path)) .finally(emitGitDirectoryChanged.bind(null, req.body.path)) .finally(emitWorkingTreeChanged.bind(null, req.body.path)); } ); app.post(`${exports.pathPrefix}/squash`, ensureAuthenticated, ensurePathExists, (req, res) => { jsonResultOrFailProm( res, gitPromise(['merge', '--squash', req.body.target.trim()], req.body.path) ) .finally(emitGitDirectoryChanged.bind(null, req.body.path)) .finally(emitWorkingTreeChanged.bind(null, req.body.path)); }); app.post(`${exports.pathPrefix}/rebase`, ensureAuthenticated, ensurePathExists, (req, res) => { jsonResultOrFailProm(res, gitPromise(['rebase', req.body.onto.trim()], req.body.path)) .finally(emitGitDirectoryChanged.bind(null, req.body.path)) .finally(emitWorkingTreeChanged.bind(null, req.body.path)); }); app.post( `${exports.pathPrefix}/rebase/continue`, ensureAuthenticated, ensurePathExists, (req, res) => { jsonResultOrFailProm(res, gitPromise(['rebase', '--continue'], req.body.path)) .finally(emitGitDirectoryChanged.bind(null, req.body.path)) .finally(emitWorkingTreeChanged.bind(null, req.body.path)); } ); app.post( `${exports.pathPrefix}/rebase/abort`, ensureAuthenticated, ensurePathExists, (req, res) => { jsonResultOrFailProm(res, gitPromise(['rebase', '--abort'], req.body.path)) .finally(emitGitDirectoryChanged.bind(null, req.body.path)) .finally(emitWorkingTreeChanged.bind(null, req.body.path)); } ); app.post( `${exports.pathPrefix}/resolveconflicts`, ensureAuthenticated, ensurePathExists, (req, res) => { logger.info('resolve conflicts'); jsonResultOrFailProm(res, gitPromise.resolveConflicts(req.body.path, req.body.files)).then( emitWorkingTreeChanged.bind(null, req.body.path) ); } ); app.post( `${exports.pathPrefix}/launchmergetool`, ensureAuthenticated, ensurePathExists, (req, res) => { const commands = [ 'mergetool', ...(typeof req.body.tool === 'string' ? ['--tool ', req.body.tool] : []), '--no-prompt', req.body.file, ]; gitPromise(commands, req.body.path); // Send immediate response, this is because merging may take a long time // and there is no need to wait for it to finish. res.json({}); } ); app.get( `${exports.pathPrefix}/baserepopath`, ensureAuthenticated, ensurePathExists, (req, res) => { const currentPath = path.resolve(path.join(req.query.path, '..')); jsonResultOrFailProm( res, gitPromise(['rev-parse', '--show-toplevel'], currentPath) .then((baseRepoPath) => { return { path: path.resolve(baseRepoPath.trim()) }; }) .catch((e) => { if (e.errorCode === 'not-a-repository' || e.errorCode === 'must-be-in-working-tree') { // not a repository or a bare repository return {}; } throw e; }) ); } ); app.get(`${exports.pathPrefix}/submodules`, ensureAuthenticated, ensurePathExists, (req, res) => { const filename = path.join(req.query.path, '.gitmodules'); const task = fs .access(filename) .then(() => { return fs.readFile(filename, { encoding: 'utf8' }).then(gitParser.parseGitSubmodule); }) .catch(() => { return {}; }); jsonResultOrFailProm(res, task); }); app.post( `${exports.pathPrefix}/submodules/update`, ensureAuthenticated, ensurePathExists, (req, res) => { jsonResultOrFailProm( res, gitPromise(['submodule', 'init'], req.body.path).then( gitPromise.bind(null, ['submodule', 'update'], req.body.path) ) ); } ); app.post( `${exports.pathPrefix}/submodules/add`, ensureAuthenticated, ensurePathExists, (req, res) => { jsonResultOrFailProm( res, gitPromise( ['submodule', 'add', req.body.submoduleUrl.trim(), req.body.submodulePath.trim()], req.body.path ) ) .finally(emitGitDirectoryChanged.bind(null, req.body.path)) .finally(emitWorkingTreeChanged.bind(null, req.body.path)); } ); app.delete( `${exports.pathPrefix}/submodules`, ensureAuthenticated, ensurePathExists, (req, res) => { // -f is needed for the cases when added submodule change is not in the staging or committed const task = gitPromise( ['submodule', 'deinit', '-f', req.query.submoduleName], req.query.path ) .then(gitPromise.bind(null, ['rm', '-f', req.query.submoduleName], req.query.path)) .then(() => { return Promise.all([ rimraf(path.join(req.query.path, req.query.submodulePath)), rimraf(path.join(req.query.path, '.git', 'modules', req.query.submodulePath)), ]); }); jsonResultOrFailProm(res, task); } ); app.get(`${exports.pathPrefix}/quickstatus`, ensureAuthenticated, (req, res) => { const task = fs .access(req.query.path) .then(() => { return gitPromise.revParse(req.query.path); }) .then((revParseRes) => { if (revParseRes.type !== 'uninited') { return revParseRes; } // for uninited directory, let's check if it's any immediate directories are // git repository so we can display them. return fs .readdir(req.query.path) .then((filePaths) => { return Promise.all( filePaths .filter((filePath) => !filePath.startsWith('.')) .map((filePath) => gitPromise.revParse(path.join(req.query.path, filePath))) ); }) .then((pathRevParses) => { revParseRes.subRepos = pathRevParses .filter( (pathRevParse) => pathRevParse.type === 'inited' || pathRevParse.type === 'bare' ) .map((pathRevParse) => pathRevParse.gitRootPath); return revParseRes; }); }) .catch((e) => { logger.error('failed during /quickstatus', e); return { type: 'no-such-path', gitRootPath: req.query.path }; }); jsonResultOrFailProm(res, task); }); app.get(`${exports.pathPrefix}/stashes`, ensureAuthenticated, ensurePathExists, (req, res) => { const task = gitPromise( ['stash', 'list', '--decorate=full', '--pretty=fuller', '-z', '--parents', '--numstat'], req.query.path ).then(gitParser.parseGitLog); jsonResultOrFailProm(res, task); }); app.post(`${exports.pathPrefix}/stashes`, ensureAuthenticated, ensurePathExists, (req, res) => { jsonResultOrFailProm( res, gitPromise(['stash', 'save', '--include-untracked', req.body.message || ''], req.body.path) ) .finally(emitGitDirectoryChanged.bind(null, req.body.path)) .finally(emitWorkingTreeChanged.bind(null, req.body.path)); }); app.delete( `${exports.pathPrefix}/stashes/:id`, ensureAuthenticated, ensurePathExists, (req, res) => { const type = req.query.apply === 'true' ? 'apply' : 'drop'; jsonResultOrFailProm( res, gitPromise(['stash', type, `stash@{${req.params.id}}`], req.query.path) ) .finally(emitGitDirectoryChanged.bind(null, req.query.path)) .finally(emitWorkingTreeChanged.bind(null, req.query.path)); } ); app.get(`${exports.pathPrefix}/gitconfig`, ensureAuthenticated, (req, res) => { jsonResultOrFailProm(res, gitPromise(['config', '--list']).then(gitParser.parseGitConfig)); }); // This method isn't called by the client but by credentials-helper.js app.get(`${exports.pathPrefix}/credentials`, (req, res) => { // this endpoint can only be invoked from localhost, since the credentials-helper is always // on the same machine that we're running ungit on if (req.ip != '127.0.0.1' && req.ip != '::ffff:127.0.0.1') { logger.info(`Trying to get credentials from unathorized ip: ${req.ip}`); res.status(400).json({ errorCode: 'request-from-unathorized-location' }); return; } const socket = socketsById[req.query.socketId]; const remote = req.query.remote; if (!socket) { // We're using the socket to display an authentication dialog in the ui, // so if the socket is closed/unavailable we pretty much can't get the username/password. logger.info(`Trying to get credentials from unavailable socket: ${req.query.socketId}`); res.status(400).json({ errorCode: 'socket-unavailable' }); } else { socket.once('credentials', (data) => res.json(data)); socket.emit('request-credentials', { remote: remote }); } }); app.post(`${exports.pathPrefix}/createdir`, ensureAuthenticated, (req, res) => { const dir = req.query.dir || req.body.dir; if (!dir) { return res.status(400).json({ errorCode: 'missing-request-parameter', error: 'You need to supply the path request parameter', }); } mkdirp(dir) .then(() => res.json({})) .catch((err) => res.status(400).json(err)); }); app.get(`${exports.pathPrefix}/gitignore`, ensureAuthenticated, ensurePathExists, (req, res) => { fs.readFile(path.join(req.query.path, '.gitignore'), { encoding: 'utf8' }) .then((ignoreContent) => res.status(200).json({ content: ignoreContent })) .catch((e) => { if (e && e.message && e.message.indexOf('no such file or directory') > -1) { res.status(200).json({ content: '' }); } else { res.status(500).json(e); } }); }); app.put(`${exports.pathPrefix}/gitignore`, ensureAuthenticated, ensurePathExists, (req, res) => { if (!req.body.data && req.body.data !== '') { return res.status(400).json({ message: 'Invalid .gitignore content' }); } fs.writeFile(path.join(req.body.path, '.gitignore'), req.body.data) .then(() => res.status(200).json({})) .finally(emitGitDirectoryChanged.bind(null, req.body.path)) .catch((e) => res.status(500).json(e)); }); if (config.dev) { app.post(`${exports.pathPrefix}/testing/createtempdir`, ensureAuthenticated, (req, res) => { temp.mkdir('test-temp-dir', (err, tempPath) => res.json({ path: path.normalize(tempPath) })); }); app.post(`${exports.pathPrefix}/testing/createfile`, ensureAuthenticated, (req, res) => { const content = req.body.content ? req.body.content : `test content\n${Math.random()}\n`; fs.writeFile(req.body.file, content) .then(() => res.json({})) .then(emitWorkingTreeChanged.bind(null, req.body.path)); }); app.post(`${exports.pathPrefix}/testing/changefile`, ensureAuthenticated, (req, res) => { const content = req.body.content ? req.body.content : `test content\n${Math.random()}\n`; fs.writeFile(req.body.file, content) .then(() => res.json({})) .then(emitWorkingTreeChanged.bind(null, req.body.path)); }); app.post(`${exports.pathPrefix}/testing/createimagefile`, ensureAuthenticated, (req, res) => { fs.writeFile(req.body.file, 'png', { encoding: 'binary' }) .then(() => res.json({})) .then(emitWorkingTreeChanged.bind(null, req.body.path)); }); app.post(`${exports.pathPrefix}/testing/changeimagefile`, ensureAuthenticated, (req, res) => { fs.writeFile(req.body.file, 'png ~~', { encoding: 'binary' }) .then(() => res.json({})) .then(emitWorkingTreeChanged.bind(null, req.body.path)); }); app.post(`${exports.pathPrefix}/testing/removefile`, ensureAuthenticated, (req, res) => { fs.unlink(req.body.file) .then(() => res.json({})) .then(emitWorkingTreeChanged.bind(null, req.body.path)); }); app.post(`${exports.pathPrefix}/testing/git`, ensureAuthenticated, (req, res) => { jsonResultOrFailProm(res, gitPromise(req.body.command, req.body.path)).then( emitWorkingTreeChanged.bind(null, req.body.path) ); }); app.post(`${exports.pathPrefix}/testing/cleanup`, (req, res) => { temp.cleanup((err, cleaned) => { logger.info('Cleaned up: ' + JSON.stringify(cleaned)); res.json({ result: cleaned }); }); }); } }; ================================================ FILE: source/git-parser.js ================================================ const path = require('path'); const fileType = require('./utils/file-type.js'); exports.parseGitStatus = (text) => { let lines = text.split('\x00'); const branch = lines[0].split(' ').pop(); // skipping first line... lines = lines.slice(1); const files = {}; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line == '') continue; const status = line.slice(0, 2); const newFileName = line.slice(3).trim(); let oldFileName; let displayName; if (status[0] == 'R') { oldFileName = lines[++i]; displayName = `${oldFileName} → ${newFileName}`; } else { oldFileName = newFileName; displayName = newFileName; } files[newFileName] = { fileName: newFileName, oldFileName: oldFileName, displayName: displayName, staged: status[0] == 'A' || status[0] == 'M', removed: status[0] == 'D' || status[1] == 'D', isNew: (status[0] == '?' || status[0] == 'A') && status[1] != 'D', conflict: (status[0] == 'A' && status[1] == 'A') || status[0] == 'U' || status[1] == 'U', renamed: status[0] == 'R', type: fileType(newFileName), }; } return { branch: branch, files: files, }; }; const fileChangeRegex = /(?[\d-]+)\t(?[\d-]+)\t((?[^\x00]+?)\x00|\x00(?[^\x00]+?)\x00(?[^\x00]+?)\x00)/g; exports.parseGitStatusNumstat = (text) => { const result = {}; fileChangeRegex.lastIndex = 0; let match = fileChangeRegex.exec(text); while (match !== null) { result[match.groups.fileName || match.groups.newFileName] = { additions: match.groups.additions, deletions: match.groups.deletions, }; match = fileChangeRegex.exec(text); } return result; }; const authorRegexp = /([^<]+)<([^>]+)>/; const gitLogHeaders = { Author: (currentCommmit, author) => { const capture = authorRegexp.exec(author); if (capture) { currentCommmit.authorName = capture[1].trim(); currentCommmit.authorEmail = capture[2].trim(); } else { currentCommmit.authorName = author; } }, Commit: (currentCommmit, author) => { const capture = authorRegexp.exec(author); if (capture) { currentCommmit.committerName = capture[1].trim(); currentCommmit.committerEmail = capture[2].trim(); } else { currentCommmit.committerName = author; } }, AuthorDate: (currentCommmit, date) => { currentCommmit.authorDate = date; }, CommitDate: (currentCommmit, date) => { currentCommmit.commitDate = date; }, Reflog: (currentCommmit, data) => { currentCommmit.reflogId = /\{(.*?)\}/.exec(data)[1]; currentCommmit.reflogName = data.substring(0, data.indexOf(' ')).replace('refs/', ''); const author = data.substring(data.indexOf('(') + 1, data.length - 1); const capture = authorRegexp.exec(author); if (capture) { currentCommmit.reflogAuthorName = capture[1].trim(); currentCommmit.reflogAuthorEmail = capture[2].trim(); } else { currentCommmit.reflogAuthorName = author; } }, gpg: (currentCommit, data) => { if (data.startsWith('Signature made')) { // extract sign date currentCommit.signatureDate = data.slice('Signature made '.length); } else if (data.indexOf('Good signature from') > -1) { // fully verified. currentCommit.signatureMade = data .slice('Good signature from '.length) .replace('[ultimate]', '') .trim(); } else if (data.indexOf("Can't check signature") > -1) { // pgp signature attempt is made but failed to verify delete currentCommit.signatureDate; } }, }; exports.parseGitLog = (data) => { const commits = []; let currentCommmit; const parseCommitLine = (row) => { if (!row.trim()) return; currentCommmit = { refs: [], fileLineDiffs: [], additions: 0, deletions: 0 }; const refStartIndex = row.indexOf('('); const sha1s = row .substring(0, refStartIndex < 0 ? row.length : refStartIndex) .split(' ') .slice(1) .filter((sha1) => { return sha1 && sha1.length; }); currentCommmit.sha1 = sha1s[0]; currentCommmit.parents = sha1s.slice(1); if (refStartIndex > 0) { const refs = row.substring(refStartIndex + 1, row.length - 1); currentCommmit.refs = refs.split(/ -> |, /g); } currentCommmit.isHead = currentCommmit.refs.some((item) => { return item.trim() === 'HEAD'; }); commits.isHeadExist = commits.isHeadExist || currentCommmit.isHead; commits.push(currentCommmit); parser = parseHeaderLine; }; const parseHeaderLine = (row) => { if (row.trim() == '') { parser = parseCommitMessage; } else { for (const key in gitLogHeaders) { if (row.indexOf(`${key}: `) == 0) { gitLogHeaders[key](currentCommmit, row.slice(`${key}: `.length).trim()); return; } } } }; const parseCommitMessage = (row, index) => { if (currentCommmit.message) currentCommmit.message += '\n'; else currentCommmit.message = ''; currentCommmit.message += row.trim(); if (/[\d-]+\t[\d-]+\t.+/g.test(rows[index + 1])) { parser = parseFileChanges; return; } if (rows[index + 1] && /^\u0000+commit/.test(rows[index + 1])) { parser = parseCommitLine; return; } }; const parseFileChanges = (row, index) => { // git log is using -z so all the file changes are on one line // merge commits start the file changes with a null if (row[0] === '\x00') { row = row.slice(1); } fileChangeRegex.lastIndex = 0; while (row[fileChangeRegex.lastIndex] && row[fileChangeRegex.lastIndex] !== '\x00') { const match = fileChangeRegex.exec(row); const fileName = match.groups.fileName || match.groups.newFileName; const oldFileName = match.groups.oldFileName || match.groups.fileName; let displayName; if (match.groups.oldFileName) { displayName = `${match.groups.oldFileName} → ${match.groups.newFileName}`; } else { displayName = fileName; } currentCommmit.fileLineDiffs.push({ additions: match.groups.additions, deletions: match.groups.deletions, fileName: fileName, oldFileName: oldFileName, displayName: displayName, type: fileType(fileName), }); } const nextRow = row.slice(fileChangeRegex.lastIndex + 1); for (const fileLineDiff of currentCommmit.fileLineDiffs) { if (!isNaN(parseInt(fileLineDiff.additions, 10))) { currentCommmit.additions += fileLineDiff.additions = parseInt(fileLineDiff.additions, 10); } if (!isNaN(parseInt(fileLineDiff.deletions, 10))) { currentCommmit.deletions += fileLineDiff.deletions = parseInt(fileLineDiff.deletions, 10); } } parser = parseCommitLine; if (nextRow) { parser(nextRow, index); } return; }; let parser = parseCommitLine; const rows = data.split('\n'); rows.forEach((row, index) => { parser(row, index); }); commits.forEach((commit) => { commit.message = typeof commit.message === 'string' ? commit.message.trim() : ''; }); return commits; }; exports.parseGitConfig = (text) => { const conf = {}; text.split('\n').forEach((row) => { const ss = row.split('='); conf[ss[0]] = ss[1]; }); return conf; }; exports.parseGitBranches = (text) => { const branches = []; text.split('\n').forEach((row) => { if (row.trim() == '') return; const branch = { name: row.slice(2) }; if (row[0] == '*') branch.current = true; branches.push(branch); }); return branches; }; exports.parseGitTags = (text) => { return text.split('\n').filter((tag) => { return tag != ''; }); }; exports.parseGitRemotes = (text) => { const remotes = {}; text.split('\n').forEach((row) => { if (row.trim() == '') return; const parts = row.split('\t'); const name = parts[0]; const remote = remotes[name] || { name }; if (parts.length > 1) { const url = parts[1]; if (url.endsWith(' (fetch)')) { remote.fetchUrl = url.substring(0, url.length - 8); } else if (url.endsWith(' (push)')) { remote.pushUrl = url.substring(0, url.length - 7); } else { remote.url = url; } } remotes[name] = remote; }); return Object.values(remotes); }; exports.parseGitLsRemote = (text) => { return text .split('\n') .filter((item) => { return item && item.indexOf('From ') != 0; }) .map((line) => { const sha1 = line.slice(0, 40); const name = line.slice(41).trim(); return { sha1: sha1, name: name }; }); }; exports.parseGitStashShow = (text) => { const lines = text.split('\n').filter((item) => item); return lines.slice(0, lines.length - 1).map((line) => { return { filename: line.substring(0, line.indexOf('|')).trim() }; }); }; exports.parseGitSubmodule = (text) => { if (!text) { return []; } let submodule; const submodules = []; text .trim() .split('\n') .filter((line) => line) .forEach((line) => { if (line.indexOf('[submodule') === 0) { submodule = { name: line.match(/"(.*?)"/)[1] }; submodules.push(submodule); } else { const parts = line.split('='); const key = parts[0].trim(); let value = parts.slice(1).join('=').trim(); if (key == 'path') { value = path.normalize(value); } else if (key == 'url') { // keep a reference to the raw url let url = (submodule.rawUrl = value); // When a repo is checkout with ssh or git instead of an url if (url.indexOf('http') != 0) { if (url.indexOf('git:') == 0) { // git url = `http${url.substring(url.indexOf(':'))}`; } else { // ssh url = `http://${url.substring(url.indexOf('@') + 1).replace(':', '/')}`; } } value = url; } submodule[key] = value; } }); const sorted_submodules = submodules.sort((a, b) => a.name.localeCompare(b.name)); return sorted_submodules; }; const updatePatchHeader = ( result, lastHeaderIndex, ignoredDiffCountTotal, ignoredDiffCountCurrent ) => { const splitedHeader = result[lastHeaderIndex].split(' '); const start = splitedHeader[1].split(','); // start of block const end = splitedHeader[2].split(','); // end of block const startLeft = Math.abs(start[0]); const startRight = Math.abs(start[1]); const endLeft = end[0]; const endRight = end[1]; splitedHeader[1] = `-${startLeft - ignoredDiffCountTotal},${startRight}`; splitedHeader[2] = `+${endLeft - ignoredDiffCountTotal},${endRight - ignoredDiffCountCurrent}`; let allSpace = true; for (let i = lastHeaderIndex + 1; i < result.length; i++) { if (result[i][0] != ' ') { allSpace = false; break; } } if (allSpace) result.splice(lastHeaderIndex, result.length - lastHeaderIndex); else result[lastHeaderIndex] = splitedHeader.join(' '); }; exports.parsePatchDiffResult = (patchLineList, text) => { if (!text) return null; const lines = text.trim().split('\n'); const result = []; let ignoredDiffCountTotal = 0; let ignoredDiffCountCurrent = 0; let lastHeaderIndex = -1; let n = 0; let selectedLines = 0; // first add all lines until diff block header is found while (!/@@ -[0-9]+,[0-9]+ \+[0-9]+,[0-9]+ @@/.test(lines[n])) { result.push(lines[n]); n++; } // per rest of the lines while (n < lines.length) { const line = lines[n]; if (/^[-+]/.test(line)) { // Modified line if (patchLineList.shift()) { selectedLines++; // diff is selected to be committed result.push(line); } else if (line[0] === '+') { // added line diff is selected to be ignored ignoredDiffCountCurrent++; } else { // lines[0] === '-' // deleted line diff is selected to be ignored ignoredDiffCountCurrent--; result.push(` ${line.slice(1)}`); } } else { // none modified line or diff block header if (/@@ -[0-9]+,[0-9]+ \+[0-9]+,[0-9]+ @@/.test(line)) { // update previous header to match line numbers if (lastHeaderIndex > -1) { updatePatchHeader( result, lastHeaderIndex, ignoredDiffCountTotal, ignoredDiffCountCurrent ); } // diff block header ignoredDiffCountTotal += ignoredDiffCountCurrent; ignoredDiffCountCurrent = 0; lastHeaderIndex = result.length; } result.push(line); } n++; } // We don't want to leave out last diff block header... updatePatchHeader(result, lastHeaderIndex, ignoredDiffCountTotal, ignoredDiffCountCurrent); if (selectedLines > 0) { return result.join('\n'); } else { return null; } }; ================================================ FILE: source/git-promise.js ================================================ const child_process = require('child_process'); const gitParser = require('./git-parser'); const path = require('path'); const config = require('./config'); const logger = require('./utils/logger'); const addressParser = require('./address-parser'); const isWindows = /^win/.test(process.platform); const pLimitPromise = import('p-limit'); const fs = require('fs').promises; const gitEmptyReproSha1 = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; // https://stackoverflow.com/q/9765453 const gitEmptyReproSha256 = '6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321'; // https://stackoverflow.com/q/9765453 const gitConfigArguments = [ '-c', 'color.ui=false', '-c', 'core.quotepath=false', '-c', 'core.pager=cat', '-c', 'core.editor=:', ]; const gitOptionalLocks = config.isGitOptionalLocks ? '--no-optional-locks' : ''; const gitBin = (() => { if (config.gitBinPath) { return (config.gitBinPath.endsWith('/') ? config.gitBinPath : config.gitBinPath + '/') + 'git'; } return 'git'; })(); const isRetryableError = (err) => { const errMsg = (err || {}).error || ''; // Dued to git operation parallelization it is possible that race condition may happen if (errMsg.indexOf("index.lock': File exists") > -1) return true; // TODO: Issue #796, based on the conversation with Appveyor team, I guess Windows system // can report "Permission denied" for the file locking issue. if (errMsg.indexOf('index file open failed: Permission denied') > -1) return true; return false; }; let pLimit = (fn) => { try { return Promise.resolve(fn()); } catch (err) { return Promise.reject(err); } }; pLimitPromise.then((limit) => { pLimit = limit.default(config.maxConcurrentGitOperations); }); const gitExecutorProm = (args, retryCount) => { let timeoutTimer; return pLimit(() => { return new Promise((resolve, reject) => { if (config.logGitCommands) logger.info(`git executing: ${args.repoPath} ${args.commands.join(' ')}`); let rejectedError = null; let stdout = ''; let stderr = ''; const env = JSON.parse(JSON.stringify(process.env)); env['LC_ALL'] = 'C'; const procOpts = { cwd: args.repoPath, maxBuffer: 1024 * 1024 * 100, detached: false, env: env, }; const gitProcess = child_process.spawn(gitBin, args.commands, procOpts); timeoutTimer = setTimeout(() => { if (!timeoutTimer) return; timeoutTimer = null; logger.warn(`command timedout: ${args.commands.join(' ')}\n`); gitProcess.kill('SIGINT'); }, args.timeout); if (args.outPipe) { gitProcess.stdout.pipe(args.outPipe); } else { gitProcess.stdout.on('data', (data) => (stdout += data.toString())); } if (args.inPipe) { gitProcess.stdin.end(args.inPipe); } gitProcess.stderr.on('data', (data) => (stderr += data.toString())); gitProcess.on('error', (error) => (rejectedError = error)); gitProcess.on('close', (code) => { if (config.logGitCommands) logger.info( `git result (first 400 bytes): ${args.commands.join(' ')}\n${stderr.slice( 0, 400 )}\n${stdout.slice(0, 400)}` ); if (rejectedError) { reject(rejectedError); } else if (code === 0 || (code === 1 && args.allowError)) { resolve(stdout); } else { reject(getGitError(args, stderr, stdout)); } }); }); }) .catch((err) => { if (retryCount > 0 && isRetryableError(err)) { return new Promise((resolve) => { logger.warn( 'retrying git commands after lock acquired fail. (If persists, lower "maxConcurrentGitOperations")' ); // sleep random amount between 250 ~ 750 ms setTimeout(resolve, Math.floor(Math.random() * 500 + 250)); }).then(gitExecutorProm.bind(null, args, retryCount - 1)); } else { throw err; } }) .finally(() => { if (args.outPipe) args.outPipe.end(); if (timeoutTimer) clearTimeout(timeoutTimer); }); }; /** * Returns a promise that executes git command with given arguments. * * @function * @param {Object | string[]} commands - An object that represents all parameters or first * parameter only, which is an array of commands. * @param {string} repoPath - path to the git repository. * @param {boolean=} allowError - true if return code of 1 is acceptable as some cases * errors are acceptable. * @param {WritableStream=} outPipe - if this argument exists, stdout is piped to this object. * @param {ReadableStream=} inPipe - if this argument exists, data is piped to stdin process * on start. * @param {number=} timeout - execution timeout, default is 2 mins. * @returns {promise} Execution promise. * @example * * getGitExecuteTask({ commands: ['show'], repoPath: '/tmp' }); * * @example * * getGitExecuteTask(['show'], '/tmp'); * */ const git = (commands, repoPath, allowError, outPipe, inPipe, timeout) => { let args = {}; if (Array.isArray(commands)) { args.commands = commands; args.repoPath = repoPath; args.outPipe = outPipe; args.inPipe = inPipe; args.allowError = allowError; args.timeout = timeout; } else { args = commands; } args.commands = gitConfigArguments.concat( args.commands.filter((element) => { return element; }) ); args.timeout = args.timeout || 2 * 60 * 1000; // Default timeout tasks after 2 min args.startTime = Date.now(); return gitExecutorProm(args, config.lockConflictRetryCount); }; const getGitError = (args, stderr, stdout) => { const err = {}; err.isGitError = true; err.errorCode = 'unknown'; err.command = args.commands.join(' '); err.workingDirectory = args.repoPath; err.error = stderr.toString(); err.message = err.error.split('\n')[0]; err.stderr = stderr; err.stdout = stdout; err.stdoutLower = (stdout || '').toLowerCase(); err.stderrLower = (stderr || '').toLowerCase(); if (err.stderrLower.indexOf('not a git repository') >= 0) { err.errorCode = 'not-a-repository'; } else if (err.stderrLower.indexOf("bad default revision 'head'") != -1) { err.errorCode = 'no-head'; } else if (err.stderrLower.indexOf('does not have any commits yet') != -1) { err.errorCode = 'no-commits'; } else if (err.stderrLower.indexOf('connection timed out') != -1) { err.errorCode = 'remote-timeout'; } else if (err.stderrLower.indexOf('permission denied (publickey)') != -1) { err.errorCode = 'permision-denied-publickey'; } else if ( err.stderrLower.indexOf('ssh: connect to host') != -1 && err.stderrLower.indexOf('bad file number') != -1 ) { err.errorCode = 'ssh-bad-file-number'; } else if (err.stderrLower.indexOf('no remote configured to list refs from.') != -1) { err.errorCode = 'no-remote-configured'; } else if ( (err.stderrLower.indexOf('unable to access') != -1 && err.stderrLower.indexOf('could not resolve host:') != -1) || err.stderrLower.indexOf('could not resolve hostname') != -1 ) { err.errorCode = 'offline'; } else if (err.stderrLower.indexOf('proxy authentication required') != -1) { err.errorCode = 'proxy-authentication-required'; } else if (err.stderrLower.indexOf('please tell me who you are') != -1) { err.errorCode = 'no-git-name-email-configured'; } else if ( err.stderrLower.indexOf( 'fatal error: disconnected: no supported authentication methods available (server sent: publickey)' ) == 0 ) { err.errorCode = 'no-supported-authentication-provided'; } else if (err.stderrLower.indexOf('fatal: no remote repository specified.') == 0) { err.errorCode = 'no-remote-specified'; } else if (err.stderrLower.indexOf('non-fast-forward') != -1) { err.errorCode = 'non-fast-forward'; } else if ( err.stderrLower.indexOf('failed to merge in the changes.') == 0 || err.stdoutLower.indexOf('conflict (content): merge conflict in') != -1 || err.stderrLower.indexOf('after resolving the conflicts') != -1 ) { err.errorCode = 'merge-failed'; } else if (err.stderrLower.indexOf('this operation must be run in a work tree') != -1) { err.errorCode = 'must-be-in-working-tree'; } else if ( err.stderrLower.indexOf( 'your local changes to the following files would be overwritten by checkout' ) != -1 ) { err.errorCode = 'local-changes-would-be-overwritten'; } return err; }; git.status = (repoPath, file) => { return Promise.all([ // 0: numStatsStaged git([gitOptionalLocks, 'diff', '--numstat', '--cached', '-z', '--', file || ''], repoPath).then( gitParser.parseGitStatusNumstat ), // 1: numStatsUnstaged config.isEnableNumStat ? git([gitOptionalLocks, 'diff', '--numstat', '-z', '--', file || ''], repoPath).then( gitParser.parseGitStatusNumstat ) : {}, // 2: status git([gitOptionalLocks, 'status', '-s', '-b', '-u', '-z', file || ''], repoPath) .then(gitParser.parseGitStatus) .then((status) => { return Promise.all([ // 0: isRebaseMerge fs .access(path.join(repoPath, '.git', 'rebase-merge')) .then(() => true) .catch(() => false), // 1: isRebaseApply fs .access(path.join(repoPath, '.git', 'rebase-apply')) .then(() => true) .catch(() => false), // 2: isMerge fs .access(path.join(repoPath, '.git', 'MERGE_HEAD')) .then(() => true) .catch(() => false), // 3: inCherry fs .access(path.join(repoPath, '.git', 'CHERRY_PICK_HEAD')) .then(() => true) .catch(() => false), ]) .then((result) => { status.inRebase = result[0] || result[1]; status.inMerge = result[2]; status.inCherry = result[3]; }) .then(() => { if (status.inMerge || status.inCherry) { return fs .readFile(path.join(repoPath, '.git', 'MERGE_MSG'), { encoding: 'utf8' }) .then((commitMessage) => { status.commitMessage = commitMessage; return status; }) .catch(() => { // 'MERGE_MSG' file is gone away, which means we are no longer in merge state // and state changed while this call is being made. status.inMerge = status.inCherry = false; return status; }); } return status; }); }), ]).then((result) => { const numstats = [result[0], result[1]].reduce(Object.assign, {}); const status = result[2]; status.inConflict = false; // merge numstats Object.keys(status.files).forEach((filename) => { // git diff returns paths relative to git repo but git status does not const absoluteFilename = filename.replace(/\.\.\//g, ''); const stats = numstats[absoluteFilename] || { additions: '-', deletions: '-' }; const fileObj = status.files[filename]; fileObj.additions = stats.additions; fileObj.deletions = stats.deletions; if (!status.inConflict && fileObj.conflict) { status.inConflict = true; } }); return status; }); }; git.getRemoteAddress = (repoPath, remoteName) => { return git(['config', '--get', `remote.${remoteName}.url`], repoPath).then((text) => addressParser.parseAddress(text.split('\n')[0]) ); }; git.resolveConflicts = (repoPath, files) => { const toAdd = []; const toRemove = []; return Promise.all( (files || []).map((file) => { return fs .access(path.join(repoPath, file)) .then(() => { toAdd.push(file); }) .catch(() => { toRemove.push(file); }); }) ).then(() => { const addExec = toAdd.length > 0 ? git(['add', toAdd], repoPath) : null; const removeExec = toRemove.length > 0 ? git(['rm', toRemove], repoPath) : null; return Promise.all([addExec, removeExec]); }); }; git.stashExecuteAndPop = (commands, repoPath, allowError, outPipe, inPipe, timeout) => { let hadLocalChanges = true; return git(['stash'], repoPath) .catch((err) => { if (err.stderr.indexOf('You do not have the initial commit yet') != -1) { hadLocalChanges = err.stderr.indexOf('You do not have the initial commit yet') == -1; } else { throw err; } }) .then((result) => { if (!result || result.indexOf('No local changes to save') != -1) { hadLocalChanges = false; } return git(commands, repoPath, allowError, outPipe, inPipe, timeout); }) .then(() => { return hadLocalChanges ? git(['stash', 'pop'], repoPath) : null; }); }; git.binaryFileContent = (repoPath, filename, version, outPipe) => { return git(['show', `${version}:${filename}`], repoPath, null, outPipe); }; git.diffFile = (repoPath, filename, oldFilename, sha1, ignoreWhiteSpace) => { if (sha1) { return git(['rev-list', '--max-parents=0', sha1], repoPath).then((initialCommitSha1) => { const prevSha1 = sha1 == initialCommitSha1.trim() ? sha1.length == 64 ? gitEmptyReproSha256 : gitEmptyReproSha1 : `${sha1}^`; if (oldFilename && oldFilename !== filename) { return git( [ 'diff', ignoreWhiteSpace ? '-w' : '', `${prevSha1}:${oldFilename.trim()}`, `${sha1}:${filename.trim()}`, ], repoPath ); } else { return git( ['diff', ignoreWhiteSpace ? '-w' : '', prevSha1, sha1, '--', filename.trim()], repoPath ); } }); } return git .revParse(repoPath) .then((revParse) => { return revParse.type === 'bare' ? { files: {} } : git.status(repoPath); }) // if bare do not call status .then((status) => { const file = status.files[filename]; if (!file) { return fs .access(path.join(repoPath, filename)) .then(() => { return []; }) .catch(() => { throw { error: `No such file: ${filename}`, errorCode: 'no-such-file' }; }); // If the file is new or if it's a directory, i.e. a submodule } else { if (file && file.isNew) { return git( ['diff', '--no-index', isWindows ? 'NUL' : '/dev/null', filename.trim()], repoPath, true ); } else if (file && file.renamed) { return git( ['diff', ignoreWhiteSpace ? '-w' : '', `HEAD:${oldFilename}`, filename.trim()], repoPath ); } else { return git( ['diff', ignoreWhiteSpace ? '-w' : '', 'HEAD', '--', filename.trim()], repoPath ); } } }); }; git.getCurrentBranch = (repoPath) => { return git(['branch'], repoPath) .then(gitParser.parseGitBranches) .then((branches) => { const branch = branches.find((branch) => branch.current); if (branch) { return branch.name; } else { return ''; } }); }; git.discardAllChanges = (repoPath) => { return git(['reset', '--hard', 'HEAD'], repoPath).then(() => { return git(['clean', '-fd'], repoPath); }); }; git.discardChangesInFile = (repoPath, filename) => { return git.status(repoPath, filename).then((status) => { if (Object.keys(status.files).length == 0) throw new Error(`No files in status in discard, filename: ${filename}`); const fileStatus = status.files[Object.keys(status.files)[0]]; const fullPath = path.join(repoPath, filename); if (fileStatus.staged) { // if staged, just remove from git return git(['rm', '-f', filename], repoPath); } else if (fileStatus.isNew) { // new file, junst unlink return fs.unlink(fullPath).catch((err) => { throw { command: 'unlink', error: err }; }); } return fs .stat(fullPath) .then((stats) => stats.isDirectory()) .catch(() => false) .then((isSubrepoChange) => { if (isSubrepoChange) { return git(['submodule', 'sync'], repoPath).then(() => git(['submodule', 'update', '--init', '-f', '--recursive', filename], repoPath) ); } else { return git(['checkout', 'HEAD', '--', filename], repoPath); } }); }); }; git.applyPatchedDiff = (repoPath, patchedDiff) => { if (patchedDiff) { return git(['apply', '--cached'], repoPath, null, null, patchedDiff + '\n\n'); } }; git.commit = (repoPath, amend, emptyCommit, message, files) => { return new Promise((resolve, reject) => { if (message == undefined) { reject({ error: 'Must specify commit message' }); } if ((!Array.isArray(files) || files.length == 0) && !amend && !emptyCommit) { reject({ error: 'Must specify files or amend to commit' }); } resolve(); }) .then(() => { return git.status(repoPath); }) .then((status) => { const toAdd = []; const toRemove = []; const promises = []; // promises that patches each files individually for (const v in files) { const file = files[v]; const fileStatus = status.files[file.name] || status.files[path.relative(repoPath, file.name)]; if (!fileStatus) { throw { error: `No such file in staging: ${file.name}` }; } if (fileStatus.removed) { toRemove.push(file.name.trim()); } else if (files[v].patchLineList) { promises.push( git(['diff', '--', file.name.trim()], repoPath) .then(gitParser.parsePatchDiffResult.bind(null, file.patchLineList)) .then(git.applyPatchedDiff.bind(null, repoPath)) ); } else { toAdd.push(file.name.trim()); } } promises.push( Promise.resolve() .then(() => { if (toRemove.length > 0) return git( ['update-index', '--remove', '--stdin'], repoPath, null, null, toRemove.join('\n') ); }) .then(() => { if (toAdd.length > 0) return git( ['update-index', '--add', '--stdin'], repoPath, null, null, toAdd.join('\n') ); }) ); return Promise.all(promises); }) .then(() => { const ammendFlag = amend ? '--amend' : ''; const allowedEmptyFlag = emptyCommit || amend ? '--allow-empty' : ''; const isGPGSign = config.isForceGPGSign ? '-S' : ''; return git( ['commit', ammendFlag, allowedEmptyFlag, isGPGSign, '--file=-'], repoPath, null, null, message ); }) .catch((err) => { // ignore the case where nothing were added to be committed if (!err.stdout || err.stdout.indexOf('Changes not staged for commit') === -1) { throw err; } }); }; git.revParse = (repoPath) => { return git(['rev-parse', '--is-inside-work-tree', '--is-bare-repository'], repoPath) .then((result) => { const resultLines = result.split('\n'); if (resultLines[1].indexOf('true') > -1) { // bare repositories don't support `--show-toplevel` since git 2.25 return { type: 'bare', gitRootPath: repoPath }; } return git(['rev-parse', '--show-toplevel'], repoPath).then((topLevel) => { const rootPath = path.normalize(topLevel.trim() ? topLevel.trim() : repoPath); if (resultLines[0].indexOf('true') > -1) { return { type: 'inited', gitRootPath: rootPath }; } return { type: 'uninited', gitRootPath: rootPath }; }); }) .catch(() => { return { type: 'uninited', gitRootPath: path.normalize(repoPath) }; }); }; git.log = (path, limit, skip, maxActiveBranchSearchIteration) => { return git( [ 'log', '--cc', '--decorate=full', '--show-signature', '--date=default', '--pretty=fuller', '-z', '--branches', '--tags', '--remotes', '--parents', '--no-notes', '--numstat', '--date-order', `--max-count=${limit}`, `--skip=${skip}`, ], path ) .then(gitParser.parseGitLog) .then((log) => { log = log ? log : []; if (maxActiveBranchSearchIteration > 0 && !log.isHeadExist && log.length > 0) { return git .log( path, config.numberOfNodesPerLoad + limit, config.numberOfNodesPerLoad + skip, maxActiveBranchSearchIteration - 1 ) .then((innerLog) => { return { limit: limit + (innerLog.isHeadExist ? 0 : config.numberOfNodesPerLoad), skip: skip + (innerLog.isHeadExist ? 0 : config.numberOfNodesPerLoad), nodes: log.concat(innerLog.nodes), isHeadExist: innerLog.isHeadExist, }; }); } else { return { limit: limit, skip: skip, nodes: log, isHeadExist: log.isHeadExist }; } }); }; module.exports = git; ================================================ FILE: source/server.js ================================================ const logger = require('./utils/logger'); const config = require('./config'); const BugTracker = require('./bugtracker'); const bugtracker = new BugTracker('server'); const express = require('express'); const gitApi = require('./git-api'); const sysinfo = require('./sysinfo'); const passport = require('passport'); const LocalStrategy = require('passport-local').Strategy; const semver = require('semver'); const path = require('path'); const fs = require('fs').promises; const signals = require('signals'); const os = require('os'); const cache = require('./utils/cache'); const UngitPlugin = require('./ungit-plugin'); const serveStatic = require('serve-static'); process.on('uncaughtException', (err) => { logger.error(err.stack ? err.stack.toString() : err.toString()); bugtracker.notify(err, 'ungit-launcher'); process.exit(); }); const users = config.users; config.users = null; // So that we don't send the users to the client if (config.authentication) { passport.serializeUser((username, done) => { done(null, username); }); passport.deserializeUser((username, done) => { done(null, users[username] !== undefined ? username : null); }); passport.use( new LocalStrategy((username, password, done) => { if (users[username] !== undefined && password === users[username]) done(null, username); else done(null, false, { message: 'No such username/password' }); }) ); } const app = express(); const server = require('http').createServer(app); gitApi.pathPrefix = '/api'; app.use((req, res, next) => { const rootPath = config.rootPath; if (req.url === rootPath) { // always have a trailing slash res.redirect(req.url + '/'); return; } if (req.url.indexOf(rootPath) === 0) { req.url = req.url.substring(rootPath.length); next(); return; } res.status(400).end(); }); if (config.logRESTRequests) { app.use((req, res, next) => { logger.info(req.method + ' ' + req.url); next(); }); } if (config.allowedIPs) { app.use((req, res, next) => { const ip = req.ip || req.connection.remoteAddress || req.socket.remoteAddress || req.connection.socket.remoteAddress; if (config.allowedIPs.indexOf(ip) >= 0) next(); else { res .status(403) .send( '

    This host is not authorized to connect

    ' + '

    You are trying to connect to an Ungit instance from an unauthorized host.

    ' ); logger.warn(`Host trying but not authorized to connect: ${ip}`); } }); } const noCache = (req, res, next) => { res.set('Cache-Control', 'no-cache, no-store, must-revalidate'); res.set('Pragma', 'no-cache'); res.set('Expires', '0'); next(); }; app.use(noCache); app.use(require('body-parser').json()); if (config.autoShutdownTimeout) { let autoShutdownTimeout; const refreshAutoShutdownTimeout = () => { if (autoShutdownTimeout) clearTimeout(autoShutdownTimeout); autoShutdownTimeout = setTimeout(() => { logger.info( `Shutting down ungit due to inactivity. (autoShutdownTimeout is set to ${config.autoShutdownTimeout} ms` ); process.exit(); }, config.autoShutdownTimeout); }; app.use((req, res, next) => { refreshAutoShutdownTimeout(); next(); }); refreshAutoShutdownTimeout(); } let ensureAuthenticated = (req, res, next) => { next(); }; if (config.authentication) { const cookieParser = require('cookie-parser'); const session = require('express-session'); const MemoryStore = require('memorystore')(session); app.use(cookieParser()); app.use( session({ store: new MemoryStore({ checkPeriod: 86400000, // prune expired entries every 24h }), secret: 'ungit', resave: true, saveUninitialized: true, }) ); app.use(passport.initialize()); app.use(passport.session()); app.post('/api/login', (req, res, next) => { passport.authenticate('local', (err, user, info) => { if (err) { return next(err); } if (!user) { res.status(401).json({ errorCode: 'authentication-failed', error: info.message }); return; } req.logIn(user, (err) => { if (err) { return next(err); } res.json({ ok: true }); return; }); })(req, res, next); }); app.get('/api/loggedin', (req, res) => { if (req.isAuthenticated()) res.json({ loggedIn: true }); else res.json({ loggedIn: false }); }); app.get('/api/logout', (req, res) => { req.logout(); res.json({ ok: true }); }); ensureAuthenticated = (req, res, next) => { if (req.isAuthenticated()) { return next(); } res.status(401).json({ errorCode: 'authentication-required', error: 'You have to authenticate to access this resource', }); }; } const indexHtmlCacheKey = cache.registerFunc(() => { return cache.resolveFunc(pluginsCacheKey).then((plugins) => { return fs.readFile(__dirname + '/../public/index.html', { encoding: 'utf8' }).then((data) => { return Promise.all( Object.values(plugins).map((plugin) => { return plugin.compile(); }) ).then((results) => { data = data.replace('', results.join('\n\n')); data = data.replace(/__ROOT_PATH__/g, config.rootPath); return data; }); }); }); }); app.get('/', (req, res) => { if (config.dev) { cache.invalidateFunc(pluginsCacheKey); cache.invalidateFunc(indexHtmlCacheKey); } cache.resolveFunc(indexHtmlCacheKey).then((data) => { res.end(data); }); }); app.use(serveStatic(__dirname + '/../public')); // Socket-IO const socketIO = require('socket.io'); const socketsById = {}; let socketIdCounter = 0; const io = socketIO(server, { path: config.rootPath + '/socket.io', logger: { debug: logger.debug.bind(logger), info: logger.info.bind(logger), error: logger.error.bind(logger), warn: logger.warn.bind(logger), }, }); io.on('connection', (socket) => { const socketId = socketIdCounter++; socketsById[socketId] = socket; socket.socketId = socketId; socket.emit('connected', { socketId: socketId }); socket.on('disconnect', () => delete socketsById[socketId]); }); const apiEnvironment = { app: app, server: server, ensureAuthenticated: ensureAuthenticated, config: config, pathPrefix: gitApi.pathPrefix, socketIO: io, socketsById: socketsById, }; gitApi.registerApi(apiEnvironment); // Init plugins const loadPlugins = (plugins, pluginBasePath) => { return fs.readdir(pluginBasePath).then((pluginDirs) => { return Promise.all( pluginDirs.map((pluginDir) => { const pluginPath = path.join(pluginBasePath, pluginDir); return fs .access(path.join(pluginPath, 'ungit-plugin.json')) .then(() => { logger.info('Loading plugin: ' + pluginPath); const plugin = new UngitPlugin({ dir: pluginDir, httpBasePath: 'plugins/' + pluginDir, path: pluginPath, }); if (plugin.manifest.disabled || plugin.config.disabled) { logger.info('Plugin disabled: ' + pluginDir); return; } plugin.init(apiEnvironment); plugins.push(plugin); logger.info('Plugin loaded: ' + pluginDir); }) .catch(() => { // Skip direcories that don't contain an "ungit-plugin.json". }); }) ); }); }; const pluginsCacheKey = cache.registerFunc(() => { const plugins = []; return loadPlugins(plugins, path.join(__dirname, '..', 'components')) .then(() => { return fs .access(config.pluginDirectory) .then(() => loadPlugins(plugins, config.pluginDirectory)) .catch(() => { /* ignore */ }); }) .then(() => plugins); }); app.get('/serverdata.js', (req, res) => { const text = `ungit.config = ${JSON.stringify(config)};\n` + `ungit.userHash = "${sysinfo.getUserHash()}";\n` + `ungit.version = "${config.ungitDevVersion}";\n` + `ungit.platform = "${os.platform()}";\n` + `ungit.pluginApiVersion = "${require('../package.json').ungitPluginApiVersion}";\n`; res.set('Content-Type', 'application/javascript'); res.send(text); }); app.get('/api/latestversion', (req, res) => { sysinfo .getUngitLatestVersion() .then((latestVersion) => { if (!semver.valid(config.ungitDevVersion)) { res.json({ latestVersion: latestVersion, currentVersion: config.ungitDevVersion, outdated: false, }); } else { // We only want to show the "new version" banner if the major/minor version was bumped const latestSansPatch = semver(latestVersion); latestSansPatch.patch = 0; const currentSansPatch = semver(config.ungitDevVersion); currentSansPatch.patch = 0; res.json({ latestVersion: latestVersion, currentVersion: config.ungitDevVersion, outdated: semver.gt(latestSansPatch, currentSansPatch), }); } }) .catch(() => { res.json({ latestVersion: config.ungitDevVersion, currentVersion: config.ungitDevVersion, outdated: false, }); }); }); app.get('/api/ping', (req, res) => res.json({})); app.get('/api/gitversion', (req, res) => { res.json(sysinfo.getGitVersionInfo()); }); const userConfigPath = path.join(config.homedir, '.ungitrc'); const readUserConfig = () => { return fs .access(userConfigPath) .then(() => { return fs .readFile(userConfigPath, { encoding: 'utf8' }) .then((content) => { return JSON.parse(content); }) .catch((err) => { logger.error(`Stop at reading ~/.ungitrc because ${err}`); process.exit(1); }); }) .catch(() => { return {}; }); }; const writeUserConfig = (configContent) => { return fs.writeFile(userConfigPath, JSON.stringify(configContent, undefined, 2)); }; app.get('/api/userconfig', ensureAuthenticated, (req, res) => { readUserConfig() .then((userConfig) => { res.json(userConfig); }) .catch((err) => { res.status(400).json(err); }); }); app.post('/api/userconfig', ensureAuthenticated, (req, res) => { writeUserConfig(req.body) .then(() => { res.json({}); }) .catch((err) => { res.status(400).json(err); }); }); app.get('/api/fs/exists', ensureAuthenticated, (req, res) => { fs.access(req.query['path']) .then(() => { res.json(true); }) .catch(() => { res.json(false); }); }); app.get('/api/fs/listDirectories', ensureAuthenticated, (req, res) => { const dir = path.resolve(req.query.term.trim()).replace('/~', ''); fs.readdir(dir, { withFileTypes: true }) .then((files) => { const dirs = []; files.forEach((file) => { if (file.isDirectory()) { dirs.push(path.join(dir, file.name)); } }); return dirs; }) .then((filteredFiles) => { filteredFiles.unshift(dir); res.json(filteredFiles); }) .catch((err) => res.status(400).json(err)); }); // Error handling // eslint-disable-next-line no-unused-vars app.use((err, req, res, next) => { bugtracker.notify(err, 'ungit-node'); logger.error(err.stack); res.status(500).send({ error: err.message, errorType: err.name, stack: err.stack }); }); exports.started = new signals.Signal(); server.listen({ port: config.port, host: config.ungitBindIp }, () => { logger.info('Listening on port ' + config.port); console.log('## Ungit started ##'); // Consumed by bin/ungit to figure out when the app is started exports.started.dispatch(); }); ================================================ FILE: source/sysinfo.js ================================================ const getMac = require('getmac').default; const md5 = require('blueimp-md5'); const semver = require('semver'); const logger = require('./utils/logger'); const config = require('./config'); exports.getUngitLatestVersion = () => { return import('latest-version').then((latestVersion) => { return latestVersion.default('ungit'); }); }; exports.getUserHash = () => { let addr; try { addr = getMac(); } catch (err) { logger.error('attempt to get mac addr failed, using fake mac.', err); addr = 'abcde'; } return md5(addr); }; exports.getGitVersionInfo = () => { const result = { requiredVersion: '>=1.8.x', version: 'unkown', satisfied: false, }; if (!config.gitVersion) { result.error = `Failed to parse git version number. Note that Ungit requires git version ${result.requiredVersion}`; } else { result.version = config.gitVersion; result.satisfied = semver.satisfies(result.version, result.requiredVersion); if (!result.satisfied) { result.error = `Ungit requires git version ${result.requiredVersion}, you are currently running ${result.version}`; } } return result; }; ================================================ FILE: source/ungit-plugin.js ================================================ const fsSync = require('fs'); const fs = fsSync.promises; const path = require('path'); const express = require('express'); const logger = require('./utils/logger'); const config = require('./config'); const assureArray = (obj) => { return Array.isArray(obj) ? obj : [obj]; }; class UngitPlugin { constructor(args) { this.dir = args.dir; this.path = args.path; this.httpBasePath = args.httpBasePath; this.manifest = JSON.parse( fsSync.readFileSync(path.join(this.path, 'ungit-plugin.json'), { encoding: 'utf8' }) ); this.name = this.manifest.name || this.dir; this.config = config.pluginConfigs[this.name] || {}; } init(env) { if (this.manifest.server) { const serverScript = require(path.join(this.path, this.manifest.server)); serverScript.install({ app: env.app, httpServer: env.httpServer, ensureAuthenticated: env.ensureAuthenticated, ensurePathExists: env.ensurePathExists, git: require('./git-promise'), config: env.config, socketIO: env.socketIO, socketsById: env.socketsById, pluginConfig: this.config, httpPath: `${env.pathPrefix}/plugins/${this.name}`, pluginApiVersion: require('../package.json').ungitPluginApiVersion, }); } env.app.use(`/plugins/${this.name}`, express.static(this.path)); } compile() { logger.info(`Compiling plugin ${this.path}`); const exports = this.manifest.exports || {}; return Promise.resolve() .then(() => { if (exports.raw) { return Promise.all( assureArray(exports.raw).map((rawSource) => { return fs .readFile(path.join(this.path, rawSource), { encoding: 'utf8' }) .then((text) => { return text + '\n'; }); }) ).then((result) => { return result.join('\n'); }); } else { return ''; } }) .then((result) => { if (exports.javascript) { return ( result + assureArray(exports.javascript) .map((filename) => { return ``; }) .join('\n') ); } else { return result; } }) .then((result) => { if (exports.knockoutTemplates) { return Promise.all( Object.keys(exports.knockoutTemplates).map((templateName) => { return fs .readFile(path.join(this.path, exports.knockoutTemplates[templateName]), { encoding: 'utf8', }) .then((text) => { return ``; }); }) ).then((templates) => { return result + templates.join('\n'); }); } else { return result; } }) .then((result) => { if (exports.css) { return ( result + assureArray(exports.css) .map((cssSource) => { return ``; }) .join('\n') ); } else { return result; } }) .then((result) => { return `\n${result}`; }); } } module.exports = UngitPlugin; ================================================ FILE: source/utils/cache.js ================================================ const NodeCache = require('node-cache'); const md5 = require('blueimp-md5'); const funcMap = {}; // Will there ever be a use case where this is a cache with TTL? func registration with TTL? class OurCache extends NodeCache { constructor() { super({ stdTTL: 0 }); } /** * Get cached result associated with the key or execute a function to get the result. * * @param {string} [key] - A key associated with a function to be executed. * @returns {Promise} - Promise either resolved with cached result of the function or rejected * with function not found. */ resolveFunc(key) { let result = this.get(key); if (result !== undefined) { return Promise.resolve(result); } result = funcMap[key]; if (result === undefined) { return Promise.reject(new Error(`Cache entry ${key} not found`)); } try { result = result.func(); } catch (err) { return Promise.reject(err); } return Promise.resolve(result) // func is found, resolve, set with TTL and return result .then((r) => { this.set(key, r, funcMap[key].ttl); return r; }); } /** * Register a function to cache it's result. If same key exists, key is deregistered and * registered again. * * @param {function} [func] - Function to be executed to get the result. * @param {string} [key=md5(func)] - Key to retrieve cached function result. Default is * `md5(func)`. * @param {number} [ttl=0] - Ttl in seconds to be used for the cached result of * function. Default is `0`. * @returns {string} - Key to retrieve cached function result. */ registerFunc(func, key, ttl) { if (typeof func !== 'function') { throw new Error('no function was passed in.'); } key = key || md5(func); ttl = ttl || this.options.stdTTL; if (isNaN(ttl) || ttl < 0) { throw new Error('ttl value is not valid.'); } if (funcMap[key]) { this.deregisterFunc(key); } funcMap[key] = { func: func, ttl: ttl, }; return key; } /** * Immediately invalidate cached function result despite ttl value. * * @param {string} [key] - A key associated with a function to be executed. */ invalidateFunc(key) { this.del(key); } /** * Remove function registration and invalidate it's cached value. * * @param {string} [key] - A key associated with a function to be executed. */ deregisterFunc(key) { this.invalidateFunc(key); delete funcMap[key]; } } module.exports = new OurCache(); ================================================ FILE: source/utils/file-type.js ================================================ 'use strict'; const path = require('path'); const imageFileExtensions = ['.PNG', '.JPG', '.BMP', '.GIF', '.JPEG']; module.exports = (fileName) => imageFileExtensions.indexOf(path.extname(fileName).toUpperCase()) > -1 ? 'image' : 'text'; ================================================ FILE: source/utils/logger.js ================================================ const winston = require('winston'); const path = require('path'); const config = require('../config'); const transports = [new winston.transports.Console()]; if (config.logDirectory) { console.log('Added log file at ' + config.logLevel); transports.push( new winston.transports.File({ filename: path.join(config.logDirectory, 'server.log'), maxsize: 100 * 1024, maxFiles: 2, format: winston.format.combine(winston.format.timestamp(), winston.format.json()), }) ); } console.log('Setting log level to ' + config.logLevel); const logger = winston.createLogger({ level: config.logLevel || 'error', format: winston.format.combine( winston.format.timestamp(), winston.format.colorize(), winston.format.printf((info) => { const splat = info[Symbol.for('splat')]; if (splat) { const splatStr = splat.map((arg) => JSON.stringify(arg)).join('\n'); return `${info.timestamp} - ${info.level}: ${info.message} ${splatStr}`; } return `${info.timestamp} - ${info.level}: ${info.message}`; }) ), transports: transports, }); module.exports = logger; ================================================ FILE: test/common-es6.js ================================================ const expect = require('expect.js'); const path = require('path'); const restGit = require('../source/git-api'); exports.makeRequest = (method, req, path, payload) => { let r; if (method === 'GET' || method === 'PNG') { r = req.get(`${restGit.pathPrefix}${path}`); } else if (method === 'POST') { r = req.post(`${restGit.pathPrefix}${path}`); } else if (method === 'DELETE') { r = req.del(`${restGit.pathPrefix}${path}`); } else if (method === 'PUT') { r = req.put(`${restGit.pathPrefix}${path}`); } else { throw new Error({ message: `invalid method of ${method}` }); } if (payload) { payload.socketId = 'ignore'; if (method === 'POST' || method === 'PUT') { r.send(payload); } else { r.query(payload); } } return new Promise((resolve, reject) => { r.expect('Content-Type', method === 'PNG' ? 'image/png' : /json/).end((err, res) => { if (err) { console.log(`failed path: ${path}`); console.dir(err); console.dir(res ? res.body : ''); reject(err); } else { let data = (res || {}).body; try { data = JSON.parse(data); } catch { /* Ignore error */ } resolve(data); } }); }); }; exports.get = this.makeRequest.bind(this, 'GET'); exports.getPng = this.makeRequest.bind(this, 'PNG'); exports.post = this.makeRequest.bind(this, 'POST'); exports.delete = this.makeRequest.bind(this, 'DELETE'); exports.put = this.makeRequest.bind(this, 'PUT'); exports.initRepo = async (req, config) => { config = config || {}; const res = await this.post(req, '/testing/createtempdir', config.path); expect(res.path).to.be.ok(); await this.post(req, '/init', { path: res.path, bare: !!config.bare }); return res.path; }; exports.createSmallRepo = (req) => { return this.initRepo(req).then((dir) => { const testFile = 'smalltestfile.txt'; return this.post(req, '/testing/createfile', { file: path.join(dir, testFile) }) .then(() => this.post(req, '/commit', { path: dir, message: 'Init', files: [{ name: testFile }] }) ) .then(() => dir); }); }; ================================================ FILE: test/spec.address-parser.js ================================================ const expect = require('expect.js'); const addressParser = require('../source/address-parser'); describe('git-parser addresses', () => { it('parseAddress ssh://some.address.com/my/awesome/project', () => { const addr = 'ssh://some.address.com/my/awesome/project'; const parsed = addressParser.parseAddress(addr); expect(parsed.host).to.be('some.address.com'); expect(parsed.port).to.be(undefined); expect(parsed.project).to.be('my/awesome/project'); expect(parsed.shortProject).to.be('project'); }); it('parseAddress ssh://some.address.com:8080/my/awesome/project', () => { const addr = 'ssh://some.address.com:8080/my/awesome/project'; const parsed = addressParser.parseAddress(addr); expect(parsed.host).to.be('some.address.com'); expect(parsed.port).to.be('8080'); expect(parsed.project).to.be('my/awesome/project'); expect(parsed.shortProject).to.be('project'); }); it('parseAddress some.address.com:my/awesome/project.git', () => { const addr = 'some.address.com:my/awesome/project.git'; const parsed = addressParser.parseAddress(addr); expect(parsed.host).to.be('some.address.com'); expect(parsed.project).to.be('my/awesome/project'); expect(parsed.shortProject).to.be('project'); }); it('parseAddress someuser@some.address.com:my/awesome/project.git', () => { const addr = 'someuser@some.address.com:my/awesome/project.git'; const parsed = addressParser.parseAddress(addr); expect(parsed.username).to.be('someuser'); expect(parsed.host).to.be('some.address.com'); expect(parsed.project).to.be('my/awesome/project'); expect(parsed.shortProject).to.be('project'); }); it('parseAddress some.address.com:my/awesome/project', () => { const addr = 'some.address.com:my/awesome/project'; const parsed = addressParser.parseAddress(addr); expect(parsed.host).to.be('some.address.com'); expect(parsed.project).to.be('my/awesome/project'); expect(parsed.shortProject).to.be('project'); }); it('parseAddress someuser@some.address.com:my/awesome/project', () => { const addr = 'someuser@some.address.com:my/awesome/project'; const parsed = addressParser.parseAddress(addr); expect(parsed.username).to.be('someuser'); expect(parsed.host).to.be('some.address.com'); expect(parsed.project).to.be('my/awesome/project'); expect(parsed.shortProject).to.be('project'); }); it('parseAddress https://some.address.com/my/awesome/project', () => { const addr = 'https://some.address.com/my/awesome/project'; const parsed = addressParser.parseAddress(addr); expect(parsed.host).to.be('some.address.com'); expect(parsed.project).to.be('my/awesome/project'); expect(parsed.shortProject).to.be('project'); }); it('parseAddress https://some.address.com/my/awesome/project.git', () => { const addr = 'https://some.address.com/my/awesome/project.git'; const parsed = addressParser.parseAddress(addr); expect(parsed.host).to.be('some.address.com'); expect(parsed.project).to.be('my/awesome/project'); expect(parsed.shortProject).to.be('project'); }); it('parseAddress /home/username/somerepo', () => { const addr = '/home/username/somerepo'; const parsed = addressParser.parseAddress(addr); expect(parsed.host).to.be('localhost'); expect(parsed.project).to.be('somerepo'); expect(parsed.shortProject).to.be('somerepo'); }); it('parseAddress ~/something/somerepo', () => { const addr = '~/something/somerepo'; const parsed = addressParser.parseAddress(addr); expect(parsed.host).to.be('localhost'); expect(parsed.project).to.be('somerepo'); expect(parsed.shortProject).to.be('somerepo'); }); it('parseAddress C:\\something\\somerepo', () => { const addr = 'C:\\something\\somerepo'; const parsed = addressParser.parseAddress(addr); expect(parsed.host).to.be('localhost'); expect(parsed.project).to.be('somerepo'); expect(parsed.shortProject).to.be('somerepo'); }); it('parseAddress C:\\somerepo', () => { const addr = 'C:\\somerepo'; const parsed = addressParser.parseAddress(addr); expect(parsed.host).to.be('localhost'); expect(parsed.project).to.be('somerepo'); expect(parsed.shortProject).to.be('somerepo'); }); it('parseAddress C:\\something\\somerepo\\', () => { const addr = 'C:\\something\\somerepo\\'; const parsed = addressParser.parseAddress(addr); expect(parsed.host).to.be('localhost'); expect(parsed.project).to.be('somerepo'); expect(parsed.shortProject).to.be('somerepo'); }); }); ================================================ FILE: test/spec.cache.js ================================================ const expect = require('expect.js'); const cache = require('../source/utils/cache'); describe('cache', () => { it('should be invokable several times', () => { let i = 0; const key = cache.registerFunc(() => i++); return cache .resolveFunc(key) .then((val) => { expect(val).to.be(0); }) .then(() => cache.resolveFunc(key)) .then((val) => expect(val).to.be(0)); }); it('should work when failing sync', () => { const errorMsg = 'A nasty error...'; const key = cache.registerFunc(() => { throw new Error(errorMsg); }); return cache .resolveFunc(key) .then(() => { throw new Error('should have thrown exception!'); }) .catch((e) => { if (e.message !== errorMsg) throw new Error('error message does not match!'); }); }); it('should work when failing async', () => { const errorMsg = 'A nasty error...'; const key = cache.registerFunc(() => Promise.reject(new Error(errorMsg))); return cache .resolveFunc(key) .then(() => { throw new Error('should have thrown exception!'); }) .catch((e) => { if (e.message !== errorMsg) throw new Error('error message does not match!'); }); }); it('should be possible to invalidate cache', () => { let i = 0; const key = cache.registerFunc(() => i++); return cache .resolveFunc(key) .then((val) => { expect(val).to.be(0); }) .then(() => { cache.invalidateFunc(key); return cache.resolveFunc(key); }) .then((val) => { expect(val).to.be(1); }); }); it('creating a same function with different keys', () => { let i = 0; const key1 = 'func1'; const key2 = 'func2'; const func = () => i++; cache.registerFunc(func, key1); cache.registerFunc(func, key2); return cache .resolveFunc(key1) .then((val) => { expect(val).to.be(0); }) .then(() => cache.resolveFunc(key1)) .then((val) => { expect(val).to.be(0); }) .then(() => cache.resolveFunc(key2)) .then((val) => { expect(val).to.be(1); }) .then(() => { cache.invalidateFunc(key1); return cache.resolveFunc(key1); }) .then((val) => { expect(val).to.be(2); }) .then(() => cache.resolveFunc(key2)) .then((val) => { expect(val).to.be(1); }); }); it('Testing ttl', function () { let i = 0; const func = () => i++; const key = cache.registerFunc(func, null, 1); this.timeout(3000); return cache .resolveFunc(key) .then((val) => { expect(val).to.be(0); }) .then(() => new Promise((resolve) => setTimeout(resolve, 500))) .then(() => { return cache.resolveFunc(key); }) .then((val) => { expect(val).to.be(0); }) .then(() => new Promise((resolve) => setTimeout(resolve, 1000))) .then(() => { return cache.resolveFunc(key); }) .then((val) => { expect(val).to.be(1); }) .then(() => new Promise((resolve) => setTimeout(resolve, 500))) .then(() => cache.resolveFunc(key)) .then((val) => { expect(val).to.be(1); }); }); }); ================================================ FILE: test/spec.credentials-helper.js ================================================ const expect = require('expect.js'); const child_process = require('child_process'); const http = require('http'); const config = require('../source/config'); describe('credentials-helper', () => { it('should be invokable', (done) => { const socketId = Math.floor(Math.random() * 1000); const remote = 'origin'; const payload = { username: 'testuser', password: 'testpassword' }; const server = http.createServer((req, res) => { try { const reqUrl = new URL(req.url, `http://${req.headers.host}`); expect(reqUrl.pathname).to.be('/api/credentials'); expect(reqUrl.searchParams.get('remote')).to.be(`${remote}`); expect(reqUrl.searchParams.get('socketId')).to.be(`${socketId}`); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(payload)); } finally { if (!res.writableFinished) { res.statusCode = 500; res.end(); } } }); server.listen({ port: config.port }, () => { const command = `node bin/credentials-helper ${socketId} ${config.port} ${remote} get`; child_process.exec(command, (err, stdout) => { server.close(); expect(err).to.not.be.ok(); const ss = stdout.split('\n'); expect(ss[0]).to.be(`username=${payload.username}`); expect(ss[1]).to.be(`password=${payload.password}`); done(); }); }); }); }); ================================================ FILE: test/spec.file-type.js ================================================ const fileType = require('../source/utils/file-type.js'); const expect = require('expect.js'); describe('file type', () => { it('should be able to detrmine image file vs text files.', () => { expect(fileType('example.txt')).to.be('text'); expect(fileType('example')).to.be('text'); expect(fileType('example.aBc')).to.be('text'); expect(fileType('examplepng.jpg.er')).to.be('text'); expect(fileType('example.png')).to.be('image'); expect(fileType('example.jpg')).to.be('image'); expect(fileType('example.bmp')).to.be('image'); expect(fileType('example.gIf')).to.be('image'); expect(fileType('example.JPEG')).to.be('image'); }); }); ================================================ FILE: test/spec.git-api.branching.js ================================================ const expect = require('expect.js'); const request = require('supertest'); const express = require('express'); const path = require('path'); const restGit = require('../source/git-api'); const common = require('./common-es6.js'); const app = express(); app.use(require('body-parser').json()); restGit.registerApi({ app: app, config: { dev: true } }); let testDir; let gitConfig; const req = request(app); describe('git-api branching', function () { before(() => { return common .initRepo(req) .then((res) => { testDir = res; }) .then(() => common.get(req, '/gitconfig', { path: testDir })) .then((res) => { gitConfig = res; }); }); after(() => common.post(req, '/testing/cleanup')); const commitMessage = 'Commit 1'; const testFile1 = 'testfile1.txt'; it('should be possible to commit to master', () => { return common .post(req, '/testing/createfile', { file: path.join(testDir, testFile1) }) .then(() => common.post(req, '/commit', { path: testDir, message: commitMessage, files: [{ name: testFile1 }], }) ); }); it('listing branches should work', () => { return common.get(req, '/branches', { path: testDir }).then((res) => { expect(res.length).to.be(1); expect(res[0].name).to.be('master'); expect(res[0].current).to.be(true); }); }); const testBranch = 'testBranch'; it('creating a branch should work', () => { return common.post(req, '/branches', { path: testDir, name: testBranch, startPoint: 'master' }); }); it('listing branches should show the new branch', () => { return common.get(req, '/branches', { path: testDir }).then((res) => { expect(res.length).to.be(2); expect(res[0].name).to.be('master'); expect(res[0].current).to.be(true); expect(res[1].name).to.be(testBranch); expect(res[1].current).to.be(undefined); }); }); it('should be possible to switch to a branch', () => { return common.post(req, '/checkout', { path: testDir, name: testBranch }); }); it('listing branches should show the new branch as current', () => { return common.get(req, '/branches', { path: testDir }).then((res) => { expect(res.length).to.be(2); expect(res[0].name).to.be('master'); expect(res[0].current).to.be(undefined); expect(res[1].name).to.be(testBranch); expect(res[1].current).to.be(true); }); }); it('get branch should show the new branch as current', () => { return common .get(req, '/checkout', { path: testDir }) .then((res) => expect(res).to.be(testBranch)); }); const commitMessage3 = 'Commit 3'; const testFile2 = 'testfile2.txt'; it('should be possible to commit to the branch', () => { return common .post(req, '/testing/createfile', { file: path.join(testDir, testFile2) }) .then(() => common.post(req, '/commit', { path: testDir, message: commitMessage3, files: [{ name: testFile2 }], }) ); }); it('log should show both branches and all commits', () => { return common.get(req, '/gitlog', { path: testDir }).then((res) => { expect(res.skip).to.be(0); expect(res.limit).to.be(25); const nodes = res.nodes; expect(nodes).to.be.a('array'); expect(nodes.length).to.be(2); const objs = {}; nodes.forEach((obj) => { obj.refs.sort(); objs[obj.refs[0]] = obj; }); const master = objs['refs/heads/master']; const HEAD = objs['HEAD']; expect(master.message.indexOf(commitMessage)).to.be(0); expect(master.authorDate).to.be.a('string'); expect(master.authorName).to.be(gitConfig['user.name']); expect(master.authorEmail).to.be(gitConfig['user.email']); expect(master.commitDate).to.be.a('string'); expect(master.committerName).to.be(gitConfig['user.name']); expect(master.committerEmail).to.be(gitConfig['user.email']); expect(master.refs).to.eql(['refs/heads/master']); expect(master.parents).to.eql([]); expect(master.sha1).to.be.ok(); expect(HEAD.message.indexOf(commitMessage3)).to.be(0); expect(HEAD.authorDate).to.be.a('string'); expect(HEAD.authorName).to.be(gitConfig['user.name']); expect(HEAD.authorEmail).to.be(gitConfig['user.email']); expect(HEAD.commitDate).to.be.a('string'); expect(HEAD.committerName).to.be(gitConfig['user.name']); expect(HEAD.committerEmail).to.be(gitConfig['user.email']); expect(HEAD.refs).to.eql(['HEAD', `refs/heads/${testBranch}`]); expect(HEAD.parents).to.eql([master.sha1]); expect(HEAD.sha1).to.be.ok(); }); }); it('should be possible to modify some local file', () => { return common.post(req, '/testing/changefile', { file: path.join(testDir, testFile1) }); }); it('should be possible to checkout another branch with local modifications', () => { return common.post(req, '/checkout', { path: testDir, name: 'master' }); }); it('status should list the changed file', () => { return common.get(req, '/status', { path: testDir }).then((res) => { expect(Object.keys(res.files).length).to.be(1); expect(res.files[testFile1]).to.eql({ displayName: testFile1, fileName: testFile1, oldFileName: testFile1, isNew: false, staged: false, removed: false, conflict: false, renamed: false, type: 'text', additions: '1', deletions: '1', }); }); }); it('should be possible to create a tag', () => { return common.post(req, '/tags', { path: testDir, name: 'v1.0' }); }); it('should be possible to list tag', () => { return common.get(req, '/tags', { path: testDir }).then((res) => expect(res.length).to.be(1)); }); it('should be possible to delete a tag', () => { return common.delete(req, '/tags', { path: testDir, name: 'v1.0' }); }); it('tag should be removed', () => { return common.get(req, '/tags', { path: testDir }).then((res) => expect(res.length).to.be(0)); }); it('should be possible to delete a branch', () => { return common.delete(req, '/branches', { path: testDir, name: testBranch }); }); it('branch should be removed', () => { return common .get(req, '/branches', { path: testDir }) .then((res) => expect(res.length).to.be(1)); }); }); ================================================ FILE: test/spec.git-api.conflict-no-auto-stash.js ================================================ const expect = require('expect.js'); const request = require('supertest'); const express = require('express'); const path = require('path'); const restGit = require('../source/git-api'); const common = require('./common-es6.js'); const app = express(); app.use(require('body-parser').json()); const req = request(app); restGit.registerApi({ app: app, config: { dev: true, autoStashAndPop: false } }); let testDir; describe('git-api conflict checkout no auto stash', function () { const testBranch = 'testBranch'; const testFile1 = 'testfile1.txt'; before(() => { return common.initRepo(req).then((dir) => { testDir = dir; return common .post(req, '/testing/createfile', { file: path.join(testDir, testFile1) }) .then(() => common.post(req, '/commit', { path: testDir, message: 'a', files: [{ name: testFile1 }], }) ) .then(() => common.post(req, '/branches', { path: testDir, name: testBranch, startPoint: 'master' }) ) .then(() => common.post(req, '/testing/changefile', { file: path.join(testDir, testFile1) }) ) .then(() => common.post(req, '/commit', { path: testDir, message: 'b', files: [{ name: testFile1 }], }) ); }); }); after(() => { return common.post(req, '/testing/cleanup'); }); it('should be possible to make some changes', () => { return common.post(req, '/testing/changefile', { file: path.join(testDir, testFile1) }); }); it('should not be possible to checkout with local files that will conflict', () => { return common .post(req, `${restGit.pathPrefix}/checkout`, { path: testDir, name: testBranch }) .then((gitErr) => expect(gitErr.errorCode).to.be('local-changes-would-be-overwritten')); }); it('checkout should say we are still on master', () => { return common .get(req, '/checkout', { path: testDir }) .then((res) => expect(res).to.be('master')); }); }); ================================================ FILE: test/spec.git-api.conflict.js ================================================ const expect = require('expect.js'); const request = require('supertest'); const express = require('express'); const path = require('path'); const restGit = require('../source/git-api'); const common = require('./common-es6.js'); const app = express(); app.use(require('body-parser').json()); restGit.registerApi({ app: app, config: { dev: true, autoStashAndPop: true } }); let testDir; const req = request(app); describe('git-api conflict rebase', function () { const commitMessage = 'Commit 1'; const testFile1 = 'testfile1.txt'; const testBranch = 'testBranch'; before(() => { return common.initRepo(req).then((dir) => { testDir = dir; return common .post(req, '/testing/createfile', { file: path.join(testDir, testFile1) }) .then(() => common.post(req, '/commit', { path: testDir, message: commitMessage, files: [{ name: testFile1 }], }) ) .then(() => common.post(req, '/branches', { path: testDir, name: testBranch, startPoint: 'master' }) ) .then(() => common.post(req, '/testing/changefile', { file: path.join(testDir, testFile1) }) ) .then(() => common.post(req, '/commit', { path: testDir, message: commitMessage, files: [{ name: testFile1 }], }) ) .then(() => common.post(req, '/checkout', { path: testDir, name: testBranch })) .then(() => common.post(req, '/testing/changefile', { file: path.join(testDir, testFile1) }) ) .then(() => common.post(req, '/commit', { path: testDir, message: commitMessage, files: [{ name: testFile1 }], }) ); }); }); it('should be possible to rebase on master', (done) => { req .post(`${restGit.pathPrefix}/rebase`) .send({ path: testDir, onto: 'master' }) .set('Accept', 'application/json') .expect('Content-Type', /json/) .expect(400) .end((err, res) => { expect(res.body.errorCode).to.be('merge-failed'); done(); }); }); it('status should list files in conflict', () => { return common.get(req, '/status', { path: testDir }).then((res) => { expect(res.inRebase).to.be(true); expect(Object.keys(res.files).length).to.be(1); expect(res.files[testFile1]).to.eql({ displayName: testFile1, fileName: testFile1, oldFileName: testFile1, isNew: false, staged: false, removed: false, conflict: true, renamed: false, type: 'text', additions: '4', deletions: '0', }); }); }); it('should be possible fix the conflict', () => { return common.post(req, '/testing/changefile', { file: path.join(testDir, testFile1) }); }); it('should be possible to resolve', () => { return common.post(req, '/resolveconflicts', { path: testDir, files: [testFile1] }); }); it('should be possible continue the rebase', () => { return common.post(req, '/rebase/continue', { path: testDir }); }); }); describe('git-api conflict checkout', function () { const testBranch = 'testBranch'; const testFile1 = 'testfile1.txt'; before(() => { return common.initRepo(req).then((dir) => { testDir = dir; return common .post(req, '/testing/createfile', { file: path.join(testDir, testFile1) }) .then(() => common.post(req, '/commit', { path: testDir, message: 'a', files: [{ name: testFile1 }], }) ) .then(() => common.post(req, '/branches', { path: testDir, name: testBranch, startPoint: 'master' }) ) .then(() => common.post(req, '/testing/changefile', { file: path.join(testDir, testFile1) }) ) .then(() => common.post(req, '/commit', { path: testDir, message: 'b', files: [{ name: testFile1 }], }) ); }); }); it('should be possible to make some changes', () => { return common.post(req, '/testing/changefile', { file: path.join(testDir, testFile1) }); }); it('should be possible to checkout with local files that will conflict', (done) => { req .post(`${restGit.pathPrefix}/checkout`) .send({ path: testDir, name: testBranch }) .set('Accept', 'application/json') .expect('Content-Type', /json/) .expect(400) .end((err, res) => { expect(res.body.errorCode).to.be('merge-failed'); done(); }); }); it('status should list files in conflict', () => { return common.get(req, '/status', { path: testDir }).then((res) => { expect(res.inRebase).to.be(false); expect(Object.keys(res.files).length).to.be(1); expect(res.files[testFile1]).to.eql({ displayName: testFile1, fileName: testFile1, oldFileName: testFile1, isNew: false, staged: false, removed: false, conflict: true, renamed: false, type: 'text', additions: '4', deletions: '0', }); }); }); }); describe('git-api conflict merge', function () { const testBranch = 'testBranch1'; const testFile1 = 'testfile1.txt'; before(() => { return common.initRepo(req).then((dir) => { testDir = dir; return common .post(req, '/testing/createfile', { file: path.join(testDir, testFile1) }) .then(() => common.post(req, '/commit', { path: testDir, message: 'a', files: [{ name: testFile1 }], }) ) .then(() => common.post(req, '/branches', { path: testDir, name: testBranch, startPoint: 'master' }) ) .then(() => common.post(req, '/testing/changefile', { file: path.join(testDir, testFile1) }) ) .then(() => common.post(req, '/commit', { path: testDir, message: 'b', files: [{ name: testFile1 }], }) ) .then(() => common.post(req, '/checkout', { path: testDir, name: testBranch })) .then(() => common.post(req, '/testing/changefile', { file: path.join(testDir, testFile1) }) ) .then(() => common.post(req, '/commit', { path: testDir, message: 'c', files: [{ name: testFile1 }], }) ); }); }); it('should be possible to merge the branches', (done) => { req .post(`${restGit.pathPrefix}/merge`) .send({ path: testDir, with: 'master' }) .set('Accept', 'application/json') .expect('Content-Type', /json/) .expect(400) .end((err, res) => { expect(res.body.errorCode).to.be('merge-failed'); done(); }); }); it('status should list files in conflict', () => { return common.get(req, '/status', { path: testDir }).then((res) => { expect(res.inMerge).to.be(true); expect(res.commitMessage).to.be.ok(); expect(Object.keys(res.files).length).to.be(1); expect(res.files[testFile1]).to.eql({ displayName: testFile1, fileName: testFile1, oldFileName: testFile1, isNew: false, staged: false, removed: false, conflict: true, renamed: false, type: 'text', additions: '4', deletions: '0', }); }); }); it('should be possible fix the conflict', () => { return common.post(req, '/testing/changefile', { file: path.join(testDir, testFile1) }); }); it('should be possible to resolve', () => { return common.post(req, '/resolveconflicts', { path: testDir, files: [testFile1] }); }); it('should be possible continue the merge', () => { return common.post(req, '/merge/continue', { path: testDir, message: 'something' }); }); it('log should show changes on the merge commit', () => { return common.get(req, '/gitlog', { path: testDir }).then((res) => { expect(res.nodes).to.be.a('array'); expect(res.nodes.length).to.be(4); expect(res.nodes[0].additions).to.eql(1); expect(res.nodes[0].deletions).to.eql(1); expect(res.nodes[0].fileLineDiffs.length).to.be(1); expect(res.nodes[0].fileLineDiffs[0]).to.eql({ additions: 1, deletions: 1, fileName: testFile1, oldFileName: testFile1, displayName: testFile1, type: 'text', }); }); }); }); describe('git-api conflict solve by deleting', function () { const commitMessage = 'Commit 1'; const testFile1 = 'testfile1.txt'; const testBranch = 'testBranch'; before(() => { return common.initRepo(req).then((dir) => { testDir = dir; return common .post(req, '/testing/createfile', { file: path.join(testDir, testFile1) }) .then(() => common.post(req, '/commit', { path: testDir, message: commitMessage, files: [{ name: testFile1 }], }) ) .then(() => common.post(req, '/branches', { path: testDir, name: testBranch, startPoint: 'master' }) ) .then(() => common.post(req, '/testing/changefile', { file: path.join(testDir, testFile1) }) ) .then(() => common.post(req, '/commit', { path: testDir, message: commitMessage, files: [{ name: testFile1 }], }) ) .then(() => common.post(req, '/checkout', { path: testDir, name: testBranch })) .then(() => common.post(req, '/testing/changefile', { file: path.join(testDir, testFile1) }) ) .then(() => common.post(req, '/commit', { path: testDir, message: commitMessage, files: [{ name: testFile1 }], }) ); }); }); it('should be possible to rebase on master', (done) => { req .post(`${restGit.pathPrefix}/rebase`) .send({ path: testDir, onto: 'master' }) .set('Accept', 'application/json') .expect('Content-Type', /json/) .expect(400) .end((err, res) => { expect(res.body.errorCode).to.be('merge-failed'); done(); }); }); it('status should list files in conflict', () => { return common.get(req, '/status', { path: testDir }).then((res) => { expect(res.inRebase).to.be(true); expect(Object.keys(res.files).length).to.be(1); expect(res.files[testFile1]).to.eql({ displayName: testFile1, fileName: testFile1, oldFileName: testFile1, isNew: false, staged: false, removed: false, conflict: true, renamed: false, type: 'text', additions: '4', deletions: '0', }); }); }); it('should be possible to remove the file', () => { return common.post(req, '/testing/removefile', { file: path.join(testDir, testFile1) }); }); it('should be possible to resolve', () => { return common.post(req, '/resolveconflicts', { path: testDir, files: [testFile1] }); }); it('should be possible continue the rebase', () => { return common.post(req, '/rebase/continue', { path: testDir }); }); after(() => { return common.post(req, '/testing/cleanup', undefined); }); }); ================================================ FILE: test/spec.git-api.diff.js ================================================ const expect = require('expect.js'); const request = require('supertest'); const express = require('express'); const path = require('path'); const restGit = require('../source/git-api'); const common = require('./common-es6.js'); const app = express(); app.use(require('body-parser').json()); restGit.registerApi({ app: app, config: { dev: true } }); const req = request(app); describe('git-api diff', () => { let testDir, testBareDir; before(() => { return common .initRepo(req) .then((dir) => (testDir = dir)) .then(() => common.initRepo(req, { bare: true })) .then((dir) => (testBareDir = dir)); }); after(() => common.post(req, '/testing/cleanup', undefined)); const testFile = 'afile.txt'; const testFile2 = 'anotherfile.txt'; const testImage = 'icon.png'; it('diff on non existing file should fail', () => { return common.get(req, '/diff', { path: testDir, file: testFile }); }); let content; it('should be possible to create a file', () => { content = ['A', 'few', 'lines', 'of', 'content', '']; return common.post(req, '/testing/createfile', { file: path.join(testDir, testFile), content: content.join('\n'), }); }); it('should be possible to create an image file', () => { return common.post(req, '/testing/createimagefile', { file: path.join(testDir, testImage) }); }); it('diff on created file should work', () => { return common.get(req, '/diff', { path: testDir, file: testFile }).then((res) => { for (let i = 0; i < content.length; i++) { expect(res.indexOf(content[i])).to.be.above(-1); } }); }); it('diff on image file should work', () => { return common .getPng(req, '/diff/image', { path: testDir, filename: testImage, version: 'current' }) .then((res) => expect(res.toString()).to.be('png')); }); it('should be possible to commit a file', () => { return common.post(req, '/commit', { path: testDir, message: 'Init File', files: [{ name: testFile }], }); }); it('should be possible to commit an image file', () => { return common.post(req, '/commit', { path: testDir, message: 'Init Image', files: [{ name: testImage }], }); }); it('diff on first commit should work', () => { return common .get(req, '/gitlog', { path: testDir }) .then((res) => { expect(res.nodes.length).to.be(2); return common.get(req, '/diff', { path: testDir, file: testFile, sha1: res.nodes[1].sha1 }); }) .then((res) => { for (let i = 0; i < content.length; i++) { expect(res.indexOf(content[i])).to.be.above(-1); } }); }); it('diff on commited file should work', () => { return common.get(req, '/diff', { path: testDir, file: testFile }).then((res) => { expect(res).to.be.an('array'); expect(res.length).to.be(0); }); }); it('diff on commited image file should work', () => { return common .getPng(req, '/diff/image', { path: testDir, filename: testImage, version: 'current' }) .then((res) => expect(res.toString()).to.be('png')); }); it('should be possible to modify a file', () => { content.splice(2, 0, 'more'); return common.post(req, '/testing/changefile', { file: path.join(testDir, testFile), content: content.join('\n'), }); }); it('should be possible to modify an image file', () => { return common.post(req, '/testing/changeimagefile', { file: path.join(testDir, testImage) }); }); it('diff on modified file should work', () => { return common.get(req, '/diff', { path: testDir, file: testFile }).then((res) => { expect(res.indexOf('diff --git a/afile.txt b/afile.txt')).to.be.above(-1); expect(res.indexOf('+more')).to.be.above(-1); }); }); it('diff on file commit should work if file is changing', () => { return common .get(req, '/gitlog', { path: testDir }) .then((res) => { expect(res.nodes.length).to.be(2); return common.get(req, '/diff', { path: testDir, file: testFile, sha1: res.nodes[1].sha1 }); }) .then((res) => { expect(res.indexOf('diff --git a/afile.txt b/afile.txt')).to.be.above(-1); expect(res.indexOf('+more')).to.be(-1); }); }); it('getting current image file should work', () => { return common .getPng(req, '/diff/image', { path: testDir, filename: testImage, version: 'current' }) .then((res) => expect(res.toString()).to.be('png ~~')); }); it('getting previous image file should work', () => { return common .getPng(req, '/diff/image', { path: testDir, filename: testImage, version: 'HEAD' }) .then((res) => expect(res.toString()).to.be('png')); }); it('should be possible to rename a modified file', () => { return common.post(req, '/testing/git', { path: testDir, command: ['mv', testFile, testFile2], }); }); it('diff on renamed and modified file should work', () => { return common .get(req, '/diff', { path: testDir, file: testFile2, oldFile: testFile }) .then((res) => { expect(res.indexOf('diff --git a/afile.txt b/anotherfile.txt')).to.be.above(-1); expect(res.indexOf('+more')).to.be.above(-1); }); }); it('should be possible to commit the renamed and modified file', () => { return common.post(req, '/commit', { path: testDir, message: 'Move and Change', files: [{ name: testFile2 }], }); }); it('diff on commit with renamed and modified file should work', () => { return common .get(req, '/gitlog', { path: testDir }) .then((res) => { expect(res.nodes.length).to.be(3); return common.get(req, '/diff', { path: testDir, file: testFile2, oldFile: testFile, sha1: res.nodes[0].sha1, }); }) .then((res) => { for (let i = 0; i < content.length; i++) { expect(res.indexOf(content[i])).to.be.above(-1); } }); }); it('removing a test file should work', () => { return common.post(req, '/testing/removefile', { file: path.join(testDir, testFile2) }); }); it('should be possible to commit an image file for removal', () => { return common.post(req, '/commit', { path: testDir, message: 'Init', files: [{ name: testImage }], }); }); it('removing a test image file should work', () => { return common.post(req, '/testing/removefile', { file: path.join(testDir, testImage) }); }); it('diff on removed file should work', () => { return common.get(req, '/diff', { path: testDir, file: testFile2 }).then((res) => { expect(res.indexOf('deleted file')).to.be.above(-1); expect(res.indexOf('@@ -1,6 +0,0 @@')).to.be.above(-1); }); }); it('getting previous image file should work after removal', () => { return common .getPng(req, '/diff/image', { path: testDir, filename: testImage, version: 'HEAD' }) .then((res) => expect(res.toString()).to.be('png ~~')); }); it('diff on bare repository file should work', () => { // first add remote and push all commits return common .post(req, '/remotes/barerepository', { path: testDir, url: testBareDir }) .then(() => common.post(req, '/push', { path: testDir, remote: 'barerepository' })) .then(() => common.get(req, '/gitlog', { path: testDir })) .then((res) => { // find a commit which contains the testFile const commit = res.nodes.filter((commit) => commit.fileLineDiffs.some((lineDiff) => lineDiff.fileName == testFile) )[0]; return common.get(req, '/diff', { path: testDir, sha1: commit.sha1, file: testFile }); }); }); }); ================================================ FILE: test/spec.git-api.discardchanges.js ================================================ const expect = require('expect.js'); const request = require('supertest'); const express = require('express'); const path = require('path'); const restGit = require('../source/git-api'); const common = require('./common-es6.js'); const app = express(); app.use(require('body-parser').json()); restGit.registerApi({ app: app, config: { dev: true } }); const req = request(app); describe('git-api discardchanges', () => { after(() => common.post(req, '/testing/cleanup')); it('should be able to discard a new file', () => { return common.createSmallRepo(req).then((dir) => { const testFile1 = 'test.txt'; return common .post(req, '/testing/createfile', { file: path.join(dir, testFile1) }) .then(() => common.post(req, '/discardchanges', { path: dir, file: testFile1 })) .then(() => common.get(req, '/status', { path: dir })) .then((res) => expect(Object.keys(res.files).length).to.be(0)); }); }); it('should be able to discard a changed file', () => { return common.createSmallRepo(req).then((dir) => { const testFile1 = 'test.txt'; return common .post(req, '/testing/createfile', { file: path.join(dir, testFile1) }) .then(() => common.post(req, '/commit', { path: dir, message: 'lol', files: [{ name: testFile1 }] }) ) .then(() => common.post(req, '/testing/changefile', { file: path.join(dir, testFile1) })) .then(() => common.post(req, '/discardchanges', { path: dir, file: testFile1 })) .then(() => common.get(req, '/status', { path: dir })) .then((res) => expect(Object.keys(res.files).length).to.be(0)); }); }); it('should be able to discard a removed file', () => { return common.createSmallRepo(req).then((dir) => { const testFile1 = 'test.txt'; return common .post(req, '/testing/createfile', { file: path.join(dir, testFile1) }) .then(() => common.post(req, '/commit', { path: dir, message: 'lol', files: [{ name: testFile1 }] }) ) .then(() => common.post(req, '/testing/removefile', { file: path.join(dir, testFile1) })) .then(() => common.post(req, '/discardchanges', { path: dir, file: testFile1 })) .then(() => common.get(req, '/status', { path: dir })) .then((res) => expect(Object.keys(res.files).length).to.be(0)); }); }); it('should be able to discard a new and staged file', () => { return common.createSmallRepo(req).then((dir) => { const testFile1 = 'test.txt'; return common .post(req, '/testing/createfile', { file: path.join(dir, testFile1) }) .then(() => common.post(req, '/testing/git', { path: dir, command: ['add', testFile1] })) .then(() => common.post(req, '/discardchanges', { path: dir, file: testFile1 })) .then(() => common.get(req, '/status', { path: dir })) .then((res) => expect(Object.keys(res.files).length).to.be(0)); }); }); it('should be able to discard a staged and removed file', () => { return common.createSmallRepo(req).then((dir) => { const testFile1 = 'test.txt'; return common .post(req, '/testing/createfile', { file: path.join(dir, testFile1) }) .then(() => common.post(req, '/testing/git', { path: dir, command: ['add', testFile1] })) .then(() => common.post(req, '/testing/removefile', { file: path.join(dir, testFile1) })) .then(() => common.post(req, '/discardchanges', { path: dir, file: testFile1 })) .then(() => common.get(req, '/status', { path: dir })) .then((res) => expect(Object.keys(res.files).length).to.be(0)); }); }); it('should be able to discard discard submodule changes', function () { const testFile = 'smalltestfile.txt'; const submodulePath = 'subrepo'; return common .createSmallRepo(req) .then((dir) => { return common.createSmallRepo(req).then((subrepoDir) => { return common .post(req, '/submodules/add', { submoduleUrl: subrepoDir, submodulePath: submodulePath, path: dir, }) .then(() => dir); }); }) .then((dir) => { return common .post(req, '/commit', { path: dir, message: 'lol', files: [{ name: '.gitmodules' }] }) .then(() => common.post(req, '/testing/changefile', { file: path.join(dir, submodulePath, testFile), }) ) .then(() => common.post(req, '/discardchanges', { path: dir, file: submodulePath })) .then(() => common.get(req, '/status', { path: dir })) .then((res) => expect(Object.keys(res.files).length).to.be(0)); }); }); // Need to make discardchanges even more powerful to handle this /*it('should be able to discard a commited, staged and removed file', () => { common.createSmallRepo(req, function(dir) { if (err) return done(err); const testFile1 = 'test.txt'; () => {common.post(req, '/testing/createfile', { file: path.join(dir, testFile1) }); () => {common.post(req, '/commit', { path: dir, message: 'lol', files: [{ name: testFile1 }] }); () => {common.post(req, '/testing/changefile', { file: path.join(dir, testFile1) }); () => {common.post(req, '/testing/git', { path: dir, command: ['add', testFile1] }); () => {common.post(req, '/testing/removefile', { file: path.join(dir, testFile1) }); () => {common.post(req, '/discardchanges', { path: dir, file: testFile1 }); () => {common.get(req, '/status', { path: dir }).then((res) => { if (err) return done(err); expect(Object.keys(res.files).length).to.be(0); done(); }); }, ], done); }); });*/ }); ================================================ FILE: test/spec.git-api.ignorefile.js ================================================ const expect = require('expect.js'); const request = require('supertest'); const express = require('express'); const fs = require('fs').promises; const path = require('path'); const restGit = require('../source/git-api'); const common = require('./common-es6.js'); const app = express(); app.use(require('body-parser').json()); restGit.registerApi({ app: app, config: { dev: true } }); const req = request(app); describe('git-api: test ignorefile call', () => { after(() => common.post(req, '/testing/cleanup')); it('Add a file to .gitignore file through api call', () => { return common.createSmallRepo(req).then((dir) => { const testFile = 'test.txt'; // Create .gitignore file prior to append return fs .writeFile(path.join(dir, '.gitignore'), 'test git ignore file...') .then(() => common.post(req, '/testing/createfile', { file: path.join(dir, testFile) })) .then(() => common.post(req, '/ignorefile', { path: dir, file: testFile })) .then(() => { return common.get(req, '/status', { path: dir }).then((res) => { expect(Object.keys(res.files).toString()).to.be('.gitignore'); }); }) .then(() => { return fs.readFile(path.join(dir, '.gitignore'), { encoding: 'utf8' }).then((data) => { if (data.indexOf(testFile) < 0) { throw new Error('Test file is not added to the .gitignore file.'); } }); }); }); }); it('Add a file to .gitignore file through api call when .gitignore is missing', () => { return common.createSmallRepo(req).then((dir) => { const testFile = 'test.txt'; return common .post(req, '/testing/createfile', { file: path.join(dir, testFile) }) .then(() => common.post(req, '/ignorefile', { path: dir, file: testFile })) .then(() => { return common.get(req, '/status', { path: dir }).then((res) => { expect(Object.keys(res.files).toString()).to.be('.gitignore'); }); }) .then(() => { return fs.readFile(path.join(dir, '.gitignore'), { encoding: 'utf8' }).then((data) => { if (data.indexOf(testFile) < 0) { throw new Error('Test file is not added to the .gitignore file.'); } }); }); }); }); it('Attempt to add a file where similar name alread exist in .gitignore through api call', () => { return common.createSmallRepo(req).then((dir) => { const testFile = 'test.txt'; // add part of file name to gitignore return fs .appendFile(path.join(dir, '.gitignore'), testFile.split('.')[0]) .then(() => common.post(req, '/testing/createfile', { file: path.join(dir, testFile) })) .then(() => common.post(req, '/ignorefile', { path: dir, file: testFile })) .then(() => { return common.get(req, '/status', { path: dir }).then((res) => { expect(Object.keys(res.files).toString()).to.be('.gitignore'); }); }) .then(() => { return fs.readFile(path.join(dir, '.gitignore'), { encoding: 'utf8' }).then((data) => { if (data.indexOf(testFile) < 0) { throw new Error('Test file is not added to the .gitignore file.'); } }); }); }); }); }); ================================================ FILE: test/spec.git-api.js ================================================ const expect = require('expect.js'); const request = require('supertest'); const express = require('express'); const fs = require('fs').promises; const path = require('path'); const restGit = require('../source/git-api'); const common = require('./common-es6.js'); const mkdirp = require('mkdirp').mkdirp; const app = express(); app.use(require('body-parser').json()); restGit.registerApi({ app: app, config: { dev: true } }); let testDir; let gitConfig; const req = request(app); const commitMessage = 'test'; const testFile = 'somefile'; const testFile2 = 'my test.txt'; const testSubDir = 'sub'; const testFile3 = path.join(testSubDir, 'testy.txt').replace('\\', '/'); const commitMessage3 = 'commit3'; const commitMessage4 = 'Removed some file'; const testFile4 = path.join(testSubDir, 'renamed.txt').replace(/\\/, '/'); describe('git-api', () => { before('creating test dir should work', () => { return common.post(req, '/testing/createtempdir').then((res) => { expect(res.path).to.be.ok(); return fs.realpath(res.path).then((dir) => { testDir = dir; }); }); }); after(() => common.post(req, '/testing/cleanup')); it('gitconfig should return config data', () => { return common.get(req, '/gitconfig', { path: testDir }).then((res) => { expect(res).to.be.an('object'); expect(res['user.name']).to.be.ok(); expect(res['user.email']).to.be.ok(); gitConfig = res; }); }); it('status should fail in uninited directory', (done) => { req .get(`${restGit.pathPrefix}/status`) .query({ path: path.join(testDir, 'nowhere') }) .set('Accept', 'application/json') .expect('Content-Type', /json/) .expect(400) .end((err, res) => { expect(res.body.errorCode).to.be('no-such-path'); done(); }); }); it('quickstatus should say uninited in uninited directory', () => { return common .get(req, '/quickstatus', { path: testDir }) .then((res) => expect(res).to.eql({ type: 'uninited', subRepos: [], gitRootPath: testDir })); }); it('quickstatus should say uninited with sub repos if it has sub repos', () => { let testDirWithSubRepos; let subRepo1, subRepo2; return common .post(req, '/testing/createtempdir') .then((res) => { expect(res.path).to.be.ok(); return fs.realpath(res.path).then((dir) => { testDirWithSubRepos = dir; }); }) .then(() => { subRepo1 = path.join(testDirWithSubRepos, 'repo1'); return fs.mkdir(subRepo1).then(() => common.post(req, '/init', { path: subRepo1 })); }) .then(() => { subRepo2 = path.join(testDirWithSubRepos, 'repo2'); return fs.mkdir(subRepo2).then(() => common.post(req, '/init', { path: subRepo2 })); }) .then(() => { return common.get(req, '/quickstatus', { path: testDirWithSubRepos }).then((res) => expect(res).to.eql({ type: 'uninited', subRepos: [subRepo1, subRepo2], gitRootPath: testDirWithSubRepos, }) ); }); }); it('status should fail in non-existing directory', () => { return common .get(req, '/status', { path: testDir }) .catch((e) => expect(e.errorCode).to.be('no-such-path')); }); it('quickstatus should say false in non-existing directory', () => { return common .get(req, '/quickstatus', { path: path.join(testDir, 'nowhere') }) .then((res) => expect(res).to.eql({ type: 'no-such-path', gitRootPath: path.join(testDir, 'nowhere') }) ); }); it('init should succeed in uninited directory', () => { return common.post(req, '/init', { path: testDir }); }); it('status should succeed in inited directory', () => { return common.get(req, '/status', { path: testDir }); }); it('quickstatus should say inited in inited directory', () => { return common .get(req, '/quickstatus', { path: testDir }) .then((res) => expect(res).to.eql({ type: 'inited', gitRootPath: testDir })); }); it("commit should fail on when there's no files to commit", (done) => { req .post(`${restGit.pathPrefix}/commit`) .send({ path: testDir, message: 'test', files: [] }) .set('Accept', 'application/json') .expect('Content-Type', /json/) .expect(400) .end(done); }); // testFile it('log should be empty before first commit', () => { return common.get(req, '/gitlog', { path: testDir }).then((res) => { expect(res.nodes).to.be.a('array'); expect(res.nodes.length).to.be(0); }); }); it('head should be empty before first commit', () => { return common.get(req, '/head', { path: testDir }).then((res) => { expect(res).to.be.a('array'); expect(res.length).to.be(0); }); }); it('commit should fail on non-existing file', (done) => { req .post(`${restGit.pathPrefix}/commit`) .send({ path: testDir, message: 'test', files: [{ name: testFile }] }) .set('Accept', 'application/json') .expect('Content-Type', /json/) .expect(400) .end(done); }); it('creating test file should work', () => { return common.post(req, '/testing/createfile', { file: path.join(testDir, testFile) }); }); it('status should list untracked file', () => { return common.get(req, '/status', { path: testDir }).then((res) => { expect(Object.keys(res.files).length).to.be(1); expect(res.files[testFile]).to.eql({ displayName: testFile, fileName: testFile, oldFileName: testFile, isNew: true, staged: false, removed: false, conflict: false, renamed: false, type: 'text', additions: '-', deletions: '-', }); }); }); // commitMessage it('commit should fail without commit message', (done) => { req .post(`${restGit.pathPrefix}/commit`) .send({ path: testDir, message: undefined, files: [{ name: testFile }] }) .set('Accept', 'application/json') .expect('Content-Type', /json/) .expect(400) .end(done); }); it("commit should succeed when there's files to commit", () => { return common.post(req, '/commit', { path: testDir, message: commitMessage, files: [{ name: testFile }], }); }); it('log should show latest commit', () => { return common.get(req, '/gitlog', { path: testDir }).then((res) => { expect(res.nodes).to.be.a('array'); expect(res.nodes.length).to.be(1); expect(res.nodes[0].message.indexOf(commitMessage)).to.be(0); expect(res.nodes[0].authorName).to.be(gitConfig['user.name']); expect(res.nodes[0].authorEmail).to.be(gitConfig['user.email']); }); }); it('head should show latest commit', () => { return common.get(req, '/head', { path: testDir }).then((res) => { expect(res).to.be.a('array'); expect(res.length).to.be(1); expect(res[0].message.indexOf(commitMessage)).to.be(0); expect(res[0].authorName).to.be(gitConfig['user.name']); expect(res[0].authorEmail).to.be(gitConfig['user.email']); }); }); it('modifying a test file should work', () => { return common.post(req, '/testing/changefile', { file: path.join(testDir, testFile) }); }); it('modified file should show up in status', () => { return common.get(req, '/status', { path: testDir }).then((res) => { expect(Object.keys(res.files).length).to.be(1); expect(res.files[testFile]).to.eql({ displayName: testFile, fileName: testFile, oldFileName: testFile, isNew: false, staged: false, removed: false, conflict: false, renamed: false, type: 'text', additions: '1', deletions: '1', }); }); }); it('discarding changes should work', () => { return common.post(req, '/discardchanges', { path: testDir, file: testFile }); }); it('modifying a test file should work part deux', () => { return common.post(req, '/testing/changefile', { file: path.join(testDir, testFile) }); }); it('commit ammend should work', () => { return common.post(req, '/commit', { path: testDir, message: commitMessage, files: [{ name: testFile }], amend: true, }); }); it('amend should not produce additional log-entry', () => { return common .get(req, '/gitlog', { path: testDir }) .then((res) => expect(res.nodes.length).to.be(1)); }); // testFile2 it('creating a multi word test file should work', () => { return common.post(req, '/testing/createfile', { file: path.join(testDir, testFile2) }); }); it('status should list the new file', () => { return common.get(req, '/status', { path: testDir }).then((res) => { expect(Object.keys(res.files).length).to.be(1); expect(res.files[testFile2]).to.eql({ displayName: testFile2, fileName: testFile2, oldFileName: testFile2, isNew: true, staged: false, removed: false, conflict: false, renamed: false, type: 'text', additions: '-', deletions: '-', }); }); }); it('discarding the new file should work', (done) => { req .post(`${restGit.pathPrefix}/discardchanges`) .send({ path: testDir, file: testFile2 }) .set('Accept', 'application/json') .expect('Content-Type', /json/) .expect(200) .end(done); }); // testSubDir it('creating test sub dir should work', () => { return common.post(req, '/createdir', { dir: path.join(testDir, testSubDir) }); }); it('creating test multi layer dir should work', () => { return common.post(req, '/createdir', { dir: path.join(testDir, `${testSubDir}test/moretest/andmore`), }); }); // testFile3 it('creating a test file in sub dir should work', () => { return common.post(req, '/testing/createfile', { file: path.join(testDir, testFile3) }); }); it('status should list the new file once again', () => { return common.get(req, '/status', { path: testDir }).then((res) => { expect(Object.keys(res.files).length).to.be(1); expect(res.files[testFile3]).to.eql({ displayName: testFile3, fileName: testFile3, oldFileName: testFile3, isNew: true, staged: false, removed: false, conflict: false, renamed: false, type: 'text', additions: '-', deletions: '-', }); }); }); // commitMessage3 it('commit should succeed with file in sub dir', () => { return common.post(req, '/commit', { path: testDir, message: commitMessage3, files: [{ name: testFile3 }], }); }); it('log should show last commit', () => { return common.get(req, '/gitlog', { path: testDir }).then((res) => { expect(res.nodes).to.be.a('array'); expect(res.nodes.length).to.be(2); const HEAD = res.nodes[0]; expect(HEAD.message.indexOf(commitMessage3)).to.be(0); expect(HEAD.authorDate).to.be.a('string'); expect(HEAD.authorName).to.be(gitConfig['user.name']); expect(HEAD.authorEmail).to.be(gitConfig['user.email']); expect(HEAD.commitDate).to.be.a('string'); expect(HEAD.committerName).to.be(gitConfig['user.name']); expect(HEAD.committerEmail).to.be(gitConfig['user.email']); expect(HEAD.sha1).to.be.ok(); }); }); it('removing a test file should work', () => { return common.post(req, '/testing/removefile', { file: path.join(testDir, testFile) }); }); it('status should list the removed file', () => { return common.get(req, '/status', { path: testDir }).then((res) => { expect(Object.keys(res.files).length).to.be(1); expect(res.files[testFile]).to.eql({ displayName: testFile, fileName: testFile, oldFileName: testFile, isNew: false, staged: false, removed: true, conflict: false, renamed: false, type: 'text', additions: '0', deletions: '2', }); }); }); // commitMessage4 it('commit on removed file should work', () => { return common.post(req, '/commit', { path: testDir, message: commitMessage4, files: [{ name: testFile }], }); }); it('status should list nothing', () => { return common .get(req, '/status', { path: testDir }) .then((res) => expect(Object.keys(res.files).length).to.be(0)); }); // testFile4 it('renaming a file should work', () => { return common.post(req, '/testing/git', { path: testDir, command: ['mv', testFile3, testFile4], }); }); it('status should list the renamed file', () => { return common.get(req, '/status', { path: testDir }).then((res) => { expect(Object.keys(res.files).length).to.be(1); expect(res.files[testFile4]).to.eql({ displayName: `${testFile3} → ${testFile4}`, fileName: testFile4, oldFileName: testFile3, isNew: false, staged: false, removed: false, conflict: false, renamed: true, type: 'text', additions: '0', deletions: '0', }); }); }); it('log with limit should only return specified number of items', () => { return common.get(req, '/gitlog', { path: testDir, limit: 1 }).then((res) => { expect(res.nodes).to.be.a('array'); expect(res.nodes.length).to.be(1); }); }); it('get the baserepopath without base repo should work', (done) => { const baseRepoPathTestDir = path.join(testDir, 'depth1', 'depth2'); mkdirp(baseRepoPathTestDir).then(() => { return common.get(req, '/baserepopath', { path: baseRepoPathTestDir }).then((res) => { // Some oses uses symlink and path will be different as git will return resolved symlink expect(res.path).to.contain(testDir); done(); }); }); }); it('test gitignore api endpoint', () => { return common .put(req, '/gitignore', { path: testDir, data: 'abc' }) .then(() => common.get(req, '/gitignore', { path: testDir })) .then((res) => expect(res.content).to.be('abc')) .then(() => common.put(req, '/gitignore', { path: testDir, data: '' })) .then(() => common.get(req, '/gitignore', { path: testDir })) .then((res) => expect(res.content).to.be('')); }); }); ================================================ FILE: test/spec.git-api.patch.js ================================================ const expect = require('expect.js'); const request = require('supertest'); const express = require('express'); const path = require('path'); const restGit = require('../source/git-api'); const common = require('./common-es6.js'); const md5 = require('blueimp-md5'); const app = express(); app.use(require('body-parser').json()); restGit.registerApi({ app: app, config: { dev: true } }); let testDir; const req = request(app); const testPatch = (req, testDir, testFileName, contentsToPatch, files) => { // testDir = '/tmp/testdir'; return common .post(req, '/testing/createfile', { file: path.join(testDir, testFileName), content: contentsToPatch[0], }) .then(() => common.post(req, '/commit', { path: testDir, message: `a commit for ${testFileName}`, files: [{ name: testFileName }], }) ) .then(() => common.post(req, '/testing/changefile', { file: path.join(testDir, testFileName), content: contentsToPatch[1], }) ) .then(() => common.post(req, '/commit', { path: testDir, message: `patched commit ${testFileName}`, files: files, }) ); }; const getPatchLineList = (size, notSelected) => { const patchLineList = []; for (let n = 0; n < size; n++) { patchLineList.push(false); } if (notSelected) { for (let m = 0; m < notSelected.length; m++) { patchLineList[notSelected[m]] = true; } } return patchLineList; }; const getContentsToPatch = (size, toChange) => { let content = ''; let changedContent = ''; for (let n = 0; n < size; n++) { content += n + '\n'; changedContent += n; if (!toChange || toChange.indexOf(n) > -1) { changedContent += '!'; } changedContent += '\n'; } return [content, changedContent]; }; const getContentsToPatchWithAdd = (size, numLinesToAdd) => { let content = ''; let changedContent = ''; let n = 0; while (n < size) { content += n + '\n'; changedContent += n + '\n'; n++; } while (n < size + numLinesToAdd) { changedContent += n + '\n'; n++; } return [content, changedContent]; }; const getContentsToPatchWithDelete = (size, numLinesToDelete) => { let content = ''; let changedContent = ''; let n = 0; while (n < size) { content += n + '\n'; if (n < size - numLinesToDelete) { changedContent += n + '\n'; } n++; } return [content, changedContent]; }; describe('git-api: test patch api', () => { it('creating test dir should work', () => { return common.post(req, '/testing/createtempdir').then((res) => { expect(res.path).to.be.ok(); testDir = res.path; }); }); it('init test dir should work', () => { return common.post(req, '/init', { path: testDir, bare: false }); }); /////////////////////////////////////////////////////// // Single diff block diff, (git apply uses diff -U3) // /////////////////////////////////////////////////////// it('Create a file with 10 lines, commit, change each 10 lines, and commit patch with all selected.', () => { const testFileName = md5(Date.now()); const testFileSize = 10; const contentsToPatch = getContentsToPatch(testFileSize); const patchLineList = []; for (let n = 0; n < testFileSize * 2; n++) { patchLineList.push(true); } return testPatch(req, testDir, testFileName, contentsToPatch, [ { name: testFileName, patchLineList: patchLineList }, ]); }); it('Create a file with 10 lines, commit, change each 10 lines, and commit patch with none selected.', () => { const testFileName = md5(Date.now()); const testFileSize = 10; const patchLineList = getPatchLineList(testFileSize * 2); const contentsToPatch = getContentsToPatch(testFileSize); return testPatch(req, testDir, testFileName, contentsToPatch, [ { name: testFileName, patchLineList: patchLineList }, ]); }); it('10 lines, 10 edit, 0~2 selected', () => { const testFileName = md5(Date.now()); const testFileSize = 10; const patchLineList = getPatchLineList(testFileSize * 2, [0, 1, 2]); const contentsToPatch = getContentsToPatch(testFileSize); return testPatch(req, testDir, testFileName, contentsToPatch, [ { name: testFileName, patchLineList: patchLineList }, ]); }); it('10 lines, 10 edit, 18~19 selected', () => { const testFileName = md5(Date.now()); const testFileSize = 10; const patchLineList = getPatchLineList(testFileSize * 2, [18, 19]); const contentsToPatch = getContentsToPatch(testFileSize); return testPatch(req, testDir, testFileName, contentsToPatch, [ { name: testFileName, patchLineList: patchLineList }, ]); }); it('10 lines, 10 edit, 0~2 and 18~19 selected', () => { const testFileName = md5(Date.now()); const testFileSize = 10; const patchLineList = getPatchLineList(testFileSize * 2, [0, 1, 2, 18, 19]); const contentsToPatch = getContentsToPatch(testFileSize); return testPatch(req, testDir, testFileName, contentsToPatch, [ { name: testFileName, patchLineList: patchLineList }, ]); }); it('10 lines, 10 edit, 5~7 selected', () => { const testFileName = md5(Date.now()); const testFileSize = 10; const patchLineList = getPatchLineList(testFileSize * 2, [5, 6, 7]); const contentsToPatch = getContentsToPatch(testFileSize); return testPatch(req, testDir, testFileName, contentsToPatch, [ { name: testFileName, patchLineList: patchLineList }, ]); }); it('30 lines, 30 edit, 0~2 and 28 ~ 29 selected', () => { const testFileName = md5(Date.now()); const testFileSize = 30; const patchLineList = getPatchLineList(testFileSize * 2, [0, 1, 2, 28, 29]); const contentsToPatch = getContentsToPatch(testFileSize); return testPatch(req, testDir, testFileName, contentsToPatch, [ { name: testFileName, patchLineList: patchLineList }, ]); }); it('30 lines, 30 edit, 0~2, 28~29, 58~59 selected', () => { const testFileName = md5(Date.now()); const testFileSize = 30; const patchLineList = getPatchLineList(testFileSize * 2, [0, 1, 2, 28, 29, 57, 58, 59]); const contentsToPatch = getContentsToPatch(testFileSize); return testPatch(req, testDir, testFileName, contentsToPatch, [ { name: testFileName, patchLineList: patchLineList }, ]); }); it('30 lines, 30 edit, 6~8, 16~18 and 58 selected', () => { const testFileName = md5(Date.now()); const testFileSize = 30; const patchLineList = getPatchLineList(testFileSize * 2, [6, 7, 8, 16, 17, 18, 58]); const contentsToPatch = getContentsToPatch(testFileSize); return testPatch(req, testDir, testFileName, contentsToPatch, [ { name: testFileName, patchLineList: patchLineList }, ]); }); it('30 lines, 30 edit, 12~15 and 17~19 selected', () => { const testFileName = md5(Date.now()); const testFileSize = 30; const patchLineList = getPatchLineList(testFileSize * 2, [12, 13, 14, 15, 17, 18, 19]); const contentsToPatch = getContentsToPatch(testFileSize); return testPatch(req, testDir, testFileName, contentsToPatch, [ { name: testFileName, patchLineList: patchLineList }, ]); }); it('30 lines, 12~19 edit, 0~7, 10~16 selected ', () => { const testFileName = md5(Date.now()); const testFileSize = 30; const linesToChange = [12, 13, 14, 15, 16, 17, 18, 19]; const contentsToPatch = getContentsToPatch(testFileSize, linesToChange); const patchLineList = getPatchLineList( linesToChange.length * 2, [0, 1, 2, 3, 4, 5, 6, 7, 10, 11, 12, 13, 14, 15, 16] ); return testPatch(req, testDir, testFileName, contentsToPatch, [ { name: testFileName, patchLineList: patchLineList }, ]); }); ////////////////////////////////////////////////////// // Multi diff block diff, (git apply uses diff -U3) // ////////////////////////////////////////////////////// it('30 lines, 2~4, 12~14, 22~24 edit, all selected', () => { const testFileName = md5(Date.now()); const testFileSize = 30; const linesToChange = [2, 3, 4, 12, 13, 14, 22, 23, 24]; const contentsToPatch = getContentsToPatch(testFileSize, linesToChange); const patchLineList = getPatchLineList( linesToChange.length * 2, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17] ); return testPatch(req, testDir, testFileName, contentsToPatch, [ { name: testFileName, patchLineList: patchLineList }, ]); }); it('30 lines, 2~4, 12~14, 22~24 edit, 0~5, 12~17 selected', () => { const testFileName = md5(Date.now()); const testFileSize = 30; const linesToChange = [2, 3, 4, 12, 13, 14, 22, 23, 24]; const contentsToPatch = getContentsToPatch(testFileSize, linesToChange); const patchLineList = getPatchLineList( linesToChange.length * 2, [0, 1, 2, 3, 4, 5, 12, 13, 14, 15, 16, 17] ); return testPatch(req, testDir, testFileName, contentsToPatch, [ { name: testFileName, patchLineList: patchLineList }, ]); }); it('30 lines, 2~4, 12~14, 22~24 edit, 6~11 selected', () => { const testFileName = md5(Date.now()); const testFileSize = 30; const linesToChange = [2, 3, 4, 12, 13, 14, 22, 23, 24]; const contentsToPatch = getContentsToPatch(testFileSize, linesToChange); const patchLineList = getPatchLineList(linesToChange.length * 2, [6, 7, 8, 9, 10, 11]); return testPatch(req, testDir, testFileName, contentsToPatch, [ { name: testFileName, patchLineList: patchLineList }, ]); }); it('30 lines, 2~4, 12~14, 22~24 edit, none selected', () => { const testFileName = md5(Date.now()); const testFileSize = 30; const linesToChange = [2, 3, 4, 12, 13, 14, 22, 23, 24]; const contentsToPatch = getContentsToPatch(testFileSize, linesToChange); const patchLineList = getPatchLineList(linesToChange.length * 2); return testPatch(req, testDir, testFileName, contentsToPatch, [ { name: testFileName, patchLineList: patchLineList }, ]); }); it('30 lines, 12~14, 16~18 edit, 6~11 selected', () => { const testFileName = md5(Date.now()); const testFileSize = 30; const linesToChange = [12, 13, 14, 22, 23, 24]; const contentsToPatch = getContentsToPatch(testFileSize, linesToChange); const patchLineList = getPatchLineList(linesToChange.length * 2, [6, 7, 8, 9, 10, 11]); return testPatch(req, testDir, testFileName, contentsToPatch, [ { name: testFileName, patchLineList: patchLineList }, ]); }); // added diff only, (git apply uses diff -U3) it('10 lines, add 5 lines, select 0~1, 5', () => { const testFileName = md5(Date.now()); const testFileSize = 10; const linesToAdd = 5; const contentsToPatch = getContentsToPatchWithAdd(testFileSize, linesToAdd); const patchLineList = getPatchLineList(linesToAdd, [0, 1, 5]); return testPatch(req, testDir, testFileName, contentsToPatch, [ { name: testFileName, patchLineList: patchLineList }, ]); }); // deleted diff only, (git apply uses diff -U3) it('10 lines, delete 5 lines, select 0~1, 5', () => { const testFileName = md5(Date.now()); const testFileSize = 10; const linesToDelete = 5; const contentsToPatch = getContentsToPatchWithDelete(testFileSize, linesToDelete); const patchLineList = getPatchLineList(linesToDelete, [0, 1, 5]); return testPatch(req, testDir, testFileName, contentsToPatch, [ { name: testFileName, patchLineList: patchLineList }, ]); }); }); ================================================ FILE: test/spec.git-api.remote.js ================================================ const expect = require('expect.js'); const request = require('supertest'); const express = require('express'); const path = require('path'); const restGit = require('../source/git-api'); const common = require('./common-es6.js'); const app = express(); app.use(require('body-parser').json()); restGit.registerApi({ app: app, config: { dev: true } }); let testDirLocal1, testDirLocal2, testDirRemote; const req = request(app); describe('git-api remote', function () { this.timeout(4000); before('creating test dirs should work', () => { return common .post(req, '/testing/createtempdir') .then((dir) => { testDirLocal1 = dir.path; }) .then(() => common.post(req, '/testing/createtempdir')) .then((dir) => { testDirLocal2 = dir.path; }) .then(() => common.post(req, '/testing/createtempdir')) .then((dir) => { testDirRemote = dir.path; }); }); after(() => common.post(req, '/testing/cleanup')); it('init a bare "remote" test dir should work', () => { return common.post(req, '/init', { path: testDirRemote, bare: true }); }); it('remotes in no-remotes-repo should be zero', () => { return common .get(req, '/remotes', { path: testDirRemote }) .then((res) => expect(res.length).to.be(0)); }); it('cloning "remote" to "local1" should work', () => { return common.post(req, '/clone', { path: testDirLocal1, url: testDirRemote, destinationDir: '.', }); }); it('remotes in cloned-repo should be one', () => { return common.get(req, '/remotes', { path: testDirLocal1 }).then((res) => { expect(res.length).to.be(1); const remote = res[0]; expect(remote.name).to.be('origin'); expect(remote.pushUrl).to.be(testDirRemote); expect(remote.fetchUrl).to.be(testDirRemote); }); }); it('remote/origin in cloned-repo should work', () => { return common .get(req, '/remotes/origin', { path: testDirLocal1 }) .then((res) => expect(res.address).to.be(testDirRemote)); }); it('creating a commit in "local1" repo should work', () => { const testFile = path.join(testDirLocal1, 'testfile1.txt'); return common.post(req, '/testing/createfile', { file: testFile }).then(() => { return common.post(req, '/commit', { path: testDirLocal1, message: 'Init', files: [{ name: testFile }], }); }); }); it('log in "local1" should show the init commit', () => { return common.get(req, '/gitlog', { path: testDirLocal1 }).then((res) => { expect(res.nodes).to.be.a('array'); expect(res.nodes.length).to.be(1); const init = res.nodes[0]; expect(init.message.indexOf('Init')).to.be(0); expect(init.refs).to.contain('HEAD'); expect(init.refs).to.contain('refs/heads/master'); }); }); it('pushing form "local1" to "remote" should work', () => { return common.post(req, '/push', { path: testDirLocal1, remote: 'origin' }); }); it('cloning "remote" to "local2" should work', () => { return common.post(req, '/clone', { path: testDirLocal2, url: testDirRemote, destinationDir: '.', isRecursiveSubmodule: true, }); }); it('log in "local2" should show the init commit', () => { return common.get(req, '/gitlog', { path: testDirLocal2 }).then((res) => { expect(res.nodes).to.be.a('array'); expect(res.nodes.length).to.be(1); const init = res.nodes[0]; expect(init.message.indexOf('Init')).to.be(0); expect(init.refs).to.contain('HEAD'); expect(init.refs).to.contain('refs/heads/master'); expect(init.refs).to.contain('refs/remotes/origin/master'); expect(init.refs).to.contain('refs/remotes/origin/HEAD'); }); }); it('creating and pushing a commit in "local1" repo should work', () => { const testFile = path.join(testDirLocal1, 'testfile2.txt'); return common .post(req, '/testing/createfile', { file: testFile }) .then(() => new Promise((resolve) => setTimeout(resolve, 500))) .then(() => common.post(req, '/commit', { path: testDirLocal1, message: 'Commit2', files: [{ name: testFile }], }) ) .then(() => common.post(req, '/push', { path: testDirLocal1, remote: 'origin' })); }); it('fetching in "local2" should work', () => { return common.get(req, '/fetch', { path: testDirLocal2, remote: 'origin' }); }); it('log in "local2" should show the branch as one behind', () => { return common.get(req, '/gitlog', { path: testDirLocal2 }).then((res) => { expect(res.nodes).to.be.a('array'); expect(res.nodes.length).to.be(2); const init = res.nodes.find((node) => node.message.indexOf('Init') == 0); const commit2 = res.nodes.find((node) => node.message.indexOf('Commit2') == 0); expect(init).to.be.ok(); expect(commit2).to.be.ok(); expect(init.refs).to.contain('HEAD'); expect(init.refs).to.contain('refs/heads/master'); expect(commit2.refs).to.contain('refs/remotes/origin/master'); expect(commit2.refs).to.contain('refs/remotes/origin/HEAD'); }); }); it('rebasing local master onto remote master should work in "local2"', () => { return common.post(req, '/rebase', { path: testDirLocal2, onto: 'origin/master' }); }); it('log in "local2" should show the branch as in sync after rebase', () => { return common.get(req, '/gitlog', { path: testDirLocal2 }).then((res) => { expect(res.nodes).to.be.a('array'); expect(res.nodes.length).to.be(2); const init = res.nodes.find((node) => node.message.indexOf('Init') == 0); const commit2 = res.nodes.find((node) => node.message.indexOf('Commit2') == 0); expect(init).to.be.ok(); expect(commit2).to.be.ok(); expect(init.refs).to.eql([]); expect(commit2.refs).to.contain('HEAD'); expect(commit2.refs).to.contain('refs/heads/master'); expect(commit2.refs).to.contain('refs/remotes/origin/master'); expect(commit2.refs).to.contain('refs/remotes/origin/HEAD'); }); }); it('creating a commit in "local2" repo should work', () => { const testFile = path.join(testDirLocal2, 'testfile3.txt'); return common .post(req, '/testing/createfile', { file: testFile }) .then(() => new Promise((resolve) => setTimeout(resolve, 500))) .then(() => common.post(req, '/commit', { path: testDirLocal2, message: 'Commit3', files: [{ name: testFile }], }) ); }); it('resetting local master to remote master should work in "local2"', () => { return common.post(req, '/reset', { path: testDirLocal2, to: 'origin/master', mode: 'hard' }); }); it('log in "local2" should show the branch as in sync after reset', () => { return common.get(req, '/gitlog', { path: testDirLocal2 }, (res) => { expect(res.nodes.length).to.be(2); const init = res.nodes.find((node) => node.message.indexOf('Init') == 0); const commit2 = res.nodes.find((node) => node.message.indexOf('Commit2') == 0); expect(init.refs).to.eql([]); expect(commit2.refs).to.contain('HEAD'); expect(commit2.refs).to.contain('refs/heads/master'); expect(commit2.refs).to.contain('refs/remotes/origin/master'); expect(commit2.refs).to.contain('refs/remotes/origin/HEAD'); }); }); it('status should show nothing', () => { return common .get(req, '/status', { path: testDirLocal2 }) .then((res) => expect(Object.keys(res.files).length).to.be(0)); }); it('should be possible to create a tag in "local2"', () => { return common.post(req, '/tags', { path: testDirLocal2, name: 'v1.0' }); }); it('should be possible to push a tag from "local2"', () => { return common.post(req, '/push', { path: testDirLocal2, remote: 'origin', refSpec: 'v1.0', remoteBranch: 'v1.0', }); }); it('log in "local2" should show the local tag', () => { return common.get(req, '/gitlog', { path: testDirLocal2 }).then((res) => { const commit2 = res.nodes.find((node) => node.message.indexOf('Commit2') == 0); expect(commit2.refs).to.contain('tag: refs/tags/v1.0'); }); }); it('remote tags in "local2" should show the remote tag', () => { return common .get(req, '/remote/tags', { path: testDirLocal2, remote: 'origin' }) .then((res) => expect(res.map((tag) => tag.name)).to.contain('refs/tags/v1.0^{}')); }); }); ================================================ FILE: test/spec.git-api.squash.js ================================================ const expect = require('expect.js'); const request = require('supertest'); const express = require('express'); const path = require('path'); const restGit = require('../source/git-api'); const common = require('./common-es6.js'); const app = express(); app.use(require('body-parser').json()); restGit.registerApi({ app: app, config: { dev: true } }); let testDir; const req = request(app); const rootBranch = 'root'; const testFile1 = 'testFile1.txt'; const testFile2 = 'testFile2.txt'; describe('git-api conflict rebase', function () { before(() => { return common.createSmallRepo(req).then((dir) => { testDir = dir; }); }); after(() => common.post(req, '/testing/cleanup')); it('establish root branch', () => { return common.post(req, '/branches', { path: testDir, name: rootBranch, startPoint: 'master' }); }); it('create some commits', () => { return common .post(req, '/testing/createfile', { file: path.join(testDir, testFile1) }) .then(() => common.post(req, '/commit', { path: testDir, message: `a commit for ${testFile1}`, files: [{ name: testFile1 }], }) ) .then(() => common.post(req, '/testing/createfile', { file: path.join(testDir, testFile2) })) .then(() => common.post(req, '/commit', { path: testDir, message: `a commit for ${testFile2}`, files: [{ name: testFile2 }], }) ); }); it('checkout master', () => { return common.post(req, '/checkout', { path: testDir, name: rootBranch }); }); it('squash 2 commits to 1', () => { return common .post(req, '/squash', { path: testDir, target: 'master' }) .then(() => common.get(req, '/status', { path: testDir })) .then((res) => expect(Object.keys(res.files).length).to.be(2)); }); it('discard all', () => { return common .post(req, '/discardchanges', { path: testDir, all: true }) .then(() => common.get(req, '/status', { path: testDir })) .then((res) => expect(Object.keys(res.files).length).to.be(0)); }); it('making conflicting commit', () => { return common .post(req, '/testing/createfile', { file: path.join(testDir, testFile1) }) .then(() => common.post(req, '/commit', { path: testDir, message: `a 2nd commit for ${testFile1}`, files: [{ name: testFile1 }], }) ); }); it('squash 2 commits to 1 with conflict', () => { return common .post(req, '/squash', { path: testDir, target: 'master' }) .then(() => common.get(req, '/status', { path: testDir })) .then((res) => { expect(res.inConflict).to.be(true); expect(Object.keys(res.files).length).to.be(2); }); }); }); ================================================ FILE: test/spec.git-api.stash.js ================================================ const expect = require('expect.js'); const request = require('supertest'); const express = require('express'); const path = require('path'); const restGit = require('../source/git-api'); const common = require('./common-es6.js'); const app = express(); app.use(require('body-parser').json()); restGit.registerApi({ app: app, config: { dev: true } }); let testDir; const req = request(app); describe('git-api conflict rebase', function () { const testFile1 = 'testfile1.txt'; before(() => { return common .createSmallRepo(req) .then((dir) => { testDir = dir; }) .then(() => common.post(req, '/testing/createfile', { file: path.join(testDir, testFile1) })); }); after(() => common.post(req, '/testing/cleanup')); it('should be possible to stash', () => common.post(req, '/stashes', { path: testDir })); it('stashes should list the stashed item', () => { return common.get(req, '/stashes', { path: testDir }).then((res) => { expect(res.length).to.be(1); expect(res[0].reflogId).to.be('0'); expect(res[0].reflogName).to.be('stash@{0}'); }); }); it('should be possible to drop stash', () => { return common.delete(req, '/stashes/0', { path: testDir }); }); }); ================================================ FILE: test/spec.git-api.submodule.js ================================================ const expect = require('expect.js'); const request = require('supertest'); const express = require('express'); const path = require('path'); const restGit = require('../source/git-api'); const common = require('./common-es6.js'); const app = express(); app.use(require('body-parser').json()); restGit.registerApi({ app: app, config: { dev: true } }); const req = request(app); describe('git-api submodule', function () { let testDirMain, testDirSecondary; before(() => { return common .createSmallRepo(req) .then((dir) => { testDirMain = dir; }) .then(() => common.createSmallRepo(req)) .then((dir) => { testDirSecondary = dir; }); }); after(() => common.post(req, '/testing/cleanup')); const submodulePath = 'sub'; it('submodule add should work', () => { return common.post(req, '/submodules/add', { path: testDirMain, submodulePath: submodulePath, submoduleUrl: testDirSecondary, }); }); it('submodule should show up in status', () => { return common.get(req, '/status', { path: testDirMain }).then((res) => { expect(Object.keys(res.files).length).to.be(2); expect(res.files[submodulePath]).to.eql({ displayName: submodulePath, fileName: submodulePath, oldFileName: submodulePath, isNew: true, staged: true, removed: false, conflict: false, renamed: false, type: 'text', additions: '1', deletions: '0', }); expect(res.files['.gitmodules']).to.eql({ displayName: '.gitmodules', fileName: '.gitmodules', oldFileName: '.gitmodules', isNew: true, staged: true, removed: false, conflict: false, renamed: false, type: 'text', additions: '3', deletions: '0', }); }); }); it('commit should succeed', () => { return common.post(req, '/commit', { path: testDirMain, message: 'Add submodule', files: [{ name: submodulePath }, { name: '.gitmodules' }], }); }); it('status should be empty after commit', () => { return common .get(req, '/status', { path: testDirMain }) .then((res) => expect(Object.keys(res.files).length).to.be(0)); }); it('creating a test file in sub dir should work', () => { const testFile = path.join(submodulePath, 'testy.txt'); return common.post(req, '/testing/createfile', { file: path.join(testDirMain, testFile) }); }); // see https://github.com/FredrikNoren/ungit/issues/1472 it.skip("submodule should show up in status when it's dirty", () => { return common.get(req, '/status', { path: testDirMain }).then((res) => { expect(Object.keys(res.files).length).to.be(1); expect(res.files[submodulePath]).to.eql({ displayName: submodulePath, fileName: submodulePath, oldFileName: submodulePath, isNew: false, staged: false, removed: false, conflict: false, renamed: false, type: 'text', additions: '0', deletions: '0', }); }); }); // see https://github.com/FredrikNoren/ungit/issues/1472 it.skip('diff on submodule should work', () => { return common.get(req, '/diff', { path: testDirMain, file: submodulePath }).then((res) => { expect(res.indexOf('-Subproject commit')).to.be.above(-1); expect(res.indexOf('+Subproject commit')).to.be.above(-1); }); }); }); ================================================ FILE: test/spec.git-parser.js ================================================ const expect = require('expect.js'); const path = require('path'); const gitParser = require('../source/git-parser'); const dedent = require('dedent'); describe('git-parser stash show', () => { it('should be possible to parse stashed show', () => { const text = ' New Text Document (2).txt | 5 +++++\n 1 file changed, 5 insertions(+)\n'; const res = gitParser.parseGitStashShow(text); expect(res).to.be.an('array'); expect(res.length).to.be(1); expect(res[0]).to.eql({ filename: 'New Text Document (2).txt' }); }); }); describe('git-parser parseDiffResult', () => { it('all diff selected', () => { const gitDiff = dedent` diff --git a/package.json b/package.json index f71e0064..08964575 100644 --- a/package.json +++ b/package.json @@ -87,9 +87,10 @@ "grunt-mocha-test": "~0.13.3", "grunt-plato": "~1.4.0", "grunt-release": "~0.14.0", - "istanbul": "~0.4.5", + "istanbul": "^0.4.5", "mocha": "~5.2.0", "nightmare": "~3.0.1", + "nyc": "^13.1.0", "supertest": "~3.3.0" `; expect(gitParser.parsePatchDiffResult([true, true, true], gitDiff)).to.eql(dedent` diff --git a/package.json b/package.json index f71e0064..08964575 100644 --- a/package.json +++ b/package.json @@ -87,9 +87,10 @@ "grunt-mocha-test": "~0.13.3", "grunt-plato": "~1.4.0", "grunt-release": "~0.14.0", - "istanbul": "~0.4.5", + "istanbul": "^0.4.5", "mocha": "~5.2.0", "nightmare": "~3.0.1", + "nyc": "^13.1.0", "supertest": "~3.3.0" `); }); it('no diff selected', () => { const gitDiff = dedent` diff --git a/package.json b/package.json index f71e0064..08964575 100644 --- a/package.json +++ b/package.json @@ -87,9 +87,10 @@ "grunt-mocha-test": "~0.13.3", "grunt-plato": "~1.4.0", "grunt-release": "~0.14.0", - "istanbul": "~0.4.5", + "istanbul": "^0.4.5", "mocha": "~5.2.0", "nightmare": "~3.0.1", + "nyc": "^13.1.0", "supertest": "~3.3.0" `; expect(gitParser.parsePatchDiffResult([false, false, false], gitDiff)).to.eql(null); }); it('one +- diff selected', () => { const gitDiff = dedent` diff --git a/package.json b/package.json index f71e0064..08964575 100644 --- a/package.json +++ b/package.json @@ -87,9 +87,10 @@ "grunt-mocha-test": "~0.13.3", "grunt-plato": "~1.4.0", "grunt-release": "~0.14.0", - "istanbul": "~0.4.5", + "istanbul": "^0.4.5", "mocha": "~5.2.0", "nightmare": "~3.0.1", + "nyc": "^13.1.0", "supertest": "~3.3.0" `; expect(gitParser.parsePatchDiffResult([true, true, false], gitDiff)).to.eql(dedent` diff --git a/package.json b/package.json index f71e0064..08964575 100644 --- a/package.json +++ b/package.json @@ -87,9 +87,9 @@ "grunt-mocha-test": "~0.13.3", "grunt-plato": "~1.4.0", "grunt-release": "~0.14.0", - "istanbul": "~0.4.5", + "istanbul": "^0.4.5", "mocha": "~5.2.0", "nightmare": "~3.0.1", "supertest": "~3.3.0" `); }); it('only one + diff selected', () => { const gitDiff = dedent` diff --git a/package.json b/package.json index f71e0064..08964575 100644 --- a/package.json +++ b/package.json @@ -87,9 +87,10 @@ "grunt-mocha-test": "~0.13.3", "grunt-plato": "~1.4.0", "grunt-release": "~0.14.0", - "istanbul": "~0.4.5", + "istanbul": "^0.4.5", "mocha": "~5.2.0", "nightmare": "~3.0.1", + "nyc": "^13.1.0", "supertest": "~3.3.0" `; expect(gitParser.parsePatchDiffResult([false, false, true], gitDiff)).to.eql( 'diff --git a/package.json b/package.json\nindex f71e0064..08964575 100644\n--- a/package.json\n+++ b/package.json\n@@ -87,9 +87,10 @@\n\t"grunt-mocha-test": "~0.13.3",\n\t"grunt-plato": "~1.4.0",\n\t"grunt-release": "~0.14.0",\n \t"istanbul": "~0.4.5",\n\t"mocha": "~5.2.0",\n\t"nightmare": "~3.0.1",\n+\t"nyc": "^13.1.0",\n\t"supertest": "~3.3.0"' ); }); it('works with multiple diffs', () => { const gitDiff = dedent` diff --git a/README.md b/README.md index 96700c3a..dc141a51 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,3 @@ -ungit ====== [![NPM version](https://badge.fury.io/js/ungit.svg)](http://badge.fury.io/js/ungit) [![Build Status](https://travis-ci.org/FredrikNoren/ungit.svg)](https://travis-ci.org/FredrikNoren/ungit) @@ -133,7 +132,6 @@ Changelog See [CHANGELOG.md](CHANGELOG.md). -License (MIT) See [LICENSE.md](LICENSE.md). To read about the Faircode experiment go to [#974](https://github.com/FredrikNoren/ungit/issues/974). Ungit is now once again MIT. `; expect(gitParser.parsePatchDiffResult([true, false], gitDiff)).to.eql( 'diff --git a/README.md b/README.md\nindex 96700c3a..dc141a51 100644\n--- a/README.md\n+++ b/README.md\n@@ -1,4 +1,3 @@\n-ungit\n======\n[![NPM version](https://badge.fury.io/js/ungit.svg)](http://badge.fury.io/js/ungit)\n[![Build Status](https://travis-ci.org/FredrikNoren/ungit.svg)](https://travis-ci.org/FredrikNoren/ungit)\n@@ -133,7 +132,7 @@ Changelog\nSee [CHANGELOG.md](CHANGELOG.md).\n\n License (MIT)\nSee [LICENSE.md](LICENSE.md). To read about the Faircode experiment go to [#974](https://github.com/FredrikNoren/ungit/issues/974). Ungit is now once again MIT.' ); }); it('works with empty diff', () => { expect(gitParser.parsePatchDiffResult([], null)).to.eql(null); }); }); describe('git-parser parseGitLog', () => { it('should work with branch name with ()', () => { const refs = gitParser.parseGitLog('commit AAA BBB (HEAD, (test), fw(4rw), 5), ((, ()')[0].refs; expect(refs.length).to.be(6); }); it('should work with no branch name', () => { const refs = gitParser.parseGitLog('commit AAA BBB')[0].refs; expect(refs.length).to.be(0); }); it('should work with empty lines', () => { expect(gitParser.parseGitLog('')).to.eql([]); }); it('parses authors without emails', () => { const gitLog = dedent` commit 37d1154434b70854ed243967e0d7e37aa3564551 d58c8e117fc257520d90b099fd2c6acd7c1e8861 (HEAD -> refs/heads/git-parser-specs) Author: Test ungit Commit: Test ungit `; expect(gitParser.parseGitLog(gitLog)[0]).to.eql({ authorName: 'Test ungit', committerName: 'Test ungit', additions: 0, deletions: 0, fileLineDiffs: [], isHead: true, message: '', parents: ['d58c8e117fc257520d90b099fd2c6acd7c1e8861'], refs: ['HEAD', 'refs/heads/git-parser-specs'], sha1: '37d1154434b70854ed243967e0d7e37aa3564551', }); }); it('parses multiple commits in a row', () => { const gitLog = dedent(` commit 5867e2766b0a0f81ad59ce9e9895d9b1a3523aa4 37d1154434b70854ed243967e0d7e37aa3564551 (HEAD -> refs/heads/git-parser-specs) Author: Test ungit AuthorDate: Fri Jan 4 14:54:06 2019 +0100 Commit: Test ungit CommitDate: Fri Jan 4 14:54:06 2019 +0100 parseGitLog + gix reflox parsing 1 1 source/git-parser.js\x00175 0 test/spec.git-parser.js\x00\x00commit 37d1154434b70854ed243967e0d7e37aa3564551 d58c8e117fc257520d90b099fd2c6acd7c1e8861 Author: Test ungit AuthorDate: Fri Jan 4 14:03:56 2019 +0100 Commit: Test ungit CommitDate: Fri Jan 4 14:03:56 2019 +0100 submodules parser 32 0 test/spec.git-parser.js\x00\x00commit 02efa0da7b1eccb1e0f1c2ff0433ce7387738f60 985617e19e30e9abe0a5711bf455f0dc10f97dff Author: Test ungit AuthorDate: Fri Jan 4 14:02:56 2019 +0100 Commit: Test ungit CommitDate: Fri Jan 4 14:02:56 2019 +0100 empty commit \x00commit 621a04f931ea9007ac826c04a1a02832e20aa470 4e5d611fdad85bcad44abf65936c95f748abef4e e2dc3ef6e2cbf6ab0acb456c0837257dc01baafd Merge: 4e5d611f e2dc3ef6 Author: Test ungit AuthorDate: Fri Jan 4 14:01:56 2019 +0100 Commit: Test ungit CommitDate: Fri Jan 4 14:01:56 2019 +0100 Merge pull request #1268 from campersau/prepare_152 Prepare version 1.5.2 \x004 1 CHANGELOG.md\x001 1 package-lock.json\x001 1 package.json\x008 6 source/git-parser.js\x00\x00`); const res = gitParser.parseGitLog(gitLog); expect(res[0]).to.eql({ authorDate: 'Fri Jan 4 14:54:06 2019 +0100', authorEmail: 'test@example.com', authorName: 'Test ungit', commitDate: 'Fri Jan 4 14:54:06 2019 +0100', committerEmail: 'test@example.com', committerName: 'Test ungit', additions: 176, deletions: 1, fileLineDiffs: [ { additions: 1, deletions: 1, displayName: 'source/git-parser.js', fileName: 'source/git-parser.js', oldFileName: 'source/git-parser.js', type: 'text', }, { additions: 175, deletions: 0, displayName: 'test/spec.git-parser.js', fileName: 'test/spec.git-parser.js', oldFileName: 'test/spec.git-parser.js', type: 'text', }, ], isHead: true, message: 'parseGitLog + gix reflox parsing', parents: ['37d1154434b70854ed243967e0d7e37aa3564551'], refs: ['HEAD', 'refs/heads/git-parser-specs'], sha1: '5867e2766b0a0f81ad59ce9e9895d9b1a3523aa4', }); expect(res[1]).to.eql({ authorDate: 'Fri Jan 4 14:03:56 2019 +0100', authorEmail: 'test@example.com', authorName: 'Test ungit', commitDate: 'Fri Jan 4 14:03:56 2019 +0100', committerEmail: 'test@example.com', committerName: 'Test ungit', additions: 32, deletions: 0, fileLineDiffs: [ { additions: 32, deletions: 0, displayName: 'test/spec.git-parser.js', fileName: 'test/spec.git-parser.js', oldFileName: 'test/spec.git-parser.js', type: 'text', }, ], isHead: false, message: 'submodules parser', parents: ['d58c8e117fc257520d90b099fd2c6acd7c1e8861'], refs: [], sha1: '37d1154434b70854ed243967e0d7e37aa3564551', }); // empty commit expect(res[2]).to.eql({ authorDate: 'Fri Jan 4 14:02:56 2019 +0100', authorEmail: 'test@example.com', authorName: 'Test ungit', commitDate: 'Fri Jan 4 14:02:56 2019 +0100', committerEmail: 'test@example.com', committerName: 'Test ungit', additions: 0, deletions: 0, fileLineDiffs: [], isHead: false, message: 'empty commit', parents: ['985617e19e30e9abe0a5711bf455f0dc10f97dff'], refs: [], sha1: '02efa0da7b1eccb1e0f1c2ff0433ce7387738f60', }); // merge commit expect(res[3]).to.eql({ authorDate: 'Fri Jan 4 14:01:56 2019 +0100', authorEmail: 'test@example.com', authorName: 'Test ungit', commitDate: 'Fri Jan 4 14:01:56 2019 +0100', committerEmail: 'test@example.com', committerName: 'Test ungit', additions: 14, deletions: 9, fileLineDiffs: [ { additions: 4, deletions: 1, displayName: 'CHANGELOG.md', fileName: 'CHANGELOG.md', oldFileName: 'CHANGELOG.md', type: 'text', }, { additions: 1, deletions: 1, displayName: 'package-lock.json', fileName: 'package-lock.json', oldFileName: 'package-lock.json', type: 'text', }, { additions: 1, deletions: 1, displayName: 'package.json', fileName: 'package.json', oldFileName: 'package.json', type: 'text', }, { additions: 8, deletions: 6, displayName: 'source/git-parser.js', fileName: 'source/git-parser.js', oldFileName: 'source/git-parser.js', type: 'text', }, ], isHead: false, message: 'Merge pull request #1268 from campersau/prepare_152\n\nPrepare version 1.5.2', parents: [ '4e5d611fdad85bcad44abf65936c95f748abef4e', 'e2dc3ef6e2cbf6ab0acb456c0837257dc01baafd', ], refs: [], sha1: '621a04f931ea9007ac826c04a1a02832e20aa470', }); }); it('parses multiple commits in a row multiple nul separators', () => { const gitLog = dedent(` commit ad4c559f05796e78095a51679324cefd9afca879 47185090d5096033db0d5c0bbf883d9295ca084e b360295026ae6afac3525b89145aa22d61e818ff (HEAD -> refs/heads/dev) Merge: 4718509 b360295 Author: Ungit Commiter AuthorDate: Sat May 22 22:21:04 2021 +0200 Commit: Ungit Commiter CommitDate: Sat May 22 22:21:04 2021 +0200 Merge branch 'a' into dev \x00\x00commit 7d7a4d7d9fc625aff46a0ff4d7e95f86d01d25c7 47185090d5096033db0d5c0bbf883d9295ca084e (refs/heads/b) Author: Ungit Commiter AuthorDate: Sat May 22 22:20:28 2021 +0200 Commit: Ungit Commiter CommitDate: Sat May 22 22:20:28 2021 +0200 b \x00commit b360295026ae6afac3525b89145aa22d61e818ff 47185090d5096033db0d5c0bbf883d9295ca084e (refs/heads/a) Author: Ungit Commiter AuthorDate: Sat May 22 22:20:23 2021 +0200 Commit: Ungit Commiter CommitDate: Sat May 22 22:20:23 2021 +0200 a \x00commit 47185090d5096033db0d5c0bbf883d9295ca084e (refs/heads/master) Author: Ungit Commiter AuthorDate: Sat May 22 22:19:31 2021 +0200 Commit: Ungit Commiter CommitDate: Sat May 22 22:19:31 2021 +0200 Initial commit`); const res = gitParser.parseGitLog(gitLog); expect(res.length).to.eql(4); expect(res[0].message).to.eql("Merge branch 'a' into dev"); expect(res[1].message).to.eql('b'); expect(res[2].message).to.eql('a'); expect(res[3].message).to.eql('Initial commit'); }); it('parses reflog commits without email', () => { const gitLog = dedent(` commit 37d11544 d58c8e11 (HEAD -> refs/heads/git-parser-specs) Reflog: git-parser-specs@{Fri Jan 4 14:03:56 2019 +0100} (Test ungit) Reflog message: commit: submodules parser Author: Test ungit AuthorDate: Fri Jan 4 14:03:56 2019 +0100 Commit: Test ungit CommitDate: Fri Jan 4 14:03:56 2019 +0100 submodules parser 32 0 test/spec.git-parser.js\x00\x00`); expect(gitParser.parseGitLog(gitLog)[0]).to.eql({ authorDate: 'Fri Jan 4 14:03:56 2019 +0100', authorEmail: 'test@example.com', authorName: 'Test ungit', commitDate: 'Fri Jan 4 14:03:56 2019 +0100', committerEmail: 'test@example.com', committerName: 'Test ungit', additions: 32, deletions: 0, fileLineDiffs: [ { additions: 32, deletions: 0, displayName: 'test/spec.git-parser.js', fileName: 'test/spec.git-parser.js', oldFileName: 'test/spec.git-parser.js', type: 'text', }, ], isHead: true, message: 'submodules parser', parents: ['d58c8e11'], reflogAuthorName: 'Test ungit', reflogId: 'Fri Jan 4 14:03:56 2019 +0100', reflogName: 'git-parser-specs@{Fri', refs: ['HEAD', 'refs/heads/git-parser-specs'], sha1: '37d11544', }); }); it('parses reflog commits', () => { const gitLog = dedent(` commit 37d11544 d58c8e11 (HEAD -> refs/heads/git-parser-specs) Reflog: git-parser-specs@{Fri Jan 4 14:03:56 2019 +0100} (Test ungit ) Reflog message: commit: submodules parser Author: Test ungit AuthorDate: Fri Jan 4 14:03:56 2019 +0100 Commit: Test ungit CommitDate: Fri Jan 4 14:03:56 2019 +0100 submodules parser 32 0 test/spec.git-parser.js\x00\x00`); expect(gitParser.parseGitLog(gitLog)[0]).to.eql({ authorDate: 'Fri Jan 4 14:03:56 2019 +0100', authorEmail: 'test@example.com', authorName: 'Test ungit', commitDate: 'Fri Jan 4 14:03:56 2019 +0100', committerEmail: 'test@example.com', committerName: 'Test ungit', additions: 32, deletions: 0, fileLineDiffs: [ { additions: 32, deletions: 0, displayName: 'test/spec.git-parser.js', fileName: 'test/spec.git-parser.js', oldFileName: 'test/spec.git-parser.js', type: 'text', }, ], isHead: true, message: 'submodules parser', parents: ['d58c8e11'], reflogAuthorEmail: 'test@example.com', reflogAuthorName: 'Test ungit', reflogId: 'Fri Jan 4 14:03:56 2019 +0100', reflogName: 'git-parser-specs@{Fri', refs: ['HEAD', 'refs/heads/git-parser-specs'], sha1: '37d11544', }); }); it('parses wrongly signed commits', () => { const gitLog = dedent` commit 37d1154434b70854ed243967e0d7e37aa3564551 d58c8e117fc257520d90b099fd2c6acd7c1e8861 (HEAD -> refs/heads/git-parser-specs) gpg: Signature made Wed Jun 4 19:49:17 2014 PDT using RSA key ID 0AAAAAAA gpg: Can't check signature: public key not found Author: Test Ungit Date: Wed Jun 4 19:49:17 2014 -0700 signed commit `; expect(gitParser.parseGitLog(gitLog)[0]).to.eql({ authorEmail: 'test@example.com', authorName: 'Test Ungit', additions: 0, deletions: 0, fileLineDiffs: [], isHead: true, message: '', parents: ['d58c8e117fc257520d90b099fd2c6acd7c1e8861'], refs: ['HEAD', 'refs/heads/git-parser-specs'], sha1: '37d1154434b70854ed243967e0d7e37aa3564551', }); }); it('parses signed commits', () => { const gitLog = dedent` commit 37d1154434b70854ed243967e0d7e37aa3564551 d58c8e117fc257520d90b099fd2c6acd7c1e8861 (HEAD -> refs/heads/git-parser-specs) gpg: Signature made Wed Jun 4 19:49:17 2014 PDT using RSA key ID 0AAAAAAA gpg: Good signature from "Test ungit (Git signing key) " Author: Test Ungit Date: Wed Jun 4 19:49:17 2014 -0700 signed commit `; expect(gitParser.parseGitLog(gitLog)[0]).to.eql({ authorEmail: 'test@example.com', authorName: 'Test Ungit', additions: 0, deletions: 0, fileLineDiffs: [], isHead: true, message: '', parents: ['d58c8e117fc257520d90b099fd2c6acd7c1e8861'], refs: ['HEAD', 'refs/heads/git-parser-specs'], sha1: '37d1154434b70854ed243967e0d7e37aa3564551', signatureDate: 'Wed Jun 4 19:49:17 2014 PDT using RSA key ID 0AAAAAAA', signatureMade: '"Test ungit (Git signing key) "', }); }); it('parses the git log', () => { const gitLog = dedent(` commit 37d1154434b70854ed243967e0d7e37aa3564551 d58c8e117fc257520d90b099fd2c6acd7c1e8861 (HEAD -> refs/heads/git-parser-specs) Author: Test ungit AuthorDate: Fri Jan 4 14:03:56 2019 +0100 Commit: Test ungit CommitDate: Fri Jan 4 14:03:56 2019 +0100 submodules parser 32 0 test/spec.git-parser.js\x00\x00`); expect(gitParser.parseGitLog(gitLog)[0]).to.eql({ refs: ['HEAD', 'refs/heads/git-parser-specs'], additions: 32, deletions: 0, fileLineDiffs: [ { additions: 32, deletions: 0, displayName: 'test/spec.git-parser.js', fileName: 'test/spec.git-parser.js', oldFileName: 'test/spec.git-parser.js', type: 'text', }, ], sha1: '37d1154434b70854ed243967e0d7e37aa3564551', parents: ['d58c8e117fc257520d90b099fd2c6acd7c1e8861'], isHead: true, authorName: 'Test ungit', authorEmail: 'test@example.com', authorDate: 'Fri Jan 4 14:03:56 2019 +0100', committerName: 'Test ungit', committerEmail: 'test@example.com', commitDate: 'Fri Jan 4 14:03:56 2019 +0100', message: 'submodules parser', }); }); }); describe('git-parser submodule', () => { it('should work with empty string', () => { const gitmodules = ''; const submodules = gitParser.parseGitSubmodule(gitmodules); expect(submodules).to.eql([]); }); it('should work with name, path and url', () => { const gitmodules = '[submodule "test1"]\npath = /path/to/sub1\nurl = http://example1.com'; const submodules = gitParser.parseGitSubmodule(gitmodules); expect(submodules.length).to.be(1); expect(submodules[0]).to.eql({ name: 'test1', path: path.join('/path', 'to', 'sub1'), rawUrl: 'http://example1.com', url: 'http://example1.com', }); }); it('should work with multiple name, path and url', () => { const gitmodules = [ '[submodule "test1"]\npath = /path/to/sub1\nurl = http://example1.com', '[submodule "test2"]\npath = /path/to/sub2\nurl = http://example2.com', ].join('\n'); const submodules = gitParser.parseGitSubmodule(gitmodules); expect(submodules.length).to.be(2); expect(submodules[0]).to.eql({ name: 'test1', path: path.join('/path', 'to', 'sub1'), rawUrl: 'http://example1.com', url: 'http://example1.com', }); expect(submodules[1]).to.eql({ name: 'test2', path: path.join('/path', 'to', 'sub2'), rawUrl: 'http://example2.com', url: 'http://example2.com', }); }); it('should work with multiple name, path, url, update, branch, fetchRecurseSubmodules and ignore', () => { const gitmodules = [ '[submodule "test1"]\npath = /path/to/sub1\nurl = http://example1.com\nupdate = checkout\nbranch = master\nfetchRecurseSubmodules = true\nignore = all', '[submodule "test2"]\n\npath = /path/to/sub2\nurl= git://example2.com', ].join('\n'); const submodules = gitParser.parseGitSubmodule(gitmodules); expect(submodules.length).to.be(2); expect(submodules[0]).to.eql({ branch: 'master', fetchRecurseSubmodules: 'true', ignore: 'all', name: 'test1', path: path.join('/path', 'to', 'sub1'), rawUrl: 'http://example1.com', update: 'checkout', url: 'http://example1.com', }); expect(submodules[1]).to.eql({ name: 'test2', path: path.join('/path', 'to', 'sub2'), rawUrl: 'git://example2.com', url: 'http://example2.com', }); }); it('should work with git submodules', () => { const gitmodules = dedent` [submodule "test1"] path = /path/to/sub1 url = git://example1.com update = checkout branch = master fetchRecurseSubmodules = true ignore = all `; expect(gitParser.parseGitSubmodule(gitmodules)).to.eql([ { name: 'test1', path: path.join('/path', 'to', 'sub1'), rawUrl: 'git://example1.com', url: 'http://example1.com', update: 'checkout', branch: 'master', fetchRecurseSubmodules: 'true', ignore: 'all', }, ]); }); it('should work with ssh submodules', () => { const gitmodules = dedent` [submodule "test1"] path = /path/to/sub1 url = ssh://login@server.com:12345 update = checkout branch = master fetchRecurseSubmodules = true ignore = all `; expect(gitParser.parseGitSubmodule(gitmodules)).to.eql([ { name: 'test1', path: path.join('/path', 'to', 'sub1'), rawUrl: 'ssh://login@server.com:12345', url: 'http://server.com/12345', update: 'checkout', branch: 'master', fetchRecurseSubmodules: 'true', ignore: 'all', }, ]); }); }); describe('parseGitConfig', () => { it('parses the git config', () => { const gitConfig = dedent` user.email=test@example.com user.name=Ungit Test core.repositoryformatversion=0 core.filemode=true core.bare=false core.logallrefupdates=true remote.origin.url=git@github.com:ungit/ungit.git branch.master.remote=origin branch.master.merge=refs/heads/master `; expect(gitParser.parseGitConfig(gitConfig)).to.eql({ 'user.email': 'test@example.com', 'user.name': 'Ungit Test', 'core.repositoryformatversion': '0', 'core.filemode': 'true', 'core.bare': 'false', 'core.logallrefupdates': 'true', 'remote.origin.url': 'git@github.com:ungit/ungit.git', 'branch.master.remote': 'origin', 'branch.master.merge': 'refs/heads/master', }); }); }); describe('parseGitBranches', () => { it('parses the branches', () => { const gitBranches = dedent` * dev master testbuild `; expect(gitParser.parseGitBranches(gitBranches)).to.eql([ { name: 'dev', current: true }, { name: 'master' }, { name: 'testbuild' }, ]); }); }); describe('parseGitTags', () => { it('parses the tags', () => { const gitTags = dedent` 0.1.0 0.1.1 0.1.2 `; expect(gitParser.parseGitTags(gitTags)).to.eql(['0.1.0', '0.1.1', '0.1.2']); }); }); describe('parseGitRemotes', () => { it('parses the remotes', () => { const gitRemotes = dedent` origin upstream `; expect(gitParser.parseGitRemotes(gitRemotes)).to.eql([ { name: 'origin' }, { name: 'upstream' }, ]); }); it('parses the remotes with fetch and push url', () => { const gitRemotes = dedent` origin http://example1.com upstream http://example2.com (fetch) upstream http://example3.com (push) `; expect(gitParser.parseGitRemotes(gitRemotes)).to.eql([ { name: 'origin', url: 'http://example1.com' }, { name: 'upstream', fetchUrl: 'http://example2.com', pushUrl: 'http://example3.com' }, ]); }); }); describe('parseGitLsRemote', () => { it('parses the ls remote', () => { const gitLsRemote = dedent` 86bec6415fa7ec0d7550a62389de86adb493d546 refs/tags/0.1.0 668ab7beae996c5a7b36da0be64b98e45ba2aa0b refs/tags/0.1.0^{} d3ec9678acf285637ef11c7cba897d697820de07 refs/tags/0.1.1 ad00b6c8b7b0cbdd0bd92d44dece559b874a4ae6 refs/tags/0.1.1^{} `; expect(gitParser.parseGitLsRemote(gitLsRemote)).to.eql([ { sha1: '86bec6415fa7ec0d7550a62389de86adb493d546', name: 'refs/tags/0.1.0' }, { sha1: '668ab7beae996c5a7b36da0be64b98e45ba2aa0b', name: 'refs/tags/0.1.0^{}' }, { sha1: 'd3ec9678acf285637ef11c7cba897d697820de07', name: 'refs/tags/0.1.1' }, { sha1: 'ad00b6c8b7b0cbdd0bd92d44dece559b874a4ae6', name: 'refs/tags/0.1.1^{}' }, ]); }); }); describe('parseGitStatusNumstat', () => { it('parses the git status numstat', () => { const gitStatusNumstat = '1459 202 package-lock.json\x002 1 package.json\x0013 0 test/spec.git-parser.js\x00'; expect(gitParser.parseGitStatusNumstat(gitStatusNumstat)).to.eql({ 'package-lock.json': { additions: '1459', deletions: '202' }, 'package.json': { additions: '2', deletions: '1' }, 'test/spec.git-parser.js': { additions: '13', deletions: '0' }, }); }); it('skips empty lines', () => { const gitStatusNumstat = dedent(` 1459 202 package-lock.json\x00 2 1 package.json\x0013 0 test/spec.git-parser.js\x00 `); expect(gitParser.parseGitStatusNumstat(gitStatusNumstat)).to.eql({ 'package-lock.json': { additions: '1459', deletions: '202' }, 'package.json': { additions: '2', deletions: '1' }, 'test/spec.git-parser.js': { additions: '13', deletions: '0' }, }); }); }); describe('parseGitStatus', () => { it('parses git status', () => { const gitStatus = '## git-parser-specs\x00' + 'A file1.js\x00' + 'M file2.js\x00' + 'D file3.js\x00' + ' D file4.js\x00' + ' U file5.js\x00' + 'U file6.js\x00' + 'AA file7.js\x00' + '? file8.js\x00' + 'A file9.js\x00' + '?D file10.js\x00' + 'AD file11.js\x00' + ' M file12.js\x00' + '?? file13.js\x00' + 'R ../source/sys.js\x00../source/sysinfo.js\x00'; expect(gitParser.parseGitStatus(gitStatus)).to.eql({ branch: 'git-parser-specs', files: { '../source/sys.js': { conflict: false, displayName: '../source/sysinfo.js → ../source/sys.js', fileName: '../source/sys.js', oldFileName: '../source/sysinfo.js', isNew: false, removed: false, renamed: true, staged: false, type: 'text', }, 'file1.js': { conflict: false, displayName: 'file1.js', fileName: 'file1.js', oldFileName: 'file1.js', isNew: true, removed: false, renamed: false, staged: true, type: 'text', }, 'file2.js': { conflict: false, displayName: 'file2.js', fileName: 'file2.js', oldFileName: 'file2.js', isNew: false, removed: false, renamed: false, staged: true, type: 'text', }, 'file3.js': { conflict: false, displayName: 'file3.js', fileName: 'file3.js', oldFileName: 'file3.js', isNew: false, removed: true, renamed: false, staged: false, type: 'text', }, 'file4.js': { conflict: false, displayName: 'file4.js', fileName: 'file4.js', oldFileName: 'file4.js', isNew: false, removed: true, renamed: false, staged: false, type: 'text', }, 'file5.js': { conflict: true, displayName: 'file5.js', fileName: 'file5.js', oldFileName: 'file5.js', isNew: false, removed: false, renamed: false, staged: false, type: 'text', }, 'file6.js': { conflict: true, displayName: 'file6.js', fileName: 'file6.js', oldFileName: 'file6.js', isNew: false, removed: false, renamed: false, staged: false, type: 'text', }, 'file7.js': { conflict: true, displayName: 'file7.js', fileName: 'file7.js', oldFileName: 'file7.js', isNew: true, removed: false, renamed: false, staged: true, type: 'text', }, 'file8.js': { conflict: false, displayName: 'file8.js', fileName: 'file8.js', oldFileName: 'file8.js', isNew: true, removed: false, renamed: false, staged: false, type: 'text', }, 'file9.js': { conflict: false, displayName: 'file9.js', fileName: 'file9.js', oldFileName: 'file9.js', isNew: true, removed: false, renamed: false, staged: true, type: 'text', }, 'file10.js': { conflict: false, displayName: 'file10.js', fileName: 'file10.js', oldFileName: 'file10.js', isNew: false, removed: true, renamed: false, staged: false, type: 'text', }, 'file11.js': { conflict: false, displayName: 'file11.js', fileName: 'file11.js', oldFileName: 'file11.js', isNew: false, removed: true, renamed: false, staged: true, type: 'text', }, 'file12.js': { conflict: false, displayName: 'file12.js', fileName: 'file12.js', oldFileName: 'file12.js', isNew: false, removed: false, renamed: false, staged: false, type: 'text', }, 'file13.js': { conflict: false, displayName: 'file13.js', fileName: 'file13.js', oldFileName: 'file13.js', isNew: true, removed: false, renamed: false, staged: false, type: 'text', }, }, }); }); }); ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "forceConsistentCasingInFileNames": false, "allowSyntheticDefaultImports": true, "resolveJsonModule": true, "downlevelIteration": true, "skipLibCheck": true, "module": "commonjs", // Check JS files too "allowJs": true, "checkJs": false, // Used for temp builds "outDir": "build", "paths": { "mina": ["./node_modules/snapsvg/src/mina"], "octicons": ["./node_modules/@primer/octicons"], "ungit-address-parser": ["./source/address-parser"], "ungit-components": ["./public/source/components"], "ungit-main": ["./public/source/main"], "ungit-navigation": ["./public/source/navigation"], "ungit-program-events": ["./public/source/program-events"], "ungit-storage": ["./public/source/storage"] } }, "exclude": ["node_modules", "build", "coverage", "public/js", "**/*.min.js", "**/*.bundle.js", "clicktests", "test"] }