├── .appveyor.yml ├── .gitignore ├── .pylint.rc ├── .travis.sh ├── .travis.yml ├── LICENSE.md ├── MANIFEST.in ├── README.md ├── gitless.1 ├── gitless ├── __init__.py ├── cli │ ├── __init__.py │ ├── commit_dialog.py │ ├── file_cmd.py │ ├── gl.py │ ├── gl_branch.py │ ├── gl_checkout.py │ ├── gl_commit.py │ ├── gl_diff.py │ ├── gl_fuse.py │ ├── gl_history.py │ ├── gl_init.py │ ├── gl_merge.py │ ├── gl_publish.py │ ├── gl_remote.py │ ├── gl_resolve.py │ ├── gl_status.py │ ├── gl_switch.py │ ├── gl_tag.py │ ├── gl_track.py │ ├── gl_untrack.py │ ├── helpers.py │ └── pprint.py ├── core.py └── tests │ ├── __init__.py │ ├── test_core.py │ ├── test_e2e.py │ └── utils.py ├── gl.py ├── gl.spec ├── requirements.txt ├── setup.py └── snap └── snapcraft.yaml /.appveyor.yml: -------------------------------------------------------------------------------- 1 | # Based on pygit2's appveyor config 2 | image: Visual Studio 2019 3 | environment: 4 | matrix: 5 | - GENERATOR: 'Visual Studio 14' 6 | PYTHON: 'C:\Python38\python.exe' 7 | - GENERATOR: 'Visual Studio 14 Win64' 8 | PYTHON: 'C:\Python38-x64\python.exe' 9 | - GENERATOR: 'Visual Studio 14' 10 | PYTHON: 'C:\Python39\python.exe' 11 | - GENERATOR: 'Visual Studio 14 Win64' 12 | PYTHON: 'C:\Python39-x64\python.exe' 13 | 14 | init: 15 | - cmd: '%PYTHON% -m pip install -U pip' 16 | - cmd: '%PYTHON% -m pip install -U nose' 17 | 18 | build_script: 19 | # build and install `gl` binary (end to end test also use it) 20 | - cmd: '%PYTHON% -m pip install -r requirements.txt .' 21 | 22 | before_test: 23 | - cmd: git config --global user.name "appveyor-test" 24 | - cmd: git config --global user.email "appveyor@test.com" 25 | 26 | test_script: 27 | - cmd: dir /a:h 28 | - ps: | 29 | # 'gl' is installed in Python Scripts directory 30 | $env:PATH += ";$(Split-Path $env:PYTHON)\Scripts" 31 | &$env:PYTHON setup.py nosetests --logging-level=WARN --with-xunit 32 | if ($LastExitCode -ne 0) { $host.SetShouldExit($LastExitCode) } 33 | # upload results to AppVeyor 34 | $wc = New-Object 'System.Net.WebClient' 35 | $wc.UploadFile("https://ci.appveyor.com/api/testresults/junit/$($env:APPVEYOR_JOB_ID)", (Resolve-Path .\nosetests.xml)) 36 | 37 | branches: 38 | only: 39 | - master 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Vi backup files 2 | *~ 3 | 4 | *.py[cod] 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Packages 10 | *.egg 11 | *.egg-info 12 | dist 13 | build 14 | eggs 15 | parts 16 | bin 17 | var 18 | sdist 19 | develop-eggs 20 | .installed.cfg 21 | lib 22 | lib64 23 | 24 | # Installer logs 25 | pip-log.txt 26 | 27 | # Unit test / coverage reports 28 | .coverage 29 | .tox 30 | nosetests.xml 31 | 32 | # Translations 33 | *.mo 34 | 35 | # Mr Developer 36 | .mr.developer.cfg 37 | .project 38 | .pydevproject 39 | -------------------------------------------------------------------------------- /.pylint.rc: -------------------------------------------------------------------------------- 1 | [FORMAT] 2 | 3 | indent-string=' ' 4 | 5 | 6 | [MESSAGES CONTROL] 7 | 8 | # W0511 = fixme 9 | # C0111 = missing docstring 10 | # C0103 = invalid name 11 | # I0011 = locally disabling 12 | # W0142 = star-args 13 | 14 | disable=W0511,C0111,C0103,I0011,W0142 15 | -------------------------------------------------------------------------------- /.travis.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Based on pygit2's .travis.sh 3 | 4 | PREFIX=/home/travis/install 5 | 6 | # Build libssh2 1.9.0 (Ubuntu only has 1.8.0, which doesn't work) 7 | cd ~ 8 | wget https://www.libssh2.org/download/libssh2-1.9.0.tar.gz 9 | tar xf libssh2-1.9.0.tar.gz 10 | cd libssh2-1.9.0 11 | ./configure --prefix=/usr --disable-static && make 12 | sudo make install 13 | 14 | # Build libgit2 15 | cd ~ 16 | git clone --depth=1 -b "maint/v1.1" https://github.com/libgit2/libgit2.git 17 | cd libgit2/ 18 | 19 | mkdir build && cd build 20 | cmake .. -DCMAKE_INSTALL_PREFIX=../_install -DBUILD_CLAR=OFF # don't build unit tests 21 | cmake --build . --target install 22 | ls -la .. 23 | 24 | cd ~ 25 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | dist: focal 3 | python: 4 | - '3.8' 5 | - '3.9' 6 | 7 | env: LIBGIT2=~/libgit2/_install/ LD_LIBRARY_PATH=~/libgit2/_install/lib 8 | before_install: ./.travis.sh 9 | install: pip install -r requirements.txt . 10 | before_script: 11 | - git config --global user.name "travis-test" 12 | - git config --global user.email "travis@test.com" 13 | script: 14 | - python -m unittest discover gitless/tests 15 | branches: 16 | only: 17 | - master 18 | jobs: 19 | include: 20 | - stage: Pack snap 21 | addons: 22 | snaps: 23 | - name: snapcraft 24 | classic: true 25 | - name: multipass 26 | classic: true 27 | channel: beta 28 | env: EMPTY 29 | before_install: skip 30 | install: skip 31 | script: sudo snapcraft --destructive-mode 32 | deploy: 33 | on: 34 | branch: master 35 | provider: snap 36 | snap: "*.snap" 37 | channel: edge 38 | skip_cleanup: true 39 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Santiago Perez De Rosso 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 14 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 15 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 16 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 17 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 18 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 19 | OR OTHER DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md LICENSE.md requirements.txt 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Gitless 2 | ======= 3 | 4 | [![PyPI version](https://img.shields.io/pypi/v/gitless.svg)](https://pypi.org/project/gitless "PyPI version") 5 | [![Homebrew Formula](https://img.shields.io/homebrew/v/gitless.svg)](https://formulae.brew.sh/formula/gitless "Homebrew Formula") 6 | 7 | [![Travis Build Status](https://img.shields.io/travis/gitless-vcs/gitless/master.svg)](https://travis-ci.org/gitless-vcs/gitless "Travis Build Status") 8 | [![AppVeyor Build Status](https://ci.appveyor.com/api/projects/status/github/gitless-vcs/gitless?svg=true)](https://ci.appveyor.com/project/spderosso/gitless-11bfm "AppVeyor Build Status") 9 | 10 | [Gitless](http://gitless.com "Gitless's website") is a version control system built on top of Git, that is easy to learn and use: 11 | 12 | - **Simple commit workflow** 13 | 14 | Track or untrack files to control what changes to commit. Changes to tracked files are committed by default, but you can easily customize the set of files to commit using flags 15 | - **Independent branches** 16 | 17 | Branches in Gitless include your working changes, so you can switch between branches without having to worry about conflicting uncommitted changes 18 | - **Friendly command-line interface** 19 | 20 | Gitless commands will give you good feedback and help you figure out what to do next 21 | - **Compatible with Git** 22 | 23 | Because Gitless is implemented on top of Git, you can always fall back on Git. And your coworkers you share a repo with need never know that you're not a Git aficionado. Moreover, you can use Gitless with GitHub or with any Git hosting service 24 | 25 | 26 | Install 27 | ------- 28 | 29 | Installing Gitless won't interfere with your Git installation in any 30 | way. You can keep using Git and switch between Git and Gitless seamlessly. 31 | 32 | We currently require Git (1.7.12+) to be installed, but this requirement is 33 | going to disappear soon once we finish with our migration to [pygit2](https://github.com/libgit2/pygit2). 34 | 35 | 36 | ### Binary release (macOS and Linux only) 37 | 38 | Binary releases for macOS and Linux are available from the 39 | [Gitless website](http://gitless.com "Gitless's website"). 40 | 41 | If you've downloaded a binary release of Gitless everything is contained in the 42 | gl binary, so to install simply do: 43 | 44 | $ cp path-to-downloaded-gl-binary /usr/local/bin/gl 45 | 46 | You can put the binary in other locations as well, just be sure to update your 47 | `PATH`. 48 | 49 | If for some reason this doesn't work (maybe you are running an old version of 50 | your OS?), try one of the other options (installing from source or via 51 | the Python Package Index). 52 | 53 | ### Installing from source 54 | 55 | To install from source you need to have Python 3.7+ installed. 56 | 57 | Additionally, you need to [install pygit2]( 58 | http://www.pygit2.org/install.html "pygit2 install"). 59 | 60 | Then, [download the source code tarball](http://gitless.com "Gitless's website") 61 | and do: 62 | 63 | $ ./setup.py install 64 | 65 | 66 | ### Installing via the Python Package Index 67 | 68 | If you are a Python fan you might find it easier to install 69 | Gitless via the Python Package Index. To do this, you need to have 70 | Python 3.7+ installed. 71 | 72 | Additionally, you need to [install pygit2]( 73 | http://www.pygit2.org/install.html "pygit2 install"). 74 | 75 | Then, just do: 76 | 77 | $ pip install gitless 78 | 79 | ### Installing via Homebrew (macOS only) 80 | 81 | If you are using [Homebrew](http://brew.sh/ "Homebrew homepage"), a package 82 | manager for macOS, you can install Gitless with: 83 | 84 | ``` 85 | brew update 86 | brew install gitless 87 | ``` 88 | 89 | ### Installing via Snapcraft (Linux only) 90 | 91 | If you are using [Snapcraft](https://snapcraft.io/ "Snapcraft"), a 92 | package manager for Linux, you can install the most recent release 93 | of Gitless with: 94 | 95 | ``` 96 | snap install --channel=beta gitless 97 | ``` 98 | 99 | You can also use the `edge` channel to install the most recent build. 100 | 101 | ### Installing via the Arch User Repository (Arch Linux only) 102 | 103 | If you are using [Arch Linux](https://www.archlinux.org/) or any of 104 | its derivatives, you can use your favorite 105 | [AUR Helper](https://wiki.archlinux.org/index.php/AUR_helpers) and install: 106 | - [gitless](https://aur.archlinux.org/packages/gitless/) for the latest 107 | released version 108 | - [gitless-git](https://aur.archlinux.org/packages/gitless-git/) to 109 | build the latest version straight from this repo 110 | 111 | Documentation 112 | ------------- 113 | 114 | `gl -h`, `gl subcmd -h` or check 115 | [our documentation](http://gitless.com "Gitless's website") 116 | 117 | 118 | Contribute 119 | ---------- 120 | 121 | If you find a bug, create an issue in our 122 | GitHub repository. If you'd like to contribute 123 | code, here are some useful things to know: 124 | 125 | - To install gitless for development, [install pygit2]( 126 | http://www.pygit2.org/install.html "pygit2 install"), clone the repo, 127 | `cd` to the repo root and do `./setup.py develop`. This will install 128 | the `gl` command with a symlink to your source files. You can make 129 | changes to your code and run `gl` to test them. 130 | - We follow, to some extent, the [Google Python Style Guide]( 131 | https://google.github.io/styleguide/pyguide.html 132 | "Google Python Style Guide"). 133 | Before submitting code, take a few seconds to look at the style guide and the 134 | Gitless code so that your edits are consistent with the codebase. 135 | 136 | - Finally, if you don't want [Travis]( 137 | https://travis-ci.org/gitless-vcs/gitless "Travis") to 138 | be mad at you, check that tests pass in Python 3.7+. Tests can be run with: 139 | ``` 140 | python -m unittest discover gitless/tests 141 | ``` 142 | -------------------------------------------------------------------------------- /gitless.1: -------------------------------------------------------------------------------- 1 | .TH gitless "1" Manual 2 | .SH NAME 3 | gl 4 | .SH SYNOPSIS 5 | .B gl 6 | [-h] [--version] {track,tr,untrack,un,status,st,diff,df,commit,ci,branch,br,tag,tg,checkout,co,merge,mg,resolve,rs,fuse,fs,remote,rt,publish,pb,switch,sw,init,in,history,hs} ... 7 | .SH DESCRIPTION 8 | Gitless: a version control system built on top of Git. 9 | More info, downloads and documentation at http://gitless.com 10 | .SH OPTIONS 11 | 12 | .TP 13 | \fB\-\-version\fR 14 | show program's version number and exit 15 | 16 | .SS 17 | \fBSub-commands\fR 18 | .TP 19 | \fBgl\fR \fI\,track\/\fR 20 | start tracking changes to files 21 | .TP 22 | \fBgl\fR \fI\,untrack\/\fR 23 | stop tracking changes to files 24 | .TP 25 | \fBgl\fR \fI\,status\/\fR 26 | show status of the repo 27 | .TP 28 | \fBgl\fR \fI\,diff\/\fR 29 | show changes to files 30 | .TP 31 | \fBgl\fR \fI\,commit\/\fR 32 | save changes to the local repository 33 | .TP 34 | \fBgl\fR \fI\,branch\/\fR 35 | list, create, delete, or edit branches 36 | .TP 37 | \fBgl\fR \fI\,tag\/\fR 38 | list, create, or delete tags 39 | .TP 40 | \fBgl\fR \fI\,checkout\/\fR 41 | checkout committed versions of files 42 | .TP 43 | \fBgl\fR \fI\,merge\/\fR 44 | merge the divergent changes of one branch onto another 45 | .TP 46 | \fBgl\fR \fI\,resolve\/\fR 47 | mark files with conflicts as resolved 48 | .TP 49 | \fBgl\fR \fI\,fuse\/\fR 50 | fuse the divergent changes of a branch onto the current branch 51 | .TP 52 | \fBgl\fR \fI\,remote\/\fR 53 | list, create, edit or delete remotes 54 | .TP 55 | \fBgl\fR \fI\,publish\/\fR 56 | publish commits upstream 57 | .TP 58 | \fBgl\fR \fI\,switch\/\fR 59 | switch branches 60 | .TP 61 | \fBgl\fR \fI\,init\/\fR 62 | create an empty git repository or clone remote 63 | .TP 64 | \fBgl\fR \fI\,history\/\fR 65 | show commit history 66 | .SH OPTIONS 'gl track' 67 | usage: gl track [-h] files [files ...] 68 | 69 | Start tracking changes to files 70 | 71 | .TP 72 | \fBfiles\fR 73 | the file(s) to track 74 | 75 | 76 | .SH OPTIONS 'gl tr' 77 | usage: gl track [-h] files [files ...] 78 | 79 | Start tracking changes to files 80 | 81 | .TP 82 | \fBfiles\fR 83 | the file(s) to track 84 | 85 | 86 | .SH OPTIONS 'gl untrack' 87 | usage: gl untrack [-h] files [files ...] 88 | 89 | Stop tracking changes to files 90 | 91 | .TP 92 | \fBfiles\fR 93 | the file(s) to untrack 94 | 95 | 96 | .SH OPTIONS 'gl un' 97 | usage: gl untrack [-h] files [files ...] 98 | 99 | Stop tracking changes to files 100 | 101 | .TP 102 | \fBfiles\fR 103 | the file(s) to untrack 104 | 105 | 106 | .SH OPTIONS 'gl status' 107 | usage: gl status [-h] [paths [paths ...]] 108 | 109 | Show status of the repo 110 | 111 | .TP 112 | \fBpaths\fR 113 | the specific path(s) to status 114 | 115 | 116 | .SH OPTIONS 'gl st' 117 | usage: gl status [-h] [paths [paths ...]] 118 | 119 | Show status of the repo 120 | 121 | .TP 122 | \fBpaths\fR 123 | the specific path(s) to status 124 | 125 | 126 | .SH OPTIONS 'gl diff' 127 | usage: gl diff [-h] [-e file [file ...]] [-i file [file ...]] 128 | [file [file ...]] 129 | 130 | Show changes to files. By default all tracked modified files are diffed. To customize the set of files to diff use the only, exclude, and include flags 131 | 132 | .TP 133 | \fBfile\fR 134 | use only files given (tracked modified or untracked) 135 | 136 | .TP 137 | \fB\-e\fR file [file ...], \fB\-\-exclude\fR file [file ...] 138 | exclude files given (files must be tracked modified) 139 | 140 | .TP 141 | \fB\-i\fR file [file ...], \fB\-\-include\fR file [file ...] 142 | include files given (files must be untracked) 143 | 144 | .SH OPTIONS 'gl df' 145 | usage: gl diff [-h] [-e file [file ...]] [-i file [file ...]] 146 | [file [file ...]] 147 | 148 | Show changes to files. By default all tracked modified files are diffed. To customize the set of files to diff use the only, exclude, and include flags 149 | 150 | .TP 151 | \fBfile\fR 152 | use only files given (tracked modified or untracked) 153 | 154 | .TP 155 | \fB\-e\fR file [file ...], \fB\-\-exclude\fR file [file ...] 156 | exclude files given (files must be tracked modified) 157 | 158 | .TP 159 | \fB\-i\fR file [file ...], \fB\-\-include\fR file [file ...] 160 | include files given (files must be untracked) 161 | 162 | .SH OPTIONS 'gl commit' 163 | usage: gl commit [-h] [-m M] [-p] [-e file [file ...]] 164 | [-i file [file ...]] 165 | [file [file ...]] 166 | 167 | Save changes to the local repository. By default all tracked modified files are committed. To customize the set of files to be committed use the only, exclude, and include flags 168 | 169 | .TP 170 | \fBfile\fR 171 | use only files given (tracked modified or untracked) 172 | 173 | .TP 174 | \fB\-m\fR \fI\,M\/\fR, \fB\-\-message\fR \fI\,M\/\fR 175 | Commit message 176 | 177 | .TP 178 | \fB\-p\fR, \fB\-\-partial\fR 179 | Interactively select segments of files to commit 180 | 181 | .TP 182 | \fB\-e\fR file [file ...], \fB\-\-exclude\fR file [file ...] 183 | exclude files given (files must be tracked modified) 184 | 185 | .TP 186 | \fB\-i\fR file [file ...], \fB\-\-include\fR file [file ...] 187 | include files given (files must be untracked) 188 | 189 | .SH OPTIONS 'gl ci' 190 | usage: gl commit [-h] [-m M] [-p] [-e file [file ...]] 191 | [-i file [file ...]] 192 | [file [file ...]] 193 | 194 | Save changes to the local repository. By default all tracked modified files are committed. To customize the set of files to be committed use the only, exclude, and include flags 195 | 196 | .TP 197 | \fBfile\fR 198 | use only files given (tracked modified or untracked) 199 | 200 | .TP 201 | \fB\-m\fR \fI\,M\/\fR, \fB\-\-message\fR \fI\,M\/\fR 202 | Commit message 203 | 204 | .TP 205 | \fB\-p\fR, \fB\-\-partial\fR 206 | Interactively select segments of files to commit 207 | 208 | .TP 209 | \fB\-e\fR file [file ...], \fB\-\-exclude\fR file [file ...] 210 | exclude files given (files must be tracked modified) 211 | 212 | .TP 213 | \fB\-i\fR file [file ...], \fB\-\-include\fR file [file ...] 214 | include files given (files must be untracked) 215 | 216 | .SH OPTIONS 'gl branch' 217 | usage: gl branch [-h] [-r] [-v] [-c branch [branch ...]] 218 | [-dp DP] [-d branch [branch ...]] 219 | [-sh commit_id] [-su branch] [-uu] 220 | [-rn RENAME_B [RENAME_B ...]] 221 | 222 | List, create, delete, or edit branches 223 | 224 | 225 | 226 | .TP 227 | \fB\-r\fR, \fB\-\-remote\fR 228 | list remote branches in addition to local branches 229 | 230 | .TP 231 | \fB\-v\fR, \fB\-\-verbose\fR 232 | be verbose, will output the head of each branch 233 | 234 | .TP 235 | \fB\-c\fR branch [branch ...], \fB\-\-create\fR branch [branch ...] 236 | create branch(es) 237 | 238 | .TP 239 | \fB\-dp\fR \fI\,DP\/\fR, \fB\-\-divergent\-point\fR \fI\,DP\/\fR 240 | the commit from where to 'branch out' (only relevant if a new branch is created; defaults to HEAD) 241 | 242 | .TP 243 | \fB\-d\fR branch [branch ...], \fB\-\-delete\fR branch [branch ...] 244 | delete branch(es) 245 | 246 | .TP 247 | \fB\-sh\fR commit_id, \fB\-\-set\-head\fR commit_id 248 | set the head of the current branch 249 | 250 | .TP 251 | \fB\-su\fR branch, \fB\-\-set\-upstream\fR branch 252 | set the upstream branch of the current branch 253 | 254 | .TP 255 | \fB\-uu\fR, \fB\-\-unset\-upstream\fR 256 | unset the upstream branch of the current branch 257 | 258 | .TP 259 | \fB\-rn\fR \fI\,RENAME_B\/\fR [\fI\,RENAME_B\/\fR ...], \fB\-\-rename\-branch\fR \fI\,RENAME_B\/\fR [\fI\,RENAME_B\/\fR ...] 260 | renames the current branch (gl branch \-rn new_name) or another specified branch (gl branch \-rn branch_name new_name) 261 | 262 | .SH OPTIONS 'gl br' 263 | usage: gl branch [-h] [-r] [-v] [-c branch [branch ...]] 264 | [-dp DP] [-d branch [branch ...]] 265 | [-sh commit_id] [-su branch] [-uu] 266 | [-rn RENAME_B [RENAME_B ...]] 267 | 268 | List, create, delete, or edit branches 269 | 270 | 271 | 272 | .TP 273 | \fB\-r\fR, \fB\-\-remote\fR 274 | list remote branches in addition to local branches 275 | 276 | .TP 277 | \fB\-v\fR, \fB\-\-verbose\fR 278 | be verbose, will output the head of each branch 279 | 280 | .TP 281 | \fB\-c\fR branch [branch ...], \fB\-\-create\fR branch [branch ...] 282 | create branch(es) 283 | 284 | .TP 285 | \fB\-dp\fR \fI\,DP\/\fR, \fB\-\-divergent\-point\fR \fI\,DP\/\fR 286 | the commit from where to 'branch out' (only relevant if a new branch is created; defaults to HEAD) 287 | 288 | .TP 289 | \fB\-d\fR branch [branch ...], \fB\-\-delete\fR branch [branch ...] 290 | delete branch(es) 291 | 292 | .TP 293 | \fB\-sh\fR commit_id, \fB\-\-set\-head\fR commit_id 294 | set the head of the current branch 295 | 296 | .TP 297 | \fB\-su\fR branch, \fB\-\-set\-upstream\fR branch 298 | set the upstream branch of the current branch 299 | 300 | .TP 301 | \fB\-uu\fR, \fB\-\-unset\-upstream\fR 302 | unset the upstream branch of the current branch 303 | 304 | .TP 305 | \fB\-rn\fR \fI\,RENAME_B\/\fR [\fI\,RENAME_B\/\fR ...], \fB\-\-rename\-branch\fR \fI\,RENAME_B\/\fR [\fI\,RENAME_B\/\fR ...] 306 | renames the current branch (gl branch \-rn new_name) or another specified branch (gl branch \-rn branch_name new_name) 307 | 308 | .SH OPTIONS 'gl tag' 309 | usage: gl tag [-h] [-r] [-c tag [tag ...]] [-ci CI] 310 | [-d tag [tag ...]] 311 | 312 | List, create, or delete tags 313 | 314 | 315 | 316 | .TP 317 | \fB\-r\fR, \fB\-\-remote\fR 318 | list remote tags in addition to local tags 319 | 320 | .TP 321 | \fB\-c\fR tag [tag ...], \fB\-\-create\fR tag [tag ...] 322 | create tag(s) 323 | 324 | .TP 325 | \fB\-ci\fR \fI\,CI\/\fR, \fB\-\-commit\fR \fI\,CI\/\fR 326 | the commit to tag (only relevant if a new tag is created; defaults to the HEAD commit) 327 | 328 | .TP 329 | \fB\-d\fR tag [tag ...], \fB\-\-delete\fR tag [tag ...] 330 | delete tag(s) 331 | 332 | .SH OPTIONS 'gl tg' 333 | usage: gl tag [-h] [-r] [-c tag [tag ...]] [-ci CI] 334 | [-d tag [tag ...]] 335 | 336 | List, create, or delete tags 337 | 338 | 339 | 340 | .TP 341 | \fB\-r\fR, \fB\-\-remote\fR 342 | list remote tags in addition to local tags 343 | 344 | .TP 345 | \fB\-c\fR tag [tag ...], \fB\-\-create\fR tag [tag ...] 346 | create tag(s) 347 | 348 | .TP 349 | \fB\-ci\fR \fI\,CI\/\fR, \fB\-\-commit\fR \fI\,CI\/\fR 350 | the commit to tag (only relevant if a new tag is created; defaults to the HEAD commit) 351 | 352 | .TP 353 | \fB\-d\fR tag [tag ...], \fB\-\-delete\fR tag [tag ...] 354 | delete tag(s) 355 | 356 | .SH OPTIONS 'gl checkout' 357 | usage: gl checkout [-h] [-cp CP] files [files ...] 358 | 359 | Checkout committed versions of files 360 | 361 | .TP 362 | \fBfiles\fR 363 | the file(s) to checkout 364 | 365 | .TP 366 | \fB\-cp\fR \fI\,CP\/\fR, \fB\-\-commit\-point\fR \fI\,CP\/\fR 367 | the commit point to checkout the files at. Defaults to HEAD. 368 | 369 | .SH OPTIONS 'gl co' 370 | usage: gl checkout [-h] [-cp CP] files [files ...] 371 | 372 | Checkout committed versions of files 373 | 374 | .TP 375 | \fBfiles\fR 376 | the file(s) to checkout 377 | 378 | .TP 379 | \fB\-cp\fR \fI\,CP\/\fR, \fB\-\-commit\-point\fR \fI\,CP\/\fR 380 | the commit point to checkout the files at. Defaults to HEAD. 381 | 382 | .SH OPTIONS 'gl merge' 383 | usage: gl merge [-h] [-a] [src] 384 | 385 | Merge the divergent changes of one branch onto another 386 | 387 | .TP 388 | \fBsrc\fR 389 | the source branch to read changes from 390 | 391 | .TP 392 | \fB\-a\fR, \fB\-\-abort\fR 393 | abort the merge in progress 394 | 395 | .SH OPTIONS 'gl mg' 396 | usage: gl merge [-h] [-a] [src] 397 | 398 | Merge the divergent changes of one branch onto another 399 | 400 | .TP 401 | \fBsrc\fR 402 | the source branch to read changes from 403 | 404 | .TP 405 | \fB\-a\fR, \fB\-\-abort\fR 406 | abort the merge in progress 407 | 408 | .SH OPTIONS 'gl resolve' 409 | usage: gl resolve [-h] files [files ...] 410 | 411 | Mark files with conflicts as resolved 412 | 413 | .TP 414 | \fBfiles\fR 415 | the file(s) to resolve 416 | 417 | 418 | .SH OPTIONS 'gl rs' 419 | usage: gl resolve [-h] files [files ...] 420 | 421 | Mark files with conflicts as resolved 422 | 423 | .TP 424 | \fBfiles\fR 425 | the file(s) to resolve 426 | 427 | 428 | .SH OPTIONS 'gl fuse' 429 | usage: gl fuse [-h] [-o commit_id [commit_id ...]] 430 | [-e commit_id [commit_id ...]] [-ip [commit_id]] 431 | [-a] 432 | [src] 433 | 434 | Fuse the divergent changes of a branch onto the current branch. By default all divergent changes from the given source branch are fused. To customize the set of commits to fuse use the only and exclude flags 435 | 436 | .TP 437 | \fBsrc\fR 438 | the source branch to read changes from. If none is given the upstream branch of the current branch is used as the source 439 | 440 | .TP 441 | \fB\-o\fR commit_id [commit_id ...], \fB\-\-only\fR commit_id [commit_id ...] 442 | fuse only the commits given (commits must belong to the set of divergent commits from the given src branch) 443 | 444 | .TP 445 | \fB\-e\fR commit_id [commit_id ...], \fB\-\-exclude\fR commit_id [commit_id ...] 446 | exclude from the fuse the commits given (commits must belong to the set of divergent commits from the given src branch) 447 | 448 | .TP 449 | \fB\-ip\fR [commit_id], \fB\-\-insertion\-point\fR [commit_id] 450 | the divergent changes will be inserted after the commit given, dp for divergent point is the default 451 | 452 | .TP 453 | \fB\-a\fR, \fB\-\-abort\fR 454 | abort the fuse in progress 455 | 456 | .SH OPTIONS 'gl fs' 457 | usage: gl fuse [-h] [-o commit_id [commit_id ...]] 458 | [-e commit_id [commit_id ...]] [-ip [commit_id]] 459 | [-a] 460 | [src] 461 | 462 | Fuse the divergent changes of a branch onto the current branch. By default all divergent changes from the given source branch are fused. To customize the set of commits to fuse use the only and exclude flags 463 | 464 | .TP 465 | \fBsrc\fR 466 | the source branch to read changes from. If none is given the upstream branch of the current branch is used as the source 467 | 468 | .TP 469 | \fB\-o\fR commit_id [commit_id ...], \fB\-\-only\fR commit_id [commit_id ...] 470 | fuse only the commits given (commits must belong to the set of divergent commits from the given src branch) 471 | 472 | .TP 473 | \fB\-e\fR commit_id [commit_id ...], \fB\-\-exclude\fR commit_id [commit_id ...] 474 | exclude from the fuse the commits given (commits must belong to the set of divergent commits from the given src branch) 475 | 476 | .TP 477 | \fB\-ip\fR [commit_id], \fB\-\-insertion\-point\fR [commit_id] 478 | the divergent changes will be inserted after the commit given, dp for divergent point is the default 479 | 480 | .TP 481 | \fB\-a\fR, \fB\-\-abort\fR 482 | abort the fuse in progress 483 | 484 | .SH OPTIONS 'gl remote' 485 | usage: gl remote [-h] [-c [remote]] [-d remote [remote ...]] 486 | [-rn RENAME_R [RENAME_R ...]] 487 | [remote_url] 488 | 489 | List, create, edit or delete remotes 490 | 491 | .TP 492 | \fBremote_url\fR 493 | the url of the remote (only relevant if a new remote is created) 494 | 495 | .TP 496 | \fB\-c\fR [remote], \fB\-\-create\fR [remote] 497 | create remote 498 | 499 | .TP 500 | \fB\-d\fR remote [remote ...], \fB\-\-delete\fR remote [remote ...] 501 | delete remote(es) 502 | 503 | .TP 504 | \fB\-rn\fR \fI\,RENAME_R\/\fR [\fI\,RENAME_R\/\fR ...], \fB\-\-rename\fR \fI\,RENAME_R\/\fR [\fI\,RENAME_R\/\fR ...] 505 | renames the specified remote: accepts two arguments (current remote name and new remote name) 506 | 507 | .SH OPTIONS 'gl rt' 508 | usage: gl remote [-h] [-c [remote]] [-d remote [remote ...]] 509 | [-rn RENAME_R [RENAME_R ...]] 510 | [remote_url] 511 | 512 | List, create, edit or delete remotes 513 | 514 | .TP 515 | \fBremote_url\fR 516 | the url of the remote (only relevant if a new remote is created) 517 | 518 | .TP 519 | \fB\-c\fR [remote], \fB\-\-create\fR [remote] 520 | create remote 521 | 522 | .TP 523 | \fB\-d\fR remote [remote ...], \fB\-\-delete\fR remote [remote ...] 524 | delete remote(es) 525 | 526 | .TP 527 | \fB\-rn\fR \fI\,RENAME_R\/\fR [\fI\,RENAME_R\/\fR ...], \fB\-\-rename\fR \fI\,RENAME_R\/\fR [\fI\,RENAME_R\/\fR ...] 528 | renames the specified remote: accepts two arguments (current remote name and new remote name) 529 | 530 | .SH OPTIONS 'gl publish' 531 | usage: gl publish [-h] [dst] 532 | 533 | Publish commits upstream 534 | 535 | .TP 536 | \fBdst\fR 537 | the branch where to publish commits 538 | 539 | 540 | .SH OPTIONS 'gl pb' 541 | usage: gl publish [-h] [dst] 542 | 543 | Publish commits upstream 544 | 545 | .TP 546 | \fBdst\fR 547 | the branch where to publish commits 548 | 549 | 550 | .SH OPTIONS 'gl switch' 551 | usage: gl switch [-h] [-mo] branch 552 | 553 | Switch branches 554 | 555 | .TP 556 | \fBbranch\fR 557 | switch to branch 558 | 559 | .TP 560 | \fB\-mo\fR, \fB\-\-move\-over\fR 561 | move uncomitted changes made in the current branch to the destination branch 562 | 563 | .SH OPTIONS 'gl sw' 564 | usage: gl switch [-h] [-mo] branch 565 | 566 | Switch branches 567 | 568 | .TP 569 | \fBbranch\fR 570 | switch to branch 571 | 572 | .TP 573 | \fB\-mo\fR, \fB\-\-move\-over\fR 574 | move uncomitted changes made in the current branch to the destination branch 575 | 576 | .SH OPTIONS 'gl init' 577 | usage: gl init [-h] [-o ONLY [ONLY ...]] 578 | [-e EXCLUDE [EXCLUDE ...]] 579 | [repo] 580 | 581 | Create an empty git repository or clone remote 582 | 583 | .TP 584 | \fBrepo\fR 585 | an optional remote repo address from where to read to create the local repo 586 | 587 | .TP 588 | \fB\-o\fR \fI\,ONLY\/\fR [\fI\,ONLY\/\fR ...], \fB\-\-only\fR \fI\,ONLY\/\fR [\fI\,ONLY\/\fR ...] 589 | use only branches given from remote repo 590 | 591 | .TP 592 | \fB\-e\fR \fI\,EXCLUDE\/\fR [\fI\,EXCLUDE\/\fR ...], \fB\-\-exclude\fR \fI\,EXCLUDE\/\fR [\fI\,EXCLUDE\/\fR ...] 593 | use everything but these branches from remote repo 594 | 595 | .SH OPTIONS 'gl in' 596 | usage: gl init [-h] [-o ONLY [ONLY ...]] 597 | [-e EXCLUDE [EXCLUDE ...]] 598 | [repo] 599 | 600 | Create an empty git repository or clone remote 601 | 602 | .TP 603 | \fBrepo\fR 604 | an optional remote repo address from where to read to create the local repo 605 | 606 | .TP 607 | \fB\-o\fR \fI\,ONLY\/\fR [\fI\,ONLY\/\fR ...], \fB\-\-only\fR \fI\,ONLY\/\fR [\fI\,ONLY\/\fR ...] 608 | use only branches given from remote repo 609 | 610 | .TP 611 | \fB\-e\fR \fI\,EXCLUDE\/\fR [\fI\,EXCLUDE\/\fR ...], \fB\-\-exclude\fR \fI\,EXCLUDE\/\fR [\fI\,EXCLUDE\/\fR ...] 612 | use everything but these branches from remote repo 613 | 614 | .SH OPTIONS 'gl history' 615 | usage: gl history [-h] [-v] [-l LIMIT] [-c] [-b [branch_name]] 616 | 617 | Show commit history 618 | 619 | 620 | .TP 621 | \fB\-v\fR, \fB\-\-verbose\fR 622 | be verbose, will output the diffs of the commit 623 | 624 | .TP 625 | \fB\-l\fR \fI\,LIMIT\/\fR, \fB\-\-limit\fR \fI\,LIMIT\/\fR 626 | limit number of commits displayed 627 | 628 | .TP 629 | \fB\-c\fR, \fB\-\-compact\fR 630 | output history in a compact format 631 | 632 | .TP 633 | \fB\-b\fR [branch_name], \fB\-\-branch\fR [branch_name] 634 | the branch to show history of (defaults to the current branch) 635 | 636 | .SH OPTIONS 'gl hs' 637 | usage: gl history [-h] [-v] [-l LIMIT] [-c] [-b [branch_name]] 638 | 639 | Show commit history 640 | 641 | 642 | .TP 643 | \fB\-v\fR, \fB\-\-verbose\fR 644 | be verbose, will output the diffs of the commit 645 | 646 | .TP 647 | \fB\-l\fR \fI\,LIMIT\/\fR, \fB\-\-limit\fR \fI\,LIMIT\/\fR 648 | limit number of commits displayed 649 | 650 | .TP 651 | \fB\-c\fR, \fB\-\-compact\fR 652 | output history in a compact format 653 | 654 | .TP 655 | \fB\-b\fR [branch_name], \fB\-\-branch\fR [branch_name] 656 | the branch to show history of (defaults to the current branch) 657 | 658 | .SH AUTHORS 659 | .B gitless 660 | was written by Santiago Perez De Rosso . 661 | .SH DISTRIBUTION 662 | The latest version of gitless may be downloaded from 663 | .UR http://gitless.com 664 | .UE 665 | -------------------------------------------------------------------------------- /gitless/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitless-vcs/gitless/3ac28e39e170acdcd1590e0a25a06790ae0e6922/gitless/__init__.py -------------------------------------------------------------------------------- /gitless/cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitless-vcs/gitless/3ac28e39e170acdcd1590e0a25a06790ae0e6922/gitless/cli/__init__.py -------------------------------------------------------------------------------- /gitless/cli/commit_dialog.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Gitless - a version control system built on top of Git 3 | # Licensed under MIT 4 | 5 | """Gitless's commit dialog.""" 6 | 7 | 8 | import io 9 | from locale import getpreferredencoding 10 | import os 11 | import subprocess 12 | import sys 13 | import shlex 14 | 15 | 16 | from . import pprint 17 | 18 | 19 | ENCODING = getpreferredencoding() or 'utf-8' 20 | 21 | _COMMIT_FILE = 'GL_COMMIT_EDIT_MSG' 22 | _MERGE_MSG_FILE = 'MERGE_MSG' 23 | 24 | 25 | def show(files, repo): 26 | """Show the commit dialog. 27 | 28 | Args: 29 | files: files for pre-populating the dialog. 30 | repo: the repository. 31 | 32 | Returns: 33 | The commit msg. 34 | """ 35 | cf = io.open(_commit_file(repo), mode='w', encoding=ENCODING) 36 | 37 | curr_b = repo.current_branch 38 | if curr_b.merge_in_progress or curr_b.fuse_in_progress: 39 | merge_msg = io.open( 40 | _merge_msg_file(repo), mode='r', encoding=ENCODING).read() 41 | cf.write(merge_msg) 42 | cf.write('\n') 43 | pprint.sep(stream=cf.write) 44 | pprint.msg( 45 | 'Please enter the commit message for your changes above, an empty ' 46 | 'message aborts', stream=cf.write) 47 | pprint.msg('the commit.', stream=cf.write) 48 | pprint.blank(stream=cf.write) 49 | pprint.msg( 50 | 'These are the files whose changes will be committed:', stream=cf.write) 51 | for f in files: 52 | pprint.item(f, stream=cf.write) 53 | pprint.sep(stream=cf.write) 54 | cf.close() 55 | _launch_editor(cf.name, repo) 56 | return _extract_msg(repo) 57 | 58 | 59 | def _launch_editor(fp, repo): 60 | try: 61 | editor = repo.config['core.editor'] 62 | except KeyError: 63 | editor = os.environ['EDITOR'] if 'EDITOR' in os.environ else 'vim' 64 | 65 | cmd = shlex.split(editor) 66 | cmd.append(fp) 67 | 68 | try: 69 | ret = subprocess.call(cmd) 70 | if ret != 0: 71 | pprint.err('Call to editor {0} failed'.format(editor)) 72 | except OSError: 73 | pprint.err('Couldn\'t launch editor {0}'.format(editor)) 74 | pprint.err_exp('change the value of git\'s core.editor setting') 75 | 76 | 77 | def _extract_msg(repo): 78 | cf = io.open(_commit_file(repo), mode='r', encoding=ENCODING) 79 | sep = pprint.SEP + '\n' 80 | msg = '' 81 | l = cf.readline() 82 | while l != sep and len(l) > 0: 83 | msg += l 84 | l = cf.readline() 85 | # We reached the separator, this marks the end of the commit msg 86 | 87 | return msg 88 | 89 | 90 | def _commit_file(repo): 91 | return os.path.join(repo.path, _COMMIT_FILE) 92 | 93 | 94 | def _merge_msg_file(repo): 95 | return os.path.join(repo.path, _MERGE_MSG_FILE) 96 | -------------------------------------------------------------------------------- /gitless/cli/file_cmd.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Gitless - a version control system built on top of Git 3 | # Licensed under MIT 4 | 5 | """Helper module for gl_{track, untrack, resolve}.""" 6 | 7 | 8 | from . import helpers, pprint 9 | 10 | 11 | VOWELS = ('a', 'e', 'i', 'o', 'u') 12 | 13 | 14 | def parser(help_msg, subcmd, subcmd_aliases=[]): 15 | def f(subparsers, repo): 16 | p = subparsers.add_parser( 17 | subcmd, help=help_msg, description=help_msg.capitalize(), aliases=subcmd_aliases) 18 | p.add_argument( 19 | 'files', nargs='+', help='the file(s) to {0}'.format(subcmd), 20 | action=helpers.PathProcessor, repo=repo, 21 | skip_dir_test=repo and repo.current_branch.path_is_ignored, 22 | skip_dir_cb=lambda path: pprint.warn( 23 | 'Skipped files under directory {0} since they are all ' 24 | 'ignored'.format(path))) 25 | p.set_defaults(func=main(subcmd)) 26 | return f 27 | 28 | 29 | def main(subcmd): 30 | def f(args, repo): 31 | curr_b = repo.current_branch 32 | success = True 33 | 34 | for fp in args.files: 35 | try: 36 | getattr(curr_b, subcmd + '_file')(fp) 37 | pprint.ok( 38 | 'File {0} is now a{1} {2}{3}d file'.format( 39 | fp, 'n' if subcmd.startswith(VOWELS) else '', subcmd, 40 | '' if subcmd.endswith('e') else 'e')) 41 | except KeyError: 42 | pprint.err('Can\'t {0} non-existent file {1}'.format(subcmd, fp)) 43 | success = False 44 | except ValueError as e: 45 | pprint.err(e) 46 | success = False 47 | 48 | return success 49 | return f 50 | -------------------------------------------------------------------------------- /gitless/cli/gl.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Gitless - a version control system built on top of Git 3 | # Licensed under MIT 4 | 5 | """gl - Main Gitless's command. Dispatcher to the other cmds.""" 6 | 7 | 8 | import sys 9 | import argparse 10 | import argcomplete 11 | import traceback 12 | import pygit2 13 | 14 | from subprocess import CalledProcessError 15 | 16 | from gitless import core 17 | 18 | from . import ( 19 | gl_track, gl_untrack, gl_status, gl_diff, gl_commit, gl_branch, gl_tag, 20 | gl_checkout, gl_merge, gl_resolve, gl_fuse, gl_remote, gl_publish, 21 | gl_switch, gl_init, gl_history) 22 | from . import pprint 23 | from . import helpers 24 | 25 | 26 | SUCCESS = 0 27 | ERRORS_FOUND = 1 28 | # 2 is used by argparse to indicate cmd syntax errors. 29 | INTERNAL_ERROR = 3 30 | NOT_IN_GL_REPO = 4 31 | 32 | __version__ = '0.8.8' 33 | URL = 'http://gitless.com' 34 | 35 | 36 | repo = None 37 | try: 38 | repo = core.Repository() 39 | try: 40 | pprint.DISABLE_COLOR = not repo.config.get_bool('color.ui') 41 | except pygit2.GitError: 42 | pprint.DISABLE_COLOR = ( 43 | repo.config['color.ui'] in ['no', 'never']) 44 | except (core.NotInRepoError, KeyError): 45 | pass 46 | 47 | 48 | def print_help(parser): 49 | """print help for humans""" 50 | print(parser.description) 51 | print('\ncommands:\n') 52 | 53 | # https://stackoverflow.com/questions/20094215/argparse-subparser-monolithic-help-output 54 | # retrieve subparsers from parser 55 | subparsers_actions = [ 56 | action for action in parser._actions 57 | if isinstance(action, argparse._SubParsersAction)] 58 | # there will probably only be one subparser_action, 59 | # but better safe than sorry 60 | for subparsers_action in subparsers_actions: 61 | # get all subparsers and print help 62 | for choice in subparsers_action._choices_actions: 63 | print(' {:<19} {}'.format(choice.dest, choice.help)) 64 | 65 | def build_parser(subcommands, repo): 66 | parser = argparse.ArgumentParser( 67 | description=( 68 | 'Gitless: a version control system built on top of Git.\nMore info, ' 69 | 'downloads and documentation at {0}'.format(URL)), 70 | formatter_class=argparse.RawDescriptionHelpFormatter) 71 | if sys.version_info[0] < 3: 72 | parser.register('action', 'parsers', helpers.AliasedSubParsersAction) 73 | parser.add_argument( 74 | '--version', action='version', version=( 75 | 'GL Version: {0}\nYou can check if there\'s a new version of Gitless ' 76 | 'available at {1}'.format(__version__, URL))) 77 | subparsers = parser.add_subparsers(title='subcommands', dest='subcmd_name') 78 | subparsers.required = True 79 | 80 | for sub_cmd in subcommands: 81 | sub_cmd.parser(subparsers, repo) 82 | 83 | return parser 84 | 85 | def setup_windows_console(): 86 | if sys.platform == 'win32': 87 | import ctypes 88 | kernel32 = ctypes.windll.kernel32 89 | kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7) 90 | 91 | def main(): 92 | sub_cmds = [ 93 | gl_track, gl_untrack, gl_status, gl_diff, gl_commit, gl_branch, gl_tag, 94 | gl_checkout, gl_merge, gl_resolve, gl_fuse, gl_remote, gl_publish, 95 | gl_switch, gl_init, gl_history] 96 | 97 | parser = build_parser(sub_cmds, repo) 98 | argcomplete.autocomplete(parser) 99 | if len(sys.argv) == 1: 100 | print_help(parser) 101 | return SUCCESS 102 | 103 | args = parser.parse_args() 104 | try: 105 | if args.subcmd_name != 'init' and not repo: 106 | raise core.NotInRepoError('You are not in a Gitless\'s repository') 107 | 108 | setup_windows_console() 109 | return SUCCESS if args.func(args, repo) else ERRORS_FOUND 110 | except KeyboardInterrupt: 111 | pprint.puts('\n') 112 | pprint.msg('Keyboard interrupt detected, operation aborted') 113 | return SUCCESS 114 | except core.NotInRepoError as e: 115 | pprint.err(e) 116 | pprint.err_exp('do gl init to turn this directory into an empty repository') 117 | pprint.err_exp('do gl init remote_repo to clone an existing repository') 118 | return NOT_IN_GL_REPO 119 | except (ValueError, pygit2.GitError, core.GlError) as e: 120 | pprint.err(e) 121 | return ERRORS_FOUND 122 | except CalledProcessError as e: 123 | pprint.err(e.stderr) 124 | return ERRORS_FOUND 125 | except: 126 | pprint.err('Some internal error occurred') 127 | pprint.err_exp( 128 | 'If you want to help, see {0} for info on how to report bugs and ' 129 | 'include the following information:\n\n{1}\n\n{2}'.format( 130 | URL, __version__, traceback.format_exc())) 131 | return INTERNAL_ERROR 132 | -------------------------------------------------------------------------------- /gitless/cli/gl_branch.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Gitless - a version control system built on top of Git 3 | # Licensed under MIT 4 | 5 | """gl branch - List, create, edit or delete branches.""" 6 | 7 | 8 | from gitless import core 9 | 10 | from . import helpers, pprint 11 | 12 | 13 | def parser(subparsers, _): 14 | """Adds the branch parser to the given subparsers object.""" 15 | desc = 'list, create, delete, or edit branches' 16 | branch_parser = subparsers.add_parser( 17 | 'branch', help=desc, description=desc.capitalize(), aliases=['br']) 18 | 19 | list_group = branch_parser.add_argument_group('list branches') 20 | list_group.add_argument( 21 | '-r', '--remote', 22 | help='list remote branches in addition to local branches', 23 | action='store_true') 24 | list_group.add_argument( 25 | '-v', '--verbose', help='be verbose, will output the head of each branch', 26 | action='store_true') 27 | 28 | create_group = branch_parser.add_argument_group('create branches') 29 | create_group.add_argument( 30 | '-c', '--create', nargs='+', help='create branch(es)', dest='create_b', 31 | metavar='branch') 32 | create_group.add_argument( 33 | '-dp', '--divergent-point', 34 | help='the commit from where to \'branch out\' (only relevant if a new ' 35 | 'branch is created; defaults to HEAD)', dest='dp') 36 | 37 | delete_group = branch_parser.add_argument_group('delete branches') 38 | delete_group.add_argument( 39 | '-d', '--delete', nargs='+', help='delete branch(es)', dest='delete_b', 40 | metavar='branch') 41 | 42 | edit_current_branch_group = branch_parser.add_argument_group('edit the current branch') 43 | edit_current_branch_group.add_argument( 44 | '-sh', '--set-head', help='set the head of the current branch', 45 | dest='new_head', metavar='commit_id') 46 | edit_current_branch_group.add_argument( 47 | '-su', '--set-upstream', 48 | help='set the upstream branch of the current branch', 49 | dest='upstream_b', metavar='branch') 50 | edit_current_branch_group.add_argument( 51 | '-uu', '--unset-upstream', 52 | help='unset the upstream branch of the current branch', 53 | action='store_true') 54 | 55 | edit_group = branch_parser.add_argument_group('edit branches') 56 | edit_group.add_argument( 57 | '-rn', '--rename-branch', nargs='+', 58 | help='renames the current branch (gl branch -rn new_name) ' 59 | 'or another specified branch (gl branch -rn branch_name new_name)', 60 | dest='rename_b' 61 | ) 62 | 63 | branch_parser.set_defaults(func=main) 64 | 65 | 66 | def main(args, repo): 67 | is_list = bool(args.verbose or args.remote) 68 | is_create = bool(args.create_b or args.dp) 69 | is_delete = bool(args.delete_b) 70 | is_edit = bool(args.new_head or args.upstream_b or args.unset_upstream or args.rename_b) 71 | 72 | if is_list + is_create + is_delete + is_edit > 1: 73 | pprint.err('Invalid flag combination') 74 | pprint.err_exp( 75 | 'Can only do one of list, create, delete, or edit branches at a time') 76 | return False 77 | 78 | ret = True 79 | if args.create_b: 80 | ret = _do_create(args.create_b, args.dp or 'HEAD', repo) 81 | elif args.delete_b: 82 | ret = _do_delete(args.delete_b, repo) 83 | elif args.upstream_b: 84 | ret = _do_set_upstream(args.upstream_b, repo) 85 | elif args.unset_upstream: 86 | ret = _do_unset_upstream(repo) 87 | elif args.new_head: 88 | ret = _do_set_head(args.new_head, repo) 89 | elif args.rename_b: 90 | ret = _do_rename(args.rename_b, repo) 91 | else: 92 | _do_list(repo, args.remote, v=args.verbose) 93 | 94 | return ret 95 | 96 | 97 | def _do_list(repo, list_remote, v=False): 98 | pprint.msg('List of branches:') 99 | pprint.exp('do gl branch -c b to create branch b') 100 | pprint.exp('do gl branch -d b to delete branch b') 101 | pprint.exp('do gl switch b to switch to branch b') 102 | pprint.exp('* = current branch') 103 | pprint.blank() 104 | 105 | 106 | for b in (repo.lookup_branch(n) for n in sorted(repo.listall_branches())): 107 | current_str = '*' if b.is_current else ' ' 108 | upstream_str = '(upstream is {0})'.format(b.upstream) if b.upstream else '' 109 | color = pprint.green if b.is_current else pprint.yellow 110 | pprint.item( 111 | '{0} {1} {2}'.format(current_str, color(b.branch_name), upstream_str)) 112 | if v: 113 | pprint.item(' ➜ head is {0}'.format(pprint.commit_str(b.head))) 114 | 115 | if list_remote: 116 | for r in sorted(repo.remotes, key=lambda r: r.name): 117 | branches = r.lookupall_branches() if v else r.listall_branches() 118 | b_remote = '' if v else r.name + '/' 119 | for b in branches: 120 | pprint.item(' {0}'.format(pprint.yellow(b_remote + str(b)))) 121 | if v: 122 | pprint.item(' ➜ head is {0}'.format(pprint.commit_str(b.head))) 123 | 124 | 125 | def _do_create(create_b, dp, repo): 126 | errors_found = False 127 | 128 | try: 129 | target = repo.revparse_single(dp) 130 | except KeyError: 131 | raise ValueError('Invalid divergent point {0}'.format(dp)) 132 | 133 | for b_name in create_b: 134 | r = repo 135 | remote_str = '' 136 | if '/' in b_name: # might want to create a remote branch 137 | maybe_remote, maybe_remote_branch = b_name.split('/', 1) 138 | if maybe_remote in repo.remotes: 139 | r = repo.remotes[maybe_remote] 140 | b_name = maybe_remote_branch 141 | conf_msg = 'Branch {0} will be created in remote repository {1}'.format( 142 | b_name, maybe_remote) 143 | if not pprint.conf_dialog(conf_msg): 144 | pprint.msg( 145 | 'Aborted: creation of branch {0} in remote repository {1}'.format( 146 | b_name, maybe_remote)) 147 | continue 148 | remote_str = ' in remote repository {0}'.format(maybe_remote) 149 | try: 150 | new_branch = r.create_branch(b_name, target) 151 | pprint.ok('Created new branch {0}{1}'.format(b_name, remote_str)) 152 | try: 153 | new_branch.upstream = helpers.get_branch(dp, repo) 154 | pprint.ok('Upstream of {0} set to {1}'.format(b_name, dp)) 155 | except: 156 | # Not a branch 157 | continue 158 | except ValueError as e: 159 | pprint.err(e) 160 | errors_found = True 161 | 162 | return not errors_found 163 | 164 | 165 | def _do_delete(delete_b, repo): 166 | errors_found = False 167 | 168 | for b_name in delete_b: 169 | try: 170 | b = helpers.get_branch(b_name, repo) 171 | 172 | branch_str = 'Branch {0} will be removed'.format(b.branch_name) 173 | remote_str = '' 174 | if isinstance(b, core.RemoteBranch): 175 | remote_str = ' from remote repository {0}'.format(b.remote_name) 176 | if not pprint.conf_dialog('{0}{1}'.format(branch_str, remote_str)): 177 | pprint.msg('Aborted: removal of branch {0}'.format(b)) 178 | continue 179 | 180 | b.delete() 181 | pprint.ok('Branch {0} removed successfully'.format(b)) 182 | except ValueError as e: 183 | pprint.err(e) 184 | errors_found = True 185 | except core.BranchIsCurrentError as e: 186 | pprint.err(e) 187 | pprint.err_exp( 188 | 'do gl branch b to create or switch to another branch b and then ' 189 | 'gl branch -d {0} to remove branch {0}'.format(b)) 190 | errors_found = True 191 | 192 | return not errors_found 193 | 194 | 195 | def _do_set_upstream(upstream, repo): 196 | curr_b = repo.current_branch 197 | curr_b.upstream = helpers.get_branch(upstream, repo) 198 | pprint.ok('Current branch {0} set to track {1}'.format(curr_b, upstream)) 199 | return True 200 | 201 | 202 | def _do_unset_upstream(repo): 203 | curr_b = repo.current_branch 204 | curr_b.upstream = None 205 | pprint.ok('Upstream unset for current branch {0}'.format(curr_b)) 206 | return True 207 | 208 | 209 | def _do_set_head(commit_id, repo): 210 | try: 211 | commit = repo.revparse_single(commit_id) 212 | except KeyError: 213 | raise ValueError('Invalid head {0}'.format(commit_id)) 214 | 215 | curr_b = repo.current_branch 216 | curr_b.head = commit.id 217 | pprint.ok( 218 | 'Head of current branch {0} is now {1}'.format(curr_b, pprint.commit_str(commit))) 219 | return True 220 | 221 | 222 | def _do_rename(rename_b, repo): 223 | ret = True 224 | if len(rename_b) == 1 : 225 | # Renaming the current branch 226 | curr_b = repo.current_branch 227 | curr_b.rename(rename_b[0]) 228 | pprint.ok('Renamed this branch to {0}'.format(rename_b[0])) 229 | elif len(rename_b) == 2: 230 | # Renaming a specified branch to a new name 231 | b = helpers.get_branch(rename_b[0], repo) 232 | b.rename(rename_b[1]) 233 | pprint.ok('Renamed branch {0} to {1}'.format(rename_b[0], rename_b[1])) 234 | else : 235 | # Gave more than 2 arguments 236 | pprint.err( 237 | 'Too many arguments given. Expected 1 or 2 arguments.') 238 | ret = False 239 | return ret 240 | -------------------------------------------------------------------------------- /gitless/cli/gl_checkout.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Gitless - a version control system built on top of Git 3 | # Licensed under MIT 4 | 5 | """gl checkout - Checkout committed versions of files.""" 6 | 7 | 8 | from gitless import core 9 | 10 | from . import helpers, pprint 11 | 12 | 13 | def parser(subparsers, repo): 14 | """Adds the checkout parser to the given subparsers object.""" 15 | desc = 'checkout committed versions of files' 16 | checkout_parser = subparsers.add_parser( 17 | 'checkout', help=desc, description=desc.capitalize(), aliases=['co']) 18 | checkout_parser.add_argument( 19 | '-cp', '--commit-point', help=( 20 | 'the commit point to checkout the files at. Defaults to HEAD.'), 21 | dest='cp', default='HEAD') 22 | checkout_parser.add_argument( 23 | 'files', nargs='+', help='the file(s) to checkout', 24 | action=helpers.PathProcessor, repo=repo, recursive=False) 25 | checkout_parser.set_defaults(func=main) 26 | 27 | 28 | def main(args, repo): 29 | errors_found = False 30 | 31 | curr_b = repo.current_branch 32 | cp = args.cp 33 | 34 | for fp in args.files: 35 | conf_msg = ( 36 | 'You have uncomitted changes in "{0}" that could be overwritten by ' 37 | 'checkout'.format(fp)) 38 | try: 39 | f = curr_b.status_file(fp) 40 | if f.type == core.GL_STATUS_TRACKED and f.modified and ( 41 | not pprint.conf_dialog(conf_msg)): 42 | pprint.err('Checkout aborted') 43 | continue 44 | except KeyError: 45 | pass 46 | 47 | try: 48 | curr_b.checkout_file(fp, repo.revparse_single(cp)) 49 | pprint.ok( 50 | 'File {0} checked out successfully to its state at {1}'.format( 51 | fp, cp)) 52 | except core.PathIsDirectoryError: 53 | commit = repo.revparse_single(cp) 54 | for fp in curr_b.get_paths(fp, commit): 55 | curr_b.checkout_file(fp, commit) 56 | pprint.ok( 57 | 'File {0} checked out successfully to its state at {1}'.format( 58 | fp, cp)) 59 | except KeyError: 60 | pprint.err('Checkout aborted') 61 | pprint.err('There\'s no file {0} at {1}'.format(fp, cp)) 62 | errors_found = True 63 | 64 | return not errors_found 65 | -------------------------------------------------------------------------------- /gitless/cli/gl_commit.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Gitless - a version control system built on top of Git 3 | # Licensed under MIT 4 | 5 | """gl commit - Record changes in the local repository.""" 6 | 7 | 8 | from gitless import core 9 | 10 | from . import commit_dialog 11 | from . import helpers, pprint 12 | 13 | 14 | def parser(subparsers, repo): 15 | """Adds the commit parser to the given subparsers object.""" 16 | desc = 'save changes to the local repository' 17 | commit_parser = subparsers.add_parser( 18 | 'commit', help=desc, description=( 19 | desc.capitalize() + '. ' + 20 | 'By default all tracked modified files are committed. To customize the' 21 | ' set of files to be committed use the only, exclude, and include ' 22 | 'flags'), aliases=['ci']) 23 | commit_parser.add_argument( 24 | '-m', '--message', help='Commit message', dest='m') 25 | commit_parser.add_argument( 26 | '-p', '--partial', 27 | help='Interactively select segments of files to commit', dest='p', 28 | action='store_true') 29 | helpers.oei_flags(commit_parser, repo) 30 | commit_parser.set_defaults(func=main) 31 | 32 | 33 | def main(args, repo): 34 | commit_files = helpers.oei_fs(args, repo) 35 | 36 | if not commit_files: 37 | pprint.err('No files to commit') 38 | pprint.err_exp('use gl track f if you want to track changes to file f') 39 | return False 40 | 41 | curr_b = repo.current_branch 42 | total_additions = 0 43 | total_deletions = 0 44 | for fp in commit_files: 45 | try: 46 | patch = curr_b.diff_file(fp) 47 | except KeyError: 48 | continue 49 | 50 | if patch.delta.is_binary: 51 | continue 52 | 53 | total_additions += patch.line_stats[1] 54 | total_deletions += patch.line_stats[2] 55 | 56 | partials = None 57 | if args.p: 58 | partials = _do_partial_selection(commit_files, curr_b) 59 | 60 | if not _author_info_is_ok(repo): 61 | return False 62 | 63 | msg = args.m if args.m else commit_dialog.show(commit_files, repo) 64 | if not msg.strip(): 65 | if partials: 66 | core.git('reset', 'HEAD', partials) 67 | raise ValueError('Missing commit message') 68 | 69 | _auto_track(commit_files, curr_b) 70 | ci = curr_b.create_commit(commit_files, msg, partials=partials) 71 | pprint.ok('Commit on branch {0} succeeded'.format(repo.current_branch)) 72 | 73 | pprint.blank() 74 | pprint.commit(ci, line_additions=total_additions, line_deletions=total_deletions) 75 | 76 | if curr_b.fuse_in_progress: 77 | _op_continue(curr_b.fuse_continue, 'Fuse') 78 | elif curr_b.merge_in_progress: 79 | _op_continue(curr_b.merge_continue, 'Merge') 80 | 81 | return True 82 | 83 | 84 | def _author_info_is_ok(repo): 85 | def show_config_error(key): 86 | pprint.err('Missing {0} for commit author'.format(key)) 87 | pprint.err_exp('change the value of git\'s user.{0} setting'.format(key)) 88 | 89 | def config_is_ok(key): 90 | try: 91 | if not repo.config['user.{0}'.format(key)]: 92 | show_config_error(key) 93 | return False 94 | except KeyError: 95 | show_config_error(key) 96 | return False 97 | return True 98 | 99 | return config_is_ok('name') and config_is_ok('email') 100 | 101 | 102 | def _do_partial_selection(files, curr_b): 103 | partials = [] 104 | for fp in files: 105 | f_st = curr_b.status_file(fp) 106 | if not f_st.exists_at_head: 107 | pprint.warn('Can\'t select segments for new file {0}'.format(fp)) 108 | continue 109 | if not f_st.exists_in_wd: 110 | pprint.warn('Can\'t select segments for deleted file {0}'.format(fp)) 111 | continue 112 | 113 | core.git('add', '-p', fp) 114 | # TODO: check that at least one hunk was staged 115 | partials.append(fp) 116 | 117 | return partials 118 | 119 | 120 | def _auto_track(files, curr_b): 121 | """Tracks those untracked files in the list.""" 122 | for fp in files: 123 | f = curr_b.status_file(fp) 124 | if f.type == core.GL_STATUS_UNTRACKED: 125 | curr_b.track_file(f.fp) 126 | 127 | 128 | def _op_continue(op, fn): 129 | pprint.blank() 130 | try: 131 | op(op_cb=pprint.OP_CB) 132 | pprint.ok('{0} succeeded'.format(fn)) 133 | except core.ApplyFailedError as e: 134 | pprint.ok('{0} succeeded'.format(fn)) 135 | raise e 136 | -------------------------------------------------------------------------------- /gitless/cli/gl_diff.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Gitless - a version control system built on top of Git 3 | # Licensed under MIT 4 | 5 | """gl diff - Show changes in files.""" 6 | 7 | 8 | import os 9 | import tempfile 10 | 11 | from . import helpers, pprint 12 | 13 | 14 | def parser(subparsers, repo): 15 | """Adds the diff parser to the given subparsers object.""" 16 | desc = 'show changes to files' 17 | diff_parser = subparsers.add_parser( 18 | 'diff', help=desc, description=( 19 | desc.capitalize() + '. ' + 20 | 'By default all tracked modified files are diffed. To customize the ' 21 | ' set of files to diff use the only, exclude, and include flags'), aliases=['df']) 22 | helpers.oei_flags(diff_parser, repo) 23 | diff_parser.set_defaults(func=main) 24 | 25 | 26 | def main(args, repo): 27 | files = helpers.oei_fs(args, repo) 28 | if not files: 29 | pprint.warn('No files to diff') 30 | 31 | success = True 32 | curr_b = repo.current_branch 33 | with tempfile.NamedTemporaryFile(mode='w', delete=False) as tf: 34 | total_additions = 0 35 | total_deletions = 0 36 | patches = [] 37 | for fp in files: 38 | try: 39 | patch = curr_b.diff_file(fp) 40 | except KeyError: 41 | pprint.err('Can\'t diff non-existent file {0}'.format(fp)) 42 | success = False 43 | continue 44 | 45 | if patch.delta.is_binary: 46 | pprint.warn('Not showing diffs for binary file {0}'.format(fp)) 47 | continue 48 | 49 | additions = patch.line_stats[1] 50 | deletions = patch.line_stats[2] 51 | total_additions += additions 52 | total_deletions += deletions 53 | if (not additions) and (not deletions): 54 | pprint.warn('No diffs to output for {0}'.format(fp)) 55 | continue 56 | patches.append(patch) 57 | if patches: 58 | pprint.diff_totals(total_additions, total_deletions, stream=tf.write) 59 | for patch in patches: 60 | pprint.diff(patch, stream=tf.write) 61 | 62 | if os.path.getsize(tf.name) > 0: 63 | helpers.page(tf.name, repo) 64 | os.remove(tf.name) 65 | 66 | return success 67 | -------------------------------------------------------------------------------- /gitless/cli/gl_fuse.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Gitless - a version control system built on top of Git 3 | # Licensed under MIT 4 | 5 | """gl fuse - Fuse the divergent changes of a branch onto the current branch.""" 6 | 7 | 8 | from gitless import core 9 | 10 | from . import helpers, pprint 11 | 12 | 13 | def parser(subparsers, repo): 14 | desc = 'fuse the divergent changes of a branch onto the current branch' 15 | fuse_parser = subparsers.add_parser( 16 | 'fuse', help=desc, description=( 17 | desc.capitalize() + '. ' + 18 | 'By default all divergent changes from the given source branch are ' 19 | 'fused. To customize the set of commits to fuse use the only and ' 20 | 'exclude flags'), aliases=['fs']) 21 | fuse_parser.add_argument( 22 | 'src', nargs='?', 23 | help=( 24 | 'the source branch to read changes from. If none is given the upstream ' 25 | 'branch of the current branch is used as the source')) 26 | fuse_parser.add_argument( 27 | '-o', '--only', nargs='+', 28 | help=( 29 | 'fuse only the commits given (commits must belong to the set of ' 30 | 'divergent commits from the given src branch)'), 31 | metavar='commit_id', action=helpers.CommitIdProcessor, repo=repo) 32 | fuse_parser.add_argument( 33 | '-e', '--exclude', nargs='+', 34 | help=( 35 | 'exclude from the fuse the commits given (commits must belong to the ' 36 | 'set of divergent commits from the given src branch)'), 37 | metavar='commit_id', action=helpers.CommitIdProcessor, repo=repo) 38 | fuse_parser.add_argument( 39 | '-ip', '--insertion-point', nargs='?', 40 | help=( 41 | 'the divergent changes will be inserted after the commit given, dp for ' 42 | 'divergent point is the default'), metavar='commit_id') 43 | fuse_parser.add_argument( 44 | '-a', '--abort', help='abort the fuse in progress', action='store_true') 45 | fuse_parser.set_defaults(func=main) 46 | 47 | 48 | def main(args, repo): 49 | current_b = repo.current_branch 50 | if args.abort: 51 | current_b.abort_fuse(op_cb=pprint.OP_CB) 52 | pprint.ok('Fuse aborted successfully') 53 | return True 54 | 55 | src_branch = helpers.get_branch_or_use_upstream(args.src, 'src', repo) 56 | 57 | mb = repo.merge_base(current_b, src_branch) 58 | if mb == src_branch.target: # the current branch is ahead or both branches are equal 59 | pprint.err('No commits to fuse') 60 | return False 61 | 62 | if (not args.insertion_point or args.insertion_point == 'dp' or 63 | args.insertion_point == 'divergent-point'): 64 | insertion_point = mb 65 | else: 66 | insertion_point = repo.revparse_single(args.insertion_point).id 67 | 68 | def valid_input(inp): 69 | walker = src_branch.history() 70 | walker.hide(insertion_point) 71 | divergent_ids = frozenset(ci.id for ci in walker) 72 | 73 | errors_found = False 74 | for ci in inp - divergent_ids: 75 | pprint.err( 76 | 'Commit with id {0} is not among the divergent commits of branch ' 77 | '{1}'.format(ci, src_branch)) 78 | errors_found = True 79 | return not errors_found 80 | 81 | only = None 82 | exclude = None 83 | if args.only: 84 | only = frozenset(args.only) 85 | if not valid_input(only): 86 | return False 87 | elif args.exclude: 88 | exclude = frozenset(args.exclude) 89 | if not valid_input(exclude): 90 | return False 91 | 92 | 93 | try: 94 | current_b.fuse( 95 | src_branch, insertion_point, only=only, exclude=exclude, 96 | op_cb=pprint.OP_CB) 97 | pprint.ok('Fuse succeeded') 98 | except core.ApplyFailedError as e: 99 | pprint.ok('Fuse succeeded') 100 | raise e 101 | return True 102 | -------------------------------------------------------------------------------- /gitless/cli/gl_history.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Gitless - a version control system built on top of Git 3 | # Licensed under MIT 4 | 5 | """gl history - Show commit history.""" 6 | 7 | 8 | import os 9 | import tempfile 10 | 11 | from . import helpers, pprint 12 | 13 | 14 | def parser(subparsers, _): 15 | """Adds the history parser to the given subparsers object.""" 16 | desc = 'show commit history' 17 | history_parser = subparsers.add_parser( 18 | 'history', help=desc, description=desc.capitalize(), aliases=['hs']) 19 | history_parser.add_argument( 20 | '-v', '--verbose', help='be verbose, will output the diffs of the commit', 21 | action='store_true') 22 | history_parser.add_argument( 23 | '-l', '--limit', help='limit number of commits displayed', type=int) 24 | history_parser.add_argument( 25 | '-c', '--compact', help='output history in a compact format', 26 | action='store_true', default=False) 27 | history_parser.add_argument( 28 | '-b', '--branch', nargs='?', metavar='branch_name', dest='b', 29 | help='the branch to show history of (defaults to the current branch)') 30 | history_parser.set_defaults(func=main) 31 | 32 | 33 | def main(args, repo): 34 | b = helpers.get_branch(args.b, repo) if args.b else repo.current_branch 35 | with tempfile.NamedTemporaryFile(mode='w', delete=False) as tf: 36 | count = 0 37 | for ci in b.history(): 38 | if args.limit and count == args.limit: 39 | break 40 | pprint.commit(ci, compact=args.compact, stream=tf.write) 41 | if not args.compact: 42 | pprint.puts(stream=tf.write) 43 | if args.verbose and len(ci.parents) == 1: 44 | for patch in b.diff_commits(ci.parents[0], ci): 45 | pprint.diff(patch, stream=tf.write) 46 | 47 | count += 1 48 | helpers.page(tf.name, repo) 49 | os.remove(tf.name) 50 | return True 51 | -------------------------------------------------------------------------------- /gitless/cli/gl_init.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Gitless - a version control system built on top of Git 3 | # Licensed under MIT 4 | 5 | """gl init - Create an empty repo or make a clone.""" 6 | 7 | 8 | import os 9 | 10 | from gitless import core 11 | 12 | from . import pprint 13 | 14 | 15 | def parser(subparsers, _): 16 | """Adds the init parser to the given subparsers object.""" 17 | desc = ( 18 | 'create an empty git repository or clone remote') 19 | init_parser = subparsers.add_parser( 20 | 'init', help=desc, description=desc.capitalize(), aliases=['in']) 21 | init_parser.add_argument( 22 | 'repo', nargs='?', 23 | help=( 24 | 'an optional remote repo address from where to read to create the ' 25 | 'local repo')) 26 | init_parser.add_argument( 27 | '-o', '--only', nargs='+', 28 | help='use only branches given from remote repo', dest='only') 29 | init_parser.add_argument( 30 | '-e', '--exclude', nargs='+', 31 | help='use everything but these branches from remote repo', dest='exclude') 32 | 33 | init_parser.set_defaults(func=main) 34 | 35 | 36 | def main(args, repo): 37 | if repo: 38 | pprint.err('You are already in a Gitless repository') 39 | return False 40 | core.init_repository(url=args.repo, 41 | only=frozenset(args.only if args.only else []), 42 | exclude=frozenset(args.exclude if args.exclude else [])) 43 | pprint.ok('Local repo created in {0}'.format(os.getcwd())) 44 | if args.repo: 45 | pprint.ok('Initialized from remote {0}'.format(args.repo)) 46 | return True 47 | -------------------------------------------------------------------------------- /gitless/cli/gl_merge.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Gitless - a version control system built on top of Git 3 | # Licensed under MIT 4 | 5 | """gl merge - Merge the divergent changes of one branch onto another.""" 6 | 7 | 8 | from gitless import core 9 | 10 | from . import helpers, pprint 11 | 12 | 13 | def parser(subparsers, repo): 14 | desc = 'merge the divergent changes of one branch onto another' 15 | merge_parser = subparsers.add_parser( 16 | 'merge', help=desc, description=desc.capitalize(), aliases=['mg']) 17 | group = merge_parser.add_mutually_exclusive_group() 18 | group.add_argument( 19 | 'src', nargs='?', help='the source branch to read changes from') 20 | group.add_argument( 21 | '-a', '--abort', help='abort the merge in progress', action='store_true') 22 | merge_parser.set_defaults(func=main) 23 | 24 | 25 | def main(args, repo): 26 | current_b = repo.current_branch 27 | if args.abort: 28 | current_b.abort_merge() 29 | pprint.ok('Merge aborted successfully') 30 | return True 31 | 32 | src_branch = helpers.get_branch_or_use_upstream(args.src, 'src', repo) 33 | try: 34 | current_b.merge(src_branch, op_cb=pprint.OP_CB) 35 | pprint.ok('Merge succeeded') 36 | except core.ApplyFailedError as e: 37 | pprint.ok('Merge succeeded') 38 | raise e 39 | return True 40 | -------------------------------------------------------------------------------- /gitless/cli/gl_publish.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Gitless - a version control system built on top of Git 3 | # Licensed under MIT 4 | 5 | """gl publish - Publish commits upstream.""" 6 | 7 | 8 | from . import helpers, pprint 9 | 10 | 11 | def parser(subparsers, _): 12 | """Adds the publish parser to the given subparsers object.""" 13 | desc = 'publish commits upstream' 14 | publish_parser = subparsers.add_parser( 15 | 'publish', help=desc, description=desc.capitalize(), aliases=['pb']) 16 | publish_parser.add_argument( 17 | 'dst', nargs='?', help='the branch where to publish commits') 18 | publish_parser.set_defaults(func=main) 19 | 20 | 21 | def main(args, repo): 22 | current_b = repo.current_branch 23 | dst_b = helpers.get_branch_or_use_upstream(args.dst, 'dst', repo) 24 | current_b.publish(dst_b) 25 | pprint.ok( 26 | 'Publish of commits from branch {0} to branch {1} succeeded'.format( 27 | current_b, dst_b)) 28 | return True 29 | -------------------------------------------------------------------------------- /gitless/cli/gl_remote.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Gitless - a version control system built on top of Git 3 | # Licensed under MIT 4 | 5 | """gl remote - List, create, edit or delete remotes.""" 6 | 7 | 8 | from . import pprint 9 | 10 | 11 | def parser(subparsers, _): 12 | """Adds the remote parser to the given subparsers object.""" 13 | desc = 'list, create, edit or delete remotes' 14 | remote_parser = subparsers.add_parser( 15 | 'remote', help=desc, description=desc.capitalize(), aliases=['rt']) 16 | remote_parser.add_argument( 17 | '-c', '--create', nargs='?', help='create remote', dest='remote_name', 18 | metavar='remote') 19 | remote_parser.add_argument( 20 | 'remote_url', nargs='?', 21 | help='the url of the remote (only relevant if a new remote is created)') 22 | remote_parser.add_argument( 23 | '-d', '--delete', nargs='+', help='delete remote(es)', dest='delete_r', 24 | metavar='remote') 25 | remote_parser.add_argument( 26 | '-rn', '--rename', nargs='+', 27 | help='renames the specified remote: accepts two arguments ' 28 | '(current remote name and new remote name)', 29 | dest='rename_r') 30 | remote_parser.set_defaults(func=main) 31 | 32 | 33 | def main(args, repo): 34 | ret = True 35 | remotes = repo.remotes 36 | if args.remote_name: 37 | if not args.remote_url: 38 | raise ValueError('Missing url') 39 | ret = _do_create(args.remote_name, args.remote_url, remotes) 40 | elif args.delete_r: 41 | ret = _do_delete(args.delete_r, remotes) 42 | elif args.rename_r: 43 | ret = _do_rename(args.rename_r, remotes) 44 | else: 45 | ret = _do_list(remotes) 46 | 47 | return ret 48 | 49 | 50 | def _do_list(remotes): 51 | pprint.msg('List of remotes:') 52 | pprint.exp( 53 | 'do gl remote -c r r_url to add a new remote r mapping to r_url') 54 | pprint.exp('do gl remote -d r to delete remote r') 55 | pprint.blank() 56 | 57 | if not len(remotes): 58 | pprint.item('There are no remotes to list') 59 | else: 60 | for r in remotes: 61 | pprint.item(r.name, opt_text=' (maps to {0})'.format(r.url)) 62 | return True 63 | 64 | 65 | def _do_create(rn, ru, remotes): 66 | remotes.create(rn, ru) 67 | pprint.ok('Remote {0} mapping to {1} created successfully'.format(rn, ru)) 68 | pprint.exp('to list existing remotes do gl remote') 69 | pprint.exp('to remove {0} do gl remote -d {1}'.format(rn, rn)) 70 | return True 71 | 72 | 73 | def _do_delete(delete_r, remotes): 74 | errors_found = False 75 | 76 | for r in delete_r: 77 | try: 78 | remotes.delete(r) 79 | pprint.ok('Remote {0} removed successfully'.format(r)) 80 | except KeyError: 81 | pprint.err('Remote \'{0}\' doesn\'t exist'.format(r)) 82 | errors_found = True 83 | return not errors_found 84 | 85 | 86 | def _do_rename(rename_r, remotes): 87 | errors_found = False 88 | if len(rename_r) != 2: 89 | pprint.err( 90 | 'Expected 2 arguments in the folllowing format: ' 91 | 'gl remote -rn current_remote_name new_remote_name') 92 | errors_found = True 93 | else: 94 | try: 95 | remotes.rename(rename_r[0], rename_r[1]) 96 | pprint.ok('Renamed remote {0} to {1}'.format(rename_r[0], rename_r[1])) 97 | except KeyError: 98 | pprint.err('Remote \'{0}\' doesn\'t exist'.format(rename_r[0])) 99 | errors_found = True 100 | return not errors_found 101 | -------------------------------------------------------------------------------- /gitless/cli/gl_resolve.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Gitless - a version control system built on top of Git 3 | # Licensed under MIT 4 | 5 | """gl resolve - Mark a file with conflicts as resolved.""" 6 | 7 | 8 | from . import file_cmd 9 | 10 | 11 | parser = file_cmd.parser('mark files with conflicts as resolved', 'resolve', ['rs']) 12 | -------------------------------------------------------------------------------- /gitless/cli/gl_status.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Gitless - a version control system built on top of Git 3 | # Licensed under MIT 4 | 5 | """gl status - Show the status of files in the repo.""" 6 | 7 | 8 | import os 9 | 10 | from gitless import core 11 | 12 | from . import helpers, pprint 13 | 14 | 15 | def parser(subparsers, repo): 16 | """Adds the status parser to the given subparsers object.""" 17 | desc = 'show status of the repo' 18 | status_parser = subparsers.add_parser( 19 | 'status', help=desc, description=desc.capitalize(), aliases=['st']) 20 | status_parser.add_argument( 21 | 'paths', nargs='*', help='the specific path(s) to status', 22 | action=helpers.PathProcessor, repo=repo) 23 | status_parser.set_defaults(func=main) 24 | 25 | 26 | def main(args, repo): 27 | curr_b = repo.current_branch 28 | pprint.msg('On branch {0}, repo-directory {1}'.format( 29 | pprint.green(curr_b.branch_name), pprint.green('//' + repo.cwd))) 30 | 31 | if curr_b.merge_in_progress: 32 | pprint.blank() 33 | _print_conflict_exp('merge') 34 | elif curr_b.fuse_in_progress: 35 | pprint.blank() 36 | _print_conflict_exp('fuse') 37 | 38 | tracked_mod_list = [] 39 | untracked_list = [] 40 | paths = frozenset(args.paths) 41 | for f in curr_b.status(): 42 | if paths and (f.fp not in paths): 43 | continue 44 | if f.type == core.GL_STATUS_TRACKED and f.modified: 45 | tracked_mod_list.append(f) 46 | elif f.type == core.GL_STATUS_UNTRACKED: 47 | untracked_list.append(f) 48 | 49 | relative_paths = True # git seems to default to true 50 | try: 51 | relative_paths = repo.config.get_bool('status.relativePaths') 52 | except KeyError: 53 | pass 54 | 55 | pprint.blank() 56 | tracked_mod_list.sort(key=lambda f: f.fp) 57 | _print_tracked_mod_files(tracked_mod_list, relative_paths, repo) 58 | pprint.blank() 59 | pprint.blank() 60 | untracked_list.sort(key=lambda f: f.fp) 61 | _print_untracked_files(untracked_list, relative_paths, repo) 62 | return True 63 | 64 | 65 | def _print_tracked_mod_files(tracked_mod_list, relative_paths, repo): 66 | pprint.msg('Tracked files with modifications:') 67 | pprint.exp('these will be automatically considered for commit') 68 | pprint.exp( 69 | 'use gl untrack f if you don\'t want to track changes to file f') 70 | pprint.exp( 71 | 'if file f was committed before, use gl checkout f to discard ' 72 | 'local changes') 73 | pprint.blank() 74 | 75 | if not tracked_mod_list: 76 | pprint.item('There are no tracked files with modifications to list') 77 | return 78 | 79 | root = repo.root 80 | for f in tracked_mod_list: 81 | exp = '' 82 | color = pprint.yellow 83 | if not f.exists_at_head: 84 | exp = ' (new file)' 85 | color = pprint.green 86 | elif not f.exists_in_wd: 87 | exp = ' (deleted)' 88 | color = pprint.red 89 | elif f.in_conflict: 90 | exp = ' (with conflicts)' 91 | color = pprint.cyan 92 | 93 | fp = os.path.relpath(os.path.join(root, f.fp)) if relative_paths else f.fp 94 | if fp == '.': 95 | continue 96 | 97 | pprint.item(color(fp), opt_text=exp) 98 | 99 | 100 | def _print_untracked_files(untracked_list, relative_paths, repo): 101 | pprint.msg('Untracked files:') 102 | pprint.exp('these won\'t be considered for commit') 103 | pprint.exp('use gl track f if you want to track changes to file f') 104 | pprint.blank() 105 | 106 | if not untracked_list: 107 | pprint.item('There are no untracked files to list') 108 | return 109 | 110 | root = repo.root 111 | for f in untracked_list: 112 | exp = '' 113 | color = pprint.blue 114 | if f.in_conflict: 115 | exp = ' (with conflicts)' 116 | color = pprint.cyan 117 | elif f.exists_at_head: 118 | color = pprint.magenta 119 | if f.exists_in_wd: 120 | exp = ' (exists at head)' 121 | else: 122 | exp = ' (exists at head but not in working directory)' 123 | 124 | fp = os.path.relpath(os.path.join(root, f.fp)) if relative_paths else f.fp 125 | if fp == '.': 126 | continue 127 | 128 | pprint.item(color(fp), opt_text=exp) 129 | 130 | 131 | def _print_conflict_exp(op): 132 | pprint.msg( 133 | 'You are in the middle of a {0}; all conflicts must be resolved before ' 134 | 'commiting'.format(op)) 135 | pprint.exp( 136 | 'use gl {0} --abort to go back to the state before the {0}'.format(op)) 137 | pprint.exp('use gl resolve f to mark file f as resolved') 138 | pprint.exp('once you solved all conflicts do gl commit to continue') 139 | pprint.blank() 140 | -------------------------------------------------------------------------------- /gitless/cli/gl_switch.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Gitless - a version control system built on top of Git 3 | # Licensed under MIT 4 | 5 | """gl switch - Switch branches.""" 6 | 7 | 8 | from . import pprint 9 | 10 | 11 | def parser(subparsers, _): 12 | """Adds the switch parser to the given subparsers object.""" 13 | desc = 'switch branches' 14 | switch_parser = subparsers.add_parser( 15 | 'switch', help=desc, description=desc.capitalize(), aliases=['sw']) 16 | switch_parser.add_argument('branch', help='switch to branch') 17 | switch_parser.add_argument( 18 | '-mo', '--move-over', 19 | help='move uncomitted changes made in the current branch to the ' 20 | 'destination branch', 21 | action='store_true') 22 | switch_parser.add_argument('-mi', '--move-ignored', 23 | help='move ignored files to the destination branch, ' 24 | 'has no effect if --move-over is also set', 25 | action='store_true') 26 | switch_parser.set_defaults(func=main) 27 | 28 | 29 | def main(args, repo): 30 | b = repo.lookup_branch(args.branch) 31 | 32 | if not b: 33 | pprint.err('Branch {0} doesn\'t exist'.format(args.branch)) 34 | pprint.err_exp('to list existing branches do gl branch') 35 | pprint.err_exp('to create a new branch do gl branch -c {0}'.format(args.branch)) 36 | return False 37 | 38 | repo.switch_current_branch(b, move_over=args.move_over, move_ignored=args.move_ignored) 39 | pprint.ok('Switched to branch {0}'.format(args.branch)) 40 | return True 41 | -------------------------------------------------------------------------------- /gitless/cli/gl_tag.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Gitless - a version control system built on top of Git 3 | # Licensed under MIT 4 | 5 | """gl tag - List, create, edit or delete tags.""" 6 | 7 | 8 | from gitless import core 9 | 10 | from . import helpers, pprint 11 | 12 | 13 | def parser(subparsers, _): 14 | """Adds the tag parser to the given subparsers object.""" 15 | desc = 'list, create, or delete tags' 16 | tag_parser = subparsers.add_parser( 17 | 'tag', help=desc, description=desc.capitalize(), aliases=['tg']) 18 | 19 | list_group = tag_parser.add_argument_group('list tags') 20 | list_group.add_argument( 21 | '-r', '--remote', 22 | help='list remote tags in addition to local tags', 23 | action='store_true') 24 | 25 | create_group = tag_parser.add_argument_group('create tags') 26 | create_group.add_argument( 27 | '-c', '--create', nargs='+', help='create tag(s)', dest='create_t', 28 | metavar='tag') 29 | create_group.add_argument( 30 | '-ci', '--commit', 31 | help='the commit to tag (only relevant if a new ' 32 | 'tag is created; defaults to the HEAD commit)', dest='ci') 33 | 34 | delete_group = tag_parser.add_argument_group('delete tags') 35 | delete_group.add_argument( 36 | '-d', '--delete', nargs='+', help='delete tag(s)', dest='delete_t', 37 | metavar='tag') 38 | 39 | tag_parser.set_defaults(func=main) 40 | 41 | 42 | def main(args, repo): 43 | is_list = bool(args.remote) 44 | is_create = bool(args.create_t or args.ci) 45 | is_delete = bool(args.delete_t) 46 | 47 | if is_list + is_create + is_delete > 1: 48 | pprint.err('Invalid flag combination') 49 | pprint.err_exp('Can only do one of list, create, or delete tags at a time') 50 | return False 51 | 52 | ret = True 53 | if args.create_t: 54 | ret = _do_create(args.create_t, args.ci or 'HEAD', repo) 55 | elif args.delete_t: 56 | ret = _do_delete(args.delete_t, repo) 57 | else: 58 | _do_list(repo, args.remote) 59 | 60 | return ret 61 | 62 | 63 | def _do_list(repo, list_remote): 64 | pprint.msg('List of tags:') 65 | pprint.exp('do gl tag -c t to create tag t') 66 | pprint.exp('do gl tag -d t to delete tag t') 67 | pprint.blank() 68 | 69 | no_tags = True 70 | for t in (repo.lookup_tag(n) for n in sorted(repo.listall_tags())): 71 | pprint.item('{0} ➜ tags {1}'.format(t, pprint.commit_str(t.commit))) 72 | no_tags = False 73 | 74 | if list_remote: 75 | for r in sorted(repo.remotes, key=lambda r: r.name): 76 | for t in (r.lookup_tag(n) for n in sorted(r.listall_tags())): 77 | pprint.item('{0} ➜ tags {1}'.format(t, pprint.commit_str(t.commit))) 78 | no_tags = False 79 | 80 | if no_tags: 81 | pprint.item('There are no tags to list') 82 | 83 | 84 | def _do_create(create_t, dp, repo): 85 | errors_found = False 86 | 87 | try: 88 | target = repo.revparse_single(dp) 89 | except KeyError: 90 | raise ValueError('Invalid commit {0}'.format(dp)) 91 | 92 | for t_name in create_t: 93 | r = repo 94 | remote_str = '' 95 | if '/' in t_name: # might want to create a remote tag 96 | maybe_remote, maybe_remote_tag = t_name.split('/', 1) 97 | if maybe_remote in repo.remotes: 98 | r = repo.remotes[maybe_remote] 99 | t_name = maybe_remote_tag 100 | conf_msg = 'Tag {0} will be created in remote repository {1}'.format( 101 | t_name, maybe_remote) 102 | if not pprint.conf_dialog(conf_msg): 103 | pprint.msg( 104 | 'Aborted: creation of tag {0} in remote repository {1}'.format( 105 | t_name, maybe_remote)) 106 | continue 107 | remote_str = ' in remote repository {0}'.format(maybe_remote) 108 | try: 109 | r.create_tag(t_name, target) 110 | pprint.ok('Created new tag {0}{1}'.format(t_name, remote_str)) 111 | except ValueError as e: 112 | pprint.err(e) 113 | errors_found = True 114 | 115 | return not errors_found 116 | 117 | 118 | def _do_delete(delete_t, repo): 119 | errors_found = False 120 | 121 | for t_name in delete_t: 122 | try: 123 | t = helpers.get_tag(t_name, repo) 124 | 125 | tag_str = 'Tag {0} will be removed'.format(t.tag_name) 126 | remote_str = '' 127 | if isinstance(t, core.RemoteTag): 128 | remote_str = 'from remote repository {0}'.format(t.remote_name) 129 | if not pprint.conf_dialog('{0} {1}'.format(tag_str, remote_str)): 130 | pprint.msg('Aborted: removal of tag {0}'.format(t)) 131 | continue 132 | 133 | t.delete() 134 | pprint.ok('Tag {0} removed successfully'.format(t)) 135 | except ValueError as e: 136 | pprint.err(e) 137 | errors_found = True 138 | 139 | return not errors_found 140 | -------------------------------------------------------------------------------- /gitless/cli/gl_track.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Gitless - a version control system built on top of Git 3 | # Licensed under MIT 4 | 5 | """gl track - Start tracking changes to files.""" 6 | 7 | 8 | from . import file_cmd 9 | 10 | 11 | parser = file_cmd.parser('start tracking changes to files', 'track', ['tr']) 12 | -------------------------------------------------------------------------------- /gitless/cli/gl_untrack.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Gitless - a version control system built on top of Git 3 | # Licensed under MIT 4 | 5 | """gl untrack - Stop tracking changes to files.""" 6 | 7 | 8 | from . import file_cmd 9 | 10 | 11 | parser = file_cmd.parser('stop tracking changes to files', 'untrack', ['un']) 12 | -------------------------------------------------------------------------------- /gitless/cli/helpers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Gitless - a version control system built on top of Git 3 | # Licensed under MIT 4 | 5 | """Some helpers for commands.""" 6 | 7 | 8 | import argparse 9 | import os 10 | import subprocess 11 | import sys 12 | import shlex 13 | import shutil 14 | 15 | from gitless import core 16 | 17 | from . import pprint 18 | 19 | 20 | def get_branch(branch_name, repo): 21 | return _get_ref("branch", branch_name, repo) 22 | 23 | 24 | def get_tag(tag_name, repo): 25 | return _get_ref("tag", tag_name, repo) 26 | 27 | 28 | def _get_ref(ref_type, ref_name, repo): 29 | ref_type_cap = ref_type.capitalize() 30 | r = getattr(repo, "lookup_" + ref_type)(ref_name) 31 | if not r: 32 | if '/' not in ref_name: 33 | raise ValueError( 34 | '{0} "{1}" doesn\'t exist'.format(ref_type_cap, ref_name)) 35 | 36 | # It might be a remote ref 37 | remote, remote_ref = ref_name.split('/', 1) 38 | try: 39 | remote_repo = repo.remotes[remote] 40 | except KeyError: 41 | raise ValueError( 42 | 'Remote "{0}" doesn\'t exist, and there is no local ' 43 | '{1} "{2}"'.format(remote, ref_type_cap, ref_name)) 44 | 45 | r = getattr(remote_repo, "lookup_" + ref_type)(remote_ref) 46 | if not r: 47 | raise ValueError('{0} "{1}" doesn\'t exist in remote "{2}"'.format( 48 | ref_type_cap, remote_ref, remote)) 49 | return r 50 | 51 | 52 | def get_branch_or_use_upstream(branch_name, arg, repo): 53 | if not branch_name: # use upstream branch 54 | current_b = repo.current_branch 55 | upstream_b = current_b.upstream 56 | if not upstream_b: 57 | raise ValueError( 58 | 'No {0} branch specified and the current branch has no upstream ' 59 | 'branch set'.format(arg)) 60 | 61 | ret = current_b.upstream 62 | else: 63 | ret = get_branch(branch_name, repo) 64 | return ret 65 | 66 | 67 | def page(fp, repo): 68 | if not sys.stdout.isatty(): # we are being piped or redirected 69 | if sys.platform != 'win32': 70 | # Prevent Python from throwing exceptions on SIGPIPE 71 | from signal import signal, SIGPIPE, SIG_DFL 72 | signal(SIGPIPE, SIG_DFL) 73 | # memory-friendly way to output contents of file to stdout 74 | with open(fp, 'r') as f: 75 | shutil.copyfileobj(f, sys.stdout) 76 | return 77 | 78 | # On Windows, we need to call 'more' through cmd.exe (with 'cmd'). The /C is 79 | # so that the command window gets closed after 'more' finishes 80 | default_pager = 'less' if sys.platform != 'win32' else 'cmd /C more' 81 | try: 82 | pager = repo.config['core.pager'] 83 | except KeyError: 84 | pager = '' # empty string will evaluate to False below 85 | pager = pager or os.environ.get('PAGER', None) or default_pager 86 | cmd = shlex.split(pager) # split into constituents 87 | if os.path.basename(cmd[0]) == 'less': 88 | cmd.extend(['-r', '-f']) # append arguments 89 | 90 | cmd.append(fp) # add file name to page command 91 | try: 92 | ret = subprocess.call(cmd, stdin=sys.stdin, stdout=sys.stdout) 93 | if ret != 0: 94 | pprint.err('Call to pager {0} failed'.format(pager)) 95 | except OSError: 96 | pprint.err('Couldn\'t launch pager {0}'.format(pager)) 97 | pprint.err_exp('change the value of git\'s core.pager setting') 98 | 99 | 100 | class PathProcessor(argparse.Action): 101 | 102 | def __init__( 103 | self, option_strings, dest, repo=None, skip_dir_test=None, 104 | skip_dir_cb=None, recursive=True, **kwargs): 105 | self.repo = repo 106 | self.skip_dir_test = skip_dir_test 107 | self.skip_dir_cb = skip_dir_cb 108 | self.recursive = recursive 109 | super(PathProcessor, self).__init__(option_strings, dest, **kwargs) 110 | 111 | def __call__(self, parser, namespace, paths, option_string=None): 112 | root = self.repo.root if self.repo else '' 113 | repo_path = self.repo.path if self.repo else '' 114 | # We add the sep so that we can use `startswith` to determine if a file 115 | # is inside the .git folder 116 | # `normpath` is important because libgit2 returns the repo_path with forward 117 | # slashes on Windows 118 | normalized_repo_path = os.path.normpath(repo_path) + os.path.sep 119 | def process_paths(): 120 | for path in paths: 121 | path = os.path.abspath(path) 122 | # Treat symlinks as normal files, even if the link points to a 123 | # directory. The directory could be outside of the repo, then things 124 | # get weird... This is standard git behavior. 125 | if self.recursive and os.path.isdir(path) and not os.path.islink(path): 126 | for curr_dir, dirs, fps in os.walk(path, topdown=True): 127 | if curr_dir.startswith(normalized_repo_path): 128 | dirs[:] = [] 129 | continue 130 | curr_dir_rel = os.path.relpath(curr_dir, root) 131 | if (curr_dir_rel != "." and self.skip_dir_test and 132 | self.skip_dir_test(curr_dir_rel)): 133 | if self.skip_dir_cb: 134 | self.skip_dir_cb(curr_dir_rel) 135 | dirs[:] = [] 136 | continue 137 | for fp in fps: 138 | yield fp if curr_dir_rel == '.' else os.path.join(curr_dir_rel, fp) 139 | else: 140 | if not path.startswith(normalized_repo_path): 141 | yield os.path.relpath(path, root) 142 | 143 | setattr(namespace, self.dest, process_paths()) 144 | 145 | 146 | class CommitIdProcessor(argparse.Action): 147 | 148 | def __init__(self, option_strings, dest, repo=None, **kwargs): 149 | self.repo = repo 150 | super(CommitIdProcessor, self).__init__(option_strings, dest, **kwargs) 151 | 152 | def __call__(self, parser, namespace, revs, option_string=None): 153 | cids = (self.repo.revparse_single(rev).id for rev in revs) 154 | setattr(namespace, self.dest, cids) 155 | 156 | 157 | def oei_flags(subparsers, repo): 158 | subparsers.add_argument( 159 | 'only', nargs='*', 160 | help='use only files given (tracked modified or untracked)', 161 | action=PathProcessor, repo=repo, metavar='file') 162 | subparsers.add_argument( 163 | '-e', '--exclude', nargs='+', 164 | help='exclude files given (files must be tracked modified)', 165 | action=PathProcessor, repo=repo, metavar='file') 166 | subparsers.add_argument( 167 | '-i', '--include', nargs='+', 168 | help='include files given (files must be untracked)', 169 | action=PathProcessor, repo=repo, metavar='file') 170 | 171 | 172 | def oei_fs(args, repo): 173 | """Compute the final fileset per oei flags.""" 174 | only = frozenset(args.only if args.only else []) 175 | exclude = frozenset(args.exclude if args.exclude else []) 176 | include = frozenset(args.include if args.include else []) 177 | 178 | curr_b = repo.current_branch 179 | if not _oei_validate(only, exclude, include, curr_b): 180 | raise ValueError('Invalid input') 181 | 182 | if only: 183 | ret = only 184 | else: 185 | # Tracked modified files 186 | ret = frozenset( 187 | f.fp for f in curr_b.status() 188 | if f.type == core.GL_STATUS_TRACKED and f.modified) # using generator expression 189 | # We get the files from status with forward slashes. On Windows, these 190 | # won't match the paths provided by the user, which are normalized by 191 | # PathProcessor 192 | if sys.platform == 'win32': 193 | ret = frozenset(p.replace('/', '\\') for p in ret) 194 | ret -= exclude 195 | ret |= include 196 | 197 | ret = sorted(list(ret)) 198 | return ret 199 | 200 | 201 | def _oei_validate(only, exclude, include, curr_b): 202 | """Validates user input per oei flags. 203 | 204 | This function will print to stderr in case user-provided values are invalid 205 | (and return False). 206 | 207 | Returns: 208 | True if the input is valid, False if otherwise. 209 | """ 210 | if only and (exclude or include): 211 | pprint.err( 212 | 'You provided a list of filenames to be committed but also ' 213 | 'provided a list of files to be excluded (-e) or included (-i)') 214 | return False 215 | 216 | err = [] 217 | 218 | def validate(fps, check_fn, msg): 219 | ''' fps: files 220 | check_fn: lambda(file) -> boolean 221 | msg: string-format of pre-defined constant string. 222 | ''' 223 | ret = True 224 | if not fps: 225 | return ret 226 | for fp in fps: 227 | try: 228 | f = curr_b.status_file(fp) 229 | except KeyError: 230 | err.append('File {0} doesn\'t exist'.format(fp)) 231 | ret = False # set error flag, but keep assessing other files 232 | else: # executed after "try", exception will be ignored here 233 | if not check_fn(f): 234 | err.append(msg(fp)) # dynamic string formatting 235 | ret = False 236 | return ret 237 | 238 | only_valid = validate( 239 | only, lambda f: f.type == core.GL_STATUS_UNTRACKED or ( 240 | f.type == core.GL_STATUS_TRACKED and f.modified), 241 | 'File {0} is not a tracked modified or untracked file'.format) 242 | exclude_valid = validate( 243 | exclude, lambda f: f.type == core.GL_STATUS_TRACKED and f.modified, 244 | 'File {0} is not a tracked modified file'.format) 245 | include_valid = validate( 246 | include, lambda f: f.type == core.GL_STATUS_UNTRACKED, 247 | 'File {0} is not an untracked file'.format) 248 | 249 | if only_valid and exclude_valid and include_valid: 250 | return True 251 | 252 | for e in err: 253 | pprint.err(e) 254 | return False 255 | 256 | """Aliases for argparse positional arguments.""" 257 | 258 | class AliasedSubParsersAction(argparse._SubParsersAction): 259 | 260 | class _AliasedPseudoAction(argparse.Action): 261 | def __init__(self, name, aliases, help): 262 | dest = name 263 | if aliases: 264 | dest += ' (%s)' % ','.join(aliases) 265 | sup = super(AliasedSubParsersAction._AliasedPseudoAction, self) 266 | sup.__init__(option_strings=[], dest=dest, help=help) 267 | 268 | def add_parser(self, name, **kwargs): 269 | if 'aliases' in kwargs: 270 | aliases = kwargs['aliases'] 271 | del kwargs['aliases'] 272 | else: 273 | aliases = [] 274 | 275 | parser = super(AliasedSubParsersAction, self).add_parser(name, **kwargs) 276 | 277 | # Make the aliases work. 278 | for alias in aliases: 279 | self._name_parser_map[alias] = parser 280 | # Make the help text reflect them, first removing old help entry. 281 | if 'help' in kwargs: 282 | help = kwargs.pop('help') 283 | self._choices_actions.pop() 284 | pseudo_action = self._AliasedPseudoAction(name, aliases, help) 285 | self._choices_actions.append(pseudo_action) 286 | 287 | return parser 288 | -------------------------------------------------------------------------------- /gitless/cli/pprint.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Gitless - a version control system built on top of Git 3 | # Licensed under MIT 4 | 5 | """Module for pretty printing Gitless output.""" 6 | 7 | 8 | try: 9 | from StringIO import StringIO 10 | except ImportError: 11 | from io import StringIO 12 | 13 | from datetime import datetime, tzinfo, timedelta 14 | from locale import getpreferredencoding 15 | import re 16 | import sys 17 | 18 | DISABLE_COLOR = False 19 | 20 | from gitless import core 21 | 22 | 23 | SEP = ( 24 | '##########################################################################' 25 | '######') 26 | 27 | 28 | ENCODING = getpreferredencoding() or 'utf-8' 29 | 30 | 31 | def puts(s='', newline=True, stream=sys.stdout.write): 32 | if newline: 33 | s = s + '\n' 34 | stream(s) 35 | 36 | 37 | # Colored strings 38 | RED = '\033[31m' 39 | RED_BOLD = '\033[1;31m' 40 | GREEN = '\033[32m' 41 | GREEN_BOLD = '\033[1;32m' 42 | YELLOW = '\033[33m' 43 | BLUE = '\033[34m' 44 | MAGENTA = '\033[35m' 45 | CYAN = '\033[36m' 46 | CLEAR = '\033[0m' 47 | 48 | def _color(color_code, text): 49 | return '{0}{1}{2}'.format(color_code, text, CLEAR) if should_color() else text 50 | 51 | def should_color(): 52 | # We only output colored lines if the coloring is enabled and we are not being 53 | # piped or redirected 54 | return not DISABLE_COLOR and sys.stdout.isatty() 55 | 56 | def red(text): 57 | return _color(RED, text) 58 | 59 | def green(text): 60 | return _color(GREEN, text) 61 | 62 | def yellow(text): 63 | return _color(YELLOW, text) 64 | 65 | def blue(text): 66 | return _color(BLUE, text) 67 | 68 | def magenta(text): 69 | return _color(MAGENTA, text) 70 | 71 | def cyan(text): 72 | return _color(CYAN, text) 73 | 74 | 75 | # Stdout 76 | 77 | 78 | def ok(text): 79 | puts(green('✔ {0}'.format(text))) 80 | 81 | 82 | def warn(text): 83 | puts(yellow('! {0}'.format(text))) 84 | 85 | 86 | def msg(text, stream=sys.stdout.write): 87 | puts(text, stream=stream) 88 | 89 | 90 | def exp(text, stream=sys.stdout.write): 91 | puts(' ➜ {0}'.format(text), stream=stream) 92 | 93 | 94 | def item(i, opt_text='', stream=sys.stdout.write): 95 | puts(' {0}{1}'.format(i, opt_text), stream=stream) 96 | 97 | 98 | def blank(stream=sys.stdout.write): 99 | puts('', stream=stream) 100 | 101 | 102 | def sep(stream=sys.stdout.write): 103 | puts(SEP, stream=stream) 104 | 105 | 106 | # Err 107 | 108 | def err(text): 109 | puts(red('✘ {0}'.format(text)), stream=sys.stderr.write) 110 | 111 | 112 | def err_msg(text): 113 | msg(text, stream=sys.stderr.write) 114 | 115 | 116 | def err_exp(text): 117 | exp(text, stream=sys.stderr.write) 118 | 119 | 120 | def err_blank(): 121 | blank(stream=sys.stderr.write) 122 | 123 | 124 | def err_item(i, opt_text=''): 125 | item(i, opt_text, stream=sys.stderr.write) 126 | 127 | 128 | # Misc 129 | 130 | def conf_dialog(text): 131 | """Gets confirmation from the user. 132 | 133 | Prints a confirmation message to stdout with the given text and waits for 134 | user confirmation. 135 | 136 | Args: 137 | text: the text to include in the confirmation. 138 | 139 | Returns: 140 | True if the user confirmed she wanted to continue or False if otherwise. 141 | """ 142 | msg('{0}. Do you wish to continue? (y/N)'.format(text)) 143 | user_input = get_user_input() 144 | return user_input and user_input[0].lower() == 'y' 145 | 146 | 147 | def get_user_input(text='> '): 148 | """Python 2/3 compatible way of getting user input.""" 149 | global input 150 | try: 151 | # Disable pylint's redefined-builtin warning and undefined-variable 152 | # (raw_input is undefined in python 3) error. 153 | # pylint: disable=W0622 154 | # pylint: disable=E0602 155 | input = raw_input 156 | except NameError: 157 | pass 158 | return input(text) 159 | 160 | 161 | def commit_str(ci): 162 | ci_str = StringIO() 163 | commit(ci, compact=True, stream=ci_str.write) 164 | return ci_str.getvalue().strip() 165 | 166 | 167 | def commit(ci, compact=False, stream=sys.stdout.write, line_additions=0, line_deletions=0): 168 | merge_commit = len(ci.parent_ids) > 1 169 | color = magenta if merge_commit else yellow 170 | if compact: 171 | title = ci.message.splitlines()[0] 172 | puts('{0} {1}'.format(color(str(ci.id)[:7]), title), stream=stream) 173 | return 174 | puts(color('Commit Id: {0}'.format(ci.id)), stream=stream) 175 | if merge_commit: 176 | merges_str = ' '.join(str(oid)[:7] for oid in ci.parent_ids) 177 | puts(color('Merges: {0}'.format(merges_str)), stream=stream) 178 | puts( 179 | color('Author: {0} <{1}>'.format(ci.author.name, ci.author.email)), 180 | stream=stream) 181 | ci_author_dt = datetime.fromtimestamp( 182 | ci.author.time, FixedOffset(ci.author.offset)) 183 | puts(color('Date: {0:%c %z}'.format(ci_author_dt)), stream=stream) 184 | put_s = lambda num: '' if num == 1 else 's' 185 | puts(color('Stats: {0} line{1} added, {2} line{3} removed' 186 | .format(line_additions, put_s(line_additions), 187 | line_deletions, put_s(line_deletions))), stream=stream) 188 | puts(stream=stream) 189 | puts(' {0}'.format(ci.message), stream=stream) 190 | 191 | # Op Callbacks 192 | 193 | def apply_ok(ci): 194 | ok('Insertion of {0} succeeded'.format(ci.id)) 195 | blank() 196 | commit(ci) 197 | blank() 198 | 199 | def apply_err(ci): 200 | err('Insertion of {0} failed'.format(ci.id)) 201 | blank() 202 | commit(ci) 203 | blank() 204 | 205 | def save(): 206 | warn('Temporarily saving uncommitted changes') 207 | 208 | def restore_ok(): 209 | ok('Uncommitted changes applied successfully to the new head of the branch') 210 | 211 | OP_CB = core.OpCb(apply_ok, apply_err, save, restore_ok) 212 | 213 | 214 | class FixedOffset(tzinfo): 215 | 216 | def __init__(self, offset): 217 | super(FixedOffset, self).__init__() 218 | self.__offset = timedelta(minutes=offset) 219 | 220 | def utcoffset(self, _): 221 | return self.__offset 222 | 223 | def dst(self, _): 224 | return timedelta(0) 225 | 226 | 227 | def diff(patch, stream=sys.stdout.write): 228 | # Diff header 229 | 230 | old_fp = patch.delta.old_file.path 231 | new_fp = patch.delta.new_file.path 232 | puts('Diff of file "{0}"'.format(old_fp), stream=stream) 233 | if old_fp != new_fp: 234 | puts(cyan(' (renamed to {0})'.format(new_fp)), stream=stream) 235 | puts(stream=stream) 236 | 237 | if patch.delta.is_binary: 238 | puts('Not showing diffs for binary file', stream=stream) 239 | return 240 | 241 | additions = patch.line_stats[1] 242 | deletions = patch.line_stats[2] 243 | if (not additions) and (not deletions): 244 | puts('No diffs to output for file', stream=stream) 245 | return 246 | 247 | put_s = lambda num: '' if num == 1 else 's' 248 | puts('{0} line{1} added'.format(additions, put_s(additions)), stream=stream) 249 | puts('{0} line{1} removed'.format(deletions, put_s(deletions)), stream=stream) 250 | puts(stream=stream) 251 | 252 | # Diff body 253 | 254 | for hunk in patch.hunks: 255 | puts(stream=stream) 256 | _hunk(hunk, stream=stream) 257 | 258 | puts(stream=stream) 259 | puts(stream=stream) 260 | 261 | def diff_totals(total_additions, total_deletions, stream=sys.stdout.write): 262 | 263 | put_s = lambda num: '' if num == 1 else 's' 264 | puts('Diff summary', stream=stream) 265 | puts('Total of {0} line{1} added' 266 | .format(total_additions, put_s(total_additions)), stream=stream) 267 | puts('Total of {0} line{1} removed' 268 | .format(total_deletions, put_s(total_deletions)), stream=stream) 269 | puts(stream=stream) 270 | 271 | 272 | def _hunk(hunk, stream=sys.stdout.write): 273 | puts(cyan('@@ -{0},{1} +{2},{3} @@'.format( 274 | hunk.old_start, hunk.old_lines, hunk.new_start, hunk.new_lines)), 275 | stream=stream) 276 | padding = _padding(hunk) 277 | 278 | del_line, add_line, maybe_bold, saw_add = None, None, False, False 279 | for diff_line in hunk.lines: 280 | st = diff_line.origin 281 | 282 | if st == '-' and not maybe_bold: 283 | maybe_bold = True 284 | del_line = diff_line 285 | elif st == '+' and maybe_bold and not saw_add: 286 | saw_add = True 287 | add_line = diff_line 288 | elif st == ' ' and maybe_bold and saw_add: 289 | bold1, bold2 = _highlight(del_line.content, add_line.content) 290 | 291 | puts(_format_line(del_line, padding, bold_delim=bold1), stream=stream) 292 | puts(_format_line(add_line, padding, bold_delim=bold2), stream=stream) 293 | 294 | del_line, add_line, maybe_bold, saw_add = None, None, False, False 295 | 296 | puts(_format_line(diff_line, padding), stream=stream) 297 | else: 298 | if del_line: 299 | puts(_format_line(del_line, padding), stream=stream) 300 | if add_line: 301 | puts(_format_line(add_line, padding), stream=stream) 302 | 303 | del_line, add_line, maybe_bold, saw_add = None, None, False, False 304 | 305 | puts(_format_line(diff_line, padding), stream=stream) 306 | 307 | 308 | if maybe_bold and saw_add: 309 | bold1, bold2 = _highlight(del_line.content, add_line.content) 310 | 311 | puts(_format_line(del_line, padding, bold_delim=bold1), stream=stream) 312 | puts(_format_line(add_line, padding, bold_delim=bold2), stream=stream) 313 | else: 314 | if del_line: 315 | puts(_format_line(del_line, padding), stream=stream) 316 | if add_line: 317 | puts(_format_line(add_line, padding), stream=stream) 318 | 319 | 320 | def _padding(hunk): 321 | MIN_LINE_PADDING = 8 322 | 323 | max_line_number = max([ 324 | hunk.old_start + hunk.old_lines, hunk.new_start + hunk.new_lines]) 325 | max_line_digits = len(str(max_line_number)) 326 | return max(MIN_LINE_PADDING, max_line_digits + 1) 327 | 328 | 329 | def _format_line(diff_line, padding, bold_delim=None): 330 | """Format a standard diff line. 331 | 332 | Returns: 333 | a padded and colored version of the diff line with line numbers 334 | """ 335 | if should_color(): 336 | green = GREEN 337 | green_bold = GREEN_BOLD 338 | red = RED 339 | red_bold = RED_BOLD 340 | clear = CLEAR 341 | else: 342 | green = '' 343 | green_bold = '' 344 | red = '' 345 | red_bold = '' 346 | clear = '' 347 | 348 | formatted = '' 349 | st = diff_line.origin 350 | line = st + diff_line.content.rstrip('\n') 351 | old_lineno = diff_line.old_lineno 352 | new_lineno = diff_line.new_lineno 353 | 354 | if st == ' ': 355 | formatted = ( 356 | str(old_lineno).ljust(padding) + str(new_lineno).ljust(padding) + line) 357 | elif st == '+': 358 | formatted = ' ' * padding + green + str(new_lineno).ljust(padding) 359 | if not bold_delim: 360 | formatted += line 361 | else: 362 | bold_start, bold_end = bold_delim 363 | formatted += ( 364 | line[:bold_start] + green_bold + line[bold_start:bold_end] + clear + 365 | green + line[bold_end:]) 366 | elif st == '-': 367 | formatted = red + str(old_lineno).ljust(padding) + ' ' * padding 368 | if not bold_delim: 369 | formatted += line 370 | else: 371 | bold_start, bold_end = bold_delim 372 | formatted += ( 373 | line[:bold_start] + red_bold + line[bold_start:bold_end] + clear + 374 | red + line[bold_end:]) 375 | 376 | return formatted + clear 377 | 378 | 379 | def _highlight(line1, line2): 380 | """Returns the sections that should be bolded in the given lines. 381 | 382 | Returns: 383 | two tuples. Each tuple indicates the start and end of the section 384 | of the line that should be bolded for line1 and line2 respectively. 385 | """ 386 | start1 = start2 = 0 387 | match = re.search(r'\S', line1) # ignore leading whitespace 388 | if match: 389 | start1 = match.start() 390 | match = re.search(r'\S', line2) 391 | if match: 392 | start2 = match.start() 393 | length = min(len(line1), len(line2)) - 1 394 | bold_start1 = start1 395 | bold_start2 = start2 396 | while (bold_start1 <= length and bold_start2 <= length and 397 | line1[bold_start1] == line2[bold_start2]): 398 | bold_start1 += 1 399 | bold_start2 += 1 400 | match = re.search(r'\s*$', line1) # ignore trailing whitespace 401 | bold_end1 = match.start() - 1 402 | match = re.search(r'\s*$', line2) 403 | bold_end2 = match.start() - 1 404 | while (bold_end1 >= bold_start1 and bold_end2 >= bold_start2 and 405 | line1[bold_end1] == line2[bold_end2]): 406 | bold_end1 -= 1 407 | bold_end2 -= 1 408 | if bold_start1 - start1 > 0 or len(line1) - 1 - bold_end1 > 0: 409 | return (bold_start1 + 1, bold_end1 + 2), (bold_start2 + 1, bold_end2 + 2) 410 | return None, None 411 | -------------------------------------------------------------------------------- /gitless/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitless-vcs/gitless/3ac28e39e170acdcd1590e0a25a06790ae0e6922/gitless/tests/__init__.py -------------------------------------------------------------------------------- /gitless/tests/test_core.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Gitless - a version control system built on top of Git 3 | # Licensed under MIT 4 | 5 | """Core unit tests.""" 6 | 7 | 8 | from functools import wraps 9 | import os 10 | import shutil 11 | import tempfile 12 | import unittest 13 | import argparse 14 | import sys 15 | from subprocess import CalledProcessError 16 | 17 | from gitless import core 18 | from gitless.cli import gl, helpers, gl_track 19 | import gitless.tests.utils as utils_lib 20 | 21 | 22 | TRACKED_FP = 'f1' 23 | TRACKED_FP_CONTENTS_1 = 'f1-1\n' 24 | TRACKED_FP_CONTENTS_2 = 'f1-2\n' 25 | TRACKED_FP_WITH_SPACE = 'f1 space' 26 | UNTRACKED_FP = 'f2' 27 | UNTRACKED_FP_CONTENTS = 'f2' 28 | UNTRACKED_FP_WITH_SPACE = 'f2 space' 29 | IGNORED_FP = 'f3' 30 | IGNORED_FP_WITH_SPACE = 'f3 space' 31 | NONEXISTENT_FP = 'nonexistent' 32 | NONEXISTENT_FP_WITH_SPACE = 'nonexistent space' 33 | GITIGNORE_FP = '.gitignore' 34 | DIR = 'dir' 35 | REPO_DIR = '.git' 36 | REPO_FP = os.path.join(REPO_DIR, 'HEAD') 37 | GITTEST_DIR = '.gittest' 38 | GITTEST_FP = os.path.join(GITTEST_DIR, 'fp') 39 | SYMLINK_TARGET = 'symtarget' 40 | SYMLINK_TARGET_FP_CONTENTS = 'symf1\n' 41 | SYMLINK_TARGET_FP = os.path.join(SYMLINK_TARGET, 'symf1') 42 | SYMLINK_DIR = 'symdir' 43 | SYMLINK_FP = os.path.join(SYMLINK_DIR, 'sym') 44 | SYMLINK_GIT = 'gitsym' 45 | UNTRACKED_DIR_FP = os.path.join(DIR, 'f1') 46 | UNTRACKED_DIR_FP_WITH_SPACE = os.path.join(DIR, 'f1 space') 47 | TRACKED_DIR_FP = os.path.join(DIR, 'f2') 48 | TRACKED_DIR_FP_WITH_SPACE = os.path.join(DIR, 'f2 space') 49 | DIR_DIR = os.path.join(DIR, DIR) 50 | UNTRACKED_DIR_DIR_FP = os.path.join(DIR_DIR, 'f1') 51 | UNTRACKED_DIR_DIR_FP_WITH_SPACE = os.path.join(DIR_DIR, 'f1 space') 52 | TRACKED_DIR_DIR_FP = os.path.join(DIR_DIR, 'f2') 53 | TRACKED_DIR_DIR_FP_WITH_SPACE = os.path.join(DIR_DIR, 'f2 space') 54 | ALL_FPS_IN_WD = [ 55 | TRACKED_FP, TRACKED_FP_WITH_SPACE, UNTRACKED_FP, UNTRACKED_FP_WITH_SPACE, 56 | IGNORED_FP, IGNORED_FP_WITH_SPACE, UNTRACKED_DIR_FP, 57 | UNTRACKED_DIR_FP_WITH_SPACE, TRACKED_DIR_FP, TRACKED_DIR_FP_WITH_SPACE, 58 | UNTRACKED_DIR_DIR_FP, UNTRACKED_DIR_DIR_FP_WITH_SPACE, TRACKED_DIR_DIR_FP, 59 | TRACKED_DIR_DIR_FP_WITH_SPACE, GITIGNORE_FP, GITTEST_FP, SYMLINK_TARGET_FP, 60 | SYMLINK_FP, SYMLINK_GIT] 61 | # the symbolic link is both a file and directory. The OS typically treats it 62 | # like a directory but we want to treat it as a file for tracking purposes. 63 | ALL_DIR_FPS_IN_WD = [ 64 | TRACKED_DIR_FP, TRACKED_DIR_FP_WITH_SPACE, UNTRACKED_DIR_FP, 65 | UNTRACKED_DIR_FP_WITH_SPACE, TRACKED_DIR_DIR_FP, 66 | TRACKED_DIR_DIR_FP_WITH_SPACE, UNTRACKED_DIR_DIR_FP, 67 | UNTRACKED_DIR_DIR_FP_WITH_SPACE, GITTEST_DIR, SYMLINK_TARGET, SYMLINK_DIR, 68 | SYMLINK_FP, SYMLINK_GIT] 69 | BRANCH = 'b1' 70 | REMOTE_BRANCH = 'rb' 71 | FP_IN_CONFLICT = 'f_conflict' 72 | DIR_FP_IN_CONFLICT = os.path.join(DIR, FP_IN_CONFLICT) 73 | 74 | 75 | # Helpers 76 | 77 | class TestCore(utils_lib.TestBase): 78 | """Base class for core tests.""" 79 | 80 | def setUp(self): 81 | super(TestCore, self).setUp('gl-core-test') 82 | utils_lib.git('init') 83 | utils_lib.set_test_config() 84 | self.repo = core.Repository() 85 | 86 | 87 | def assert_contents_unchanged(*fps): 88 | """Decorator that fails the test if the contents of the file fp changed.""" 89 | def prop(*args, **kwargs): 90 | return utils_lib.read_file 91 | return __assert_decorator('Contents', prop, *fps) 92 | 93 | 94 | def assert_status_unchanged(*fps): 95 | """Decorator that fails the test if the status of fp changed.""" 96 | def prop(self, *args, **kwargs): 97 | return self.curr_b.status_file 98 | return __assert_decorator('Status', prop, *fps) 99 | 100 | 101 | def assert_no_side_effects(*fps): 102 | """Decorator that fails the test if the contents or status of fp changed.""" 103 | def decorator(f): 104 | @assert_contents_unchanged(*fps) 105 | @assert_status_unchanged(*fps) 106 | @wraps(f) 107 | def wrapper(*args, **kwargs): 108 | f(*args, **kwargs) 109 | return wrapper 110 | return decorator 111 | 112 | 113 | def __assert_decorator(msg, prop, *fps): 114 | def decorator(f): 115 | @wraps(f) 116 | def wrapper(*args, **kwargs): 117 | self = args[0] 118 | # We save up the cwd to chdir to it after the test has run so that the 119 | # the given fps still "work" even if the test changed the cwd. 120 | cwd_before = os.getcwd() 121 | before_list = [prop(*args, **kwargs)(fp) for fp in fps] 122 | f(*args, **kwargs) 123 | os.chdir(cwd_before) 124 | after_list = [prop(*args, **kwargs)(fp) for fp in fps] 125 | for fp, before, after in zip(fps, before_list, after_list): 126 | self.assertEqual( 127 | before, after, 128 | '{0} of file "{1}" changed: from "{2}" to "{3}"'.format( 129 | msg, fp, before, after)) 130 | return wrapper 131 | return decorator 132 | 133 | 134 | # Unit tests for file related operations 135 | 136 | class TestFile(TestCore): 137 | """Base class for file tests.""" 138 | 139 | def setUp(self): 140 | super(TestFile, self).setUp() 141 | 142 | # Build up an interesting mock repo 143 | utils_lib.write_file(TRACKED_FP, contents=TRACKED_FP_CONTENTS_1) 144 | utils_lib.write_file(TRACKED_FP_WITH_SPACE, contents=TRACKED_FP_CONTENTS_1) 145 | utils_lib.write_file(TRACKED_DIR_FP, contents=TRACKED_FP_CONTENTS_1) 146 | utils_lib.write_file( 147 | TRACKED_DIR_FP_WITH_SPACE, contents=TRACKED_FP_CONTENTS_1) 148 | utils_lib.write_file(TRACKED_DIR_DIR_FP, contents=TRACKED_FP_CONTENTS_1) 149 | utils_lib.write_file( 150 | TRACKED_DIR_DIR_FP_WITH_SPACE, contents=TRACKED_FP_CONTENTS_1) 151 | utils_lib.git( 152 | 'add', 153 | TRACKED_FP, TRACKED_FP_WITH_SPACE, 154 | TRACKED_DIR_FP, TRACKED_DIR_FP_WITH_SPACE, 155 | TRACKED_DIR_DIR_FP, TRACKED_DIR_DIR_FP_WITH_SPACE) 156 | utils_lib.git( 157 | 'commit', 158 | TRACKED_FP, TRACKED_FP_WITH_SPACE, 159 | TRACKED_DIR_FP, TRACKED_DIR_FP_WITH_SPACE, 160 | TRACKED_DIR_DIR_FP, TRACKED_DIR_DIR_FP_WITH_SPACE, '-m', '1') 161 | utils_lib.write_file(TRACKED_FP, contents=TRACKED_FP_CONTENTS_2) 162 | utils_lib.write_file(TRACKED_FP_WITH_SPACE, contents=TRACKED_FP_CONTENTS_2) 163 | utils_lib.write_file(TRACKED_DIR_FP, contents=TRACKED_FP_CONTENTS_2) 164 | utils_lib.write_file( 165 | TRACKED_DIR_FP_WITH_SPACE, contents=TRACKED_FP_CONTENTS_2) 166 | utils_lib.write_file(TRACKED_DIR_DIR_FP, contents=TRACKED_FP_CONTENTS_2) 167 | utils_lib.write_file( 168 | TRACKED_DIR_DIR_FP_WITH_SPACE, contents=TRACKED_FP_CONTENTS_2) 169 | utils_lib.git( 170 | 'commit', 171 | TRACKED_FP, TRACKED_FP_WITH_SPACE, 172 | TRACKED_DIR_FP, TRACKED_DIR_FP_WITH_SPACE, 173 | TRACKED_DIR_DIR_FP, TRACKED_DIR_DIR_FP_WITH_SPACE, '-m', '2') 174 | utils_lib.write_file(UNTRACKED_FP) 175 | utils_lib.write_file(UNTRACKED_FP_WITH_SPACE) 176 | utils_lib.write_file(UNTRACKED_DIR_FP) 177 | utils_lib.write_file(UNTRACKED_DIR_FP_WITH_SPACE) 178 | utils_lib.write_file(UNTRACKED_DIR_DIR_FP) 179 | utils_lib.write_file(UNTRACKED_DIR_DIR_FP_WITH_SPACE) 180 | utils_lib.write_file( 181 | GITIGNORE_FP, contents='{0}\n{1}'.format( 182 | IGNORED_FP, IGNORED_FP_WITH_SPACE)) 183 | utils_lib.write_file(IGNORED_FP) 184 | utils_lib.write_file(IGNORED_FP_WITH_SPACE) 185 | utils_lib.write_file(GITTEST_FP) 186 | 187 | # Testing with symlinks! The symlink calls will be no ops on Windows 188 | utils_lib.write_file(SYMLINK_TARGET_FP, contents=SYMLINK_TARGET_FP_CONTENTS) 189 | utils_lib.symlink(REPO_DIR, SYMLINK_GIT) 190 | os.mkdir(SYMLINK_DIR) 191 | utils_lib.symlink(SYMLINK_TARGET, SYMLINK_FP) 192 | 193 | self.curr_b = self.repo.current_branch 194 | 195 | 196 | class TestFileTrack(TestFile): 197 | 198 | def __assert_track_untracked(self, *fps): 199 | root = self.repo.root 200 | for fp in fps: 201 | fp = os.path.relpath(fp, root) 202 | self.curr_b.track_file(fp) 203 | st = self.curr_b.status_file(fp) 204 | self.assertEqual( 205 | core.GL_STATUS_TRACKED, st.type, 206 | 'Track of fp "{0}" failed: expected status.type={1}, got ' 207 | 'status.type={2}'.format(fp, core.GL_STATUS_TRACKED, st.type)) 208 | 209 | @assert_contents_unchanged( 210 | UNTRACKED_FP, UNTRACKED_FP_WITH_SPACE, 211 | UNTRACKED_DIR_FP, UNTRACKED_DIR_FP_WITH_SPACE, 212 | UNTRACKED_DIR_DIR_FP, UNTRACKED_DIR_DIR_FP_WITH_SPACE) 213 | def test_track_untracked(self): 214 | self.__assert_track_untracked( 215 | UNTRACKED_FP, UNTRACKED_FP_WITH_SPACE, 216 | UNTRACKED_DIR_FP, UNTRACKED_DIR_FP_WITH_SPACE, 217 | UNTRACKED_DIR_DIR_FP, UNTRACKED_DIR_DIR_FP_WITH_SPACE) 218 | 219 | @assert_contents_unchanged( 220 | UNTRACKED_DIR_FP, UNTRACKED_DIR_FP_WITH_SPACE, 221 | UNTRACKED_DIR_DIR_FP, UNTRACKED_DIR_DIR_FP_WITH_SPACE) 222 | def test_track_untracked_relative(self): 223 | os.chdir(DIR) 224 | self.__assert_track_untracked( 225 | os.path.relpath(UNTRACKED_DIR_FP, DIR), 226 | os.path.relpath(UNTRACKED_DIR_FP_WITH_SPACE, DIR)) 227 | os.chdir(DIR) 228 | self.__assert_track_untracked( 229 | os.path.relpath(UNTRACKED_DIR_DIR_FP, DIR_DIR), 230 | os.path.relpath(UNTRACKED_DIR_DIR_FP_WITH_SPACE, DIR_DIR)) 231 | 232 | def __assert_track_tracked(self, *fps): 233 | root = self.repo.root 234 | for fp in fps: 235 | fp = os.path.relpath(fp, root) 236 | self.assertRaisesRegexp( 237 | ValueError, 'already tracked', self.curr_b.track_file, fp) 238 | 239 | @assert_no_side_effects( 240 | TRACKED_FP, TRACKED_FP_WITH_SPACE, 241 | TRACKED_DIR_FP, TRACKED_DIR_FP_WITH_SPACE, 242 | TRACKED_DIR_DIR_FP, TRACKED_DIR_DIR_FP_WITH_SPACE) 243 | def test_track_tracked_fp(self): 244 | self.__assert_track_tracked( 245 | TRACKED_FP, TRACKED_FP_WITH_SPACE, 246 | TRACKED_DIR_FP, TRACKED_DIR_FP_WITH_SPACE, 247 | TRACKED_DIR_DIR_FP, TRACKED_DIR_DIR_FP_WITH_SPACE) 248 | 249 | @assert_no_side_effects( 250 | TRACKED_DIR_FP, TRACKED_DIR_FP_WITH_SPACE, 251 | TRACKED_DIR_DIR_FP, TRACKED_DIR_DIR_FP_WITH_SPACE) 252 | def test_track_tracked_relative(self): 253 | os.chdir(DIR) 254 | self.__assert_track_tracked( 255 | os.path.relpath(TRACKED_DIR_FP, DIR), 256 | os.path.relpath(TRACKED_DIR_FP_WITH_SPACE, DIR)) 257 | os.chdir(DIR) 258 | self.__assert_track_tracked( 259 | os.path.relpath(TRACKED_DIR_DIR_FP, DIR_DIR), 260 | os.path.relpath(TRACKED_DIR_DIR_FP_WITH_SPACE, DIR_DIR)) 261 | 262 | def __assert_track_nonexistent_fp(self, *fps): 263 | root = self.repo.root 264 | for fp in fps: 265 | fp = os.path.relpath(fp, root) 266 | self.assertRaises(KeyError, self.curr_b.track_file, fp) 267 | 268 | def test_track_nonexistent_fp(self): 269 | self.__assert_track_nonexistent_fp( 270 | NONEXISTENT_FP, NONEXISTENT_FP_WITH_SPACE) 271 | 272 | def __assert_track_ignored(self, *fps): 273 | root = self.repo.root 274 | for fp in fps: 275 | fp = os.path.relpath(fp, root) 276 | self.assertRaisesRegexp( 277 | ValueError, 'is ignored', self.curr_b.track_file, fp) 278 | 279 | @assert_no_side_effects(IGNORED_FP, IGNORED_FP_WITH_SPACE) 280 | def test_track_ignored(self): 281 | self.__assert_track_ignored(IGNORED_FP, IGNORED_FP_WITH_SPACE) 282 | 283 | @assert_contents_unchanged(GITIGNORE_FP) 284 | def test_track_gitignore(self): 285 | self.__assert_track_untracked(GITIGNORE_FP) 286 | 287 | 288 | class TestFileUntrack(TestFile): 289 | 290 | def __assert_untrack_tracked(self, *fps): 291 | root = self.repo.root 292 | for fp in fps: 293 | fp = os.path.relpath(fp, root) 294 | self.curr_b.untrack_file(fp) 295 | st = self.curr_b.status_file(fp) 296 | self.assertEqual( 297 | core.GL_STATUS_UNTRACKED, st.type, 298 | 'Untrack of fp "{0}" failed: expected status.type={1}, got ' 299 | 'status.type={2}'.format(fp, core.GL_STATUS_UNTRACKED, st.type)) 300 | 301 | @assert_contents_unchanged( 302 | TRACKED_FP, TRACKED_FP_WITH_SPACE, 303 | TRACKED_DIR_FP, TRACKED_DIR_FP_WITH_SPACE, 304 | TRACKED_DIR_DIR_FP, TRACKED_DIR_DIR_FP_WITH_SPACE) 305 | def test_untrack_tracked(self): 306 | self.__assert_untrack_tracked( 307 | TRACKED_FP, TRACKED_FP_WITH_SPACE, 308 | TRACKED_DIR_FP, TRACKED_DIR_FP_WITH_SPACE, 309 | TRACKED_DIR_DIR_FP, TRACKED_DIR_DIR_FP_WITH_SPACE) 310 | 311 | @assert_contents_unchanged( 312 | TRACKED_DIR_FP, TRACKED_DIR_FP_WITH_SPACE, 313 | TRACKED_DIR_DIR_FP, TRACKED_DIR_DIR_FP_WITH_SPACE) 314 | def test_untrack_tracked_relative(self): 315 | os.chdir(DIR) 316 | self.__assert_untrack_tracked( 317 | os.path.relpath(TRACKED_DIR_FP, DIR), 318 | os.path.relpath(TRACKED_DIR_FP_WITH_SPACE, DIR)) 319 | os.chdir(DIR) 320 | self.__assert_untrack_tracked( 321 | os.path.relpath(TRACKED_DIR_DIR_FP, DIR_DIR), 322 | os.path.relpath(TRACKED_DIR_DIR_FP_WITH_SPACE, DIR_DIR)) 323 | 324 | def __assert_untrack_error(self, msg, *fps): 325 | root = self.repo.root 326 | for fp in fps: 327 | fp = os.path.relpath(fp, root) 328 | self.assertRaisesRegexp(ValueError, msg, self.curr_b.untrack_file, fp) 329 | 330 | def __assert_untrack_untracked(self, *fps): 331 | self.__assert_untrack_error('already untracked', *fps) 332 | 333 | @assert_no_side_effects( 334 | UNTRACKED_FP, UNTRACKED_FP_WITH_SPACE, 335 | UNTRACKED_DIR_FP, UNTRACKED_DIR_FP_WITH_SPACE, 336 | UNTRACKED_DIR_DIR_FP, UNTRACKED_DIR_DIR_FP_WITH_SPACE) 337 | def test_untrack_untracked_fp(self): 338 | self.__assert_untrack_untracked( 339 | UNTRACKED_FP, UNTRACKED_FP_WITH_SPACE, 340 | UNTRACKED_DIR_FP, UNTRACKED_DIR_FP_WITH_SPACE, 341 | UNTRACKED_DIR_DIR_FP, UNTRACKED_DIR_DIR_FP_WITH_SPACE) 342 | 343 | @assert_contents_unchanged( 344 | UNTRACKED_DIR_FP, UNTRACKED_DIR_FP_WITH_SPACE, 345 | UNTRACKED_DIR_DIR_FP, UNTRACKED_DIR_DIR_FP_WITH_SPACE) 346 | def test_untrack_untracked_relative(self): 347 | os.chdir(DIR) 348 | self.__assert_untrack_untracked( 349 | os.path.relpath(UNTRACKED_DIR_FP, DIR), 350 | os.path.relpath(UNTRACKED_DIR_FP_WITH_SPACE, DIR)) 351 | os.chdir(DIR) 352 | self.__assert_untrack_untracked( 353 | os.path.relpath(UNTRACKED_DIR_DIR_FP, DIR_DIR), 354 | os.path.relpath(UNTRACKED_DIR_DIR_FP_WITH_SPACE, DIR_DIR)) 355 | 356 | def __assert_untrack_nonexistent_fp(self, *fps): 357 | root = self.repo.root 358 | for fp in fps: 359 | fp = os.path.relpath(fp, root) 360 | self.assertRaises(KeyError, self.curr_b.untrack_file, fp) 361 | 362 | def test_untrack_nonexistent_fp(self): 363 | self.__assert_untrack_nonexistent_fp( 364 | NONEXISTENT_FP, NONEXISTENT_FP_WITH_SPACE) 365 | 366 | def __assert_untrack_ignored(self, *fps): 367 | self.__assert_untrack_error('is ignored', *fps) 368 | 369 | @assert_no_side_effects(IGNORED_FP, IGNORED_FP_WITH_SPACE) 370 | def test_untrack_ignored(self): 371 | self.__assert_untrack_ignored(IGNORED_FP, IGNORED_FP_WITH_SPACE) 372 | 373 | 374 | class TestFileCheckout(TestFile): 375 | 376 | def __assert_checkout_head(self, *fps): 377 | root = self.repo.root 378 | for fp in fps: 379 | utils_lib.write_file(fp, contents='contents') 380 | self.curr_b.checkout_file( 381 | os.path.relpath(fp, root), self.repo.revparse_single('HEAD')) 382 | self.assertEqual(TRACKED_FP_CONTENTS_2, utils_lib.read_file(fp)) 383 | 384 | @assert_no_side_effects( 385 | TRACKED_FP, TRACKED_FP_WITH_SPACE, 386 | TRACKED_DIR_FP, TRACKED_DIR_FP_WITH_SPACE, 387 | TRACKED_DIR_DIR_FP, TRACKED_DIR_DIR_FP_WITH_SPACE) 388 | def test_checkout_head(self): 389 | self.__assert_checkout_head( 390 | TRACKED_FP, TRACKED_FP_WITH_SPACE, 391 | TRACKED_DIR_FP, TRACKED_DIR_FP_WITH_SPACE, 392 | TRACKED_DIR_DIR_FP, TRACKED_DIR_DIR_FP_WITH_SPACE) 393 | 394 | @assert_no_side_effects( 395 | TRACKED_FP, TRACKED_FP_WITH_SPACE, 396 | TRACKED_DIR_FP, TRACKED_DIR_FP_WITH_SPACE, 397 | TRACKED_DIR_DIR_FP, TRACKED_DIR_DIR_FP_WITH_SPACE) 398 | def test_checkout_head_relative(self): 399 | os.chdir(DIR) 400 | self.__assert_checkout_head(os.path.relpath(TRACKED_DIR_FP, DIR)) 401 | self.__assert_checkout_head(os.path.relpath(TRACKED_DIR_FP_WITH_SPACE, DIR)) 402 | os.chdir(DIR) 403 | self.__assert_checkout_head(os.path.relpath(TRACKED_DIR_DIR_FP, DIR_DIR)) 404 | self.__assert_checkout_head( 405 | os.path.relpath(TRACKED_DIR_DIR_FP_WITH_SPACE, DIR_DIR)) 406 | 407 | def __assert_checkout_not_head(self, *fps): 408 | root = self.repo.root 409 | for fp in fps: 410 | utils_lib.write_file(fp, contents='contents') 411 | self.curr_b.checkout_file( 412 | os.path.relpath(fp, root), self.repo.revparse_single('HEAD^')) 413 | self.assertEqual(TRACKED_FP_CONTENTS_1, utils_lib.read_file(fp)) 414 | 415 | def test_checkout_not_head(self): 416 | self.__assert_checkout_not_head( 417 | TRACKED_FP, TRACKED_FP_WITH_SPACE, 418 | TRACKED_DIR_FP, TRACKED_DIR_FP_WITH_SPACE, 419 | TRACKED_DIR_DIR_FP, TRACKED_DIR_DIR_FP_WITH_SPACE) 420 | 421 | def test_checkout_not_head_relative(self): 422 | os.chdir(DIR) 423 | self.__assert_checkout_not_head(os.path.relpath(TRACKED_DIR_FP, DIR)) 424 | self.__assert_checkout_not_head( 425 | os.path.relpath(TRACKED_DIR_FP_WITH_SPACE, DIR)) 426 | os.chdir(DIR) 427 | self.__assert_checkout_not_head( 428 | os.path.relpath(TRACKED_DIR_DIR_FP, DIR_DIR)) 429 | self.__assert_checkout_not_head( 430 | os.path.relpath(TRACKED_DIR_DIR_FP_WITH_SPACE, DIR_DIR)) 431 | 432 | def __assert_checkout_error(self, *fps, **kwargs): 433 | root = self.repo.root 434 | cp = kwargs.get('cp', 'HEAD') 435 | for fp in fps: 436 | self.assertRaises( 437 | KeyError, self.curr_b.checkout_file, os.path.relpath(fp, root), 438 | self.repo.revparse_single(cp)) 439 | 440 | @assert_no_side_effects( 441 | UNTRACKED_FP, UNTRACKED_FP_WITH_SPACE, 442 | UNTRACKED_DIR_FP, UNTRACKED_DIR_FP_WITH_SPACE, 443 | UNTRACKED_DIR_DIR_FP, UNTRACKED_DIR_DIR_FP_WITH_SPACE) 444 | def test_checkout_uncommitted(self): 445 | self.__assert_checkout_error( 446 | UNTRACKED_FP, UNTRACKED_FP_WITH_SPACE, 447 | UNTRACKED_DIR_FP, UNTRACKED_DIR_FP_WITH_SPACE, 448 | UNTRACKED_DIR_DIR_FP, UNTRACKED_DIR_DIR_FP_WITH_SPACE) 449 | self.__assert_checkout_error( 450 | UNTRACKED_FP, UNTRACKED_FP_WITH_SPACE, 451 | UNTRACKED_DIR_FP, UNTRACKED_DIR_FP_WITH_SPACE, 452 | UNTRACKED_DIR_DIR_FP, UNTRACKED_DIR_DIR_FP_WITH_SPACE, cp='HEAD^1') 453 | 454 | def test_checkout_nonexistent(self): 455 | self.__assert_checkout_error(NONEXISTENT_FP, NONEXISTENT_FP_WITH_SPACE) 456 | 457 | 458 | class TestFileStatus(TestFile): 459 | 460 | def test_status_all(self): 461 | st_all = self.curr_b.status() 462 | for fp, f_type, exists_at_head, exists_in_wd, modified, _ in st_all: 463 | if (fp == UNTRACKED_FP or fp == UNTRACKED_FP_WITH_SPACE or 464 | fp == UNTRACKED_DIR_FP or fp == UNTRACKED_DIR_FP_WITH_SPACE or 465 | fp == UNTRACKED_DIR_DIR_FP or 466 | fp == UNTRACKED_DIR_DIR_FP_WITH_SPACE): 467 | self.__assert_type(fp, core.GL_STATUS_UNTRACKED, f_type) 468 | self.__assert_field(fp, 'exists_at_head', False, exists_at_head) 469 | self.__assert_field(fp, 'modified', True, modified) 470 | elif fp == '.gitignore': 471 | self.__assert_type(fp, core.GL_STATUS_UNTRACKED, f_type) 472 | self.__assert_field(fp, 'exists_at_head', False, exists_at_head) 473 | self.__assert_field(fp, 'modified', True, modified) 474 | self.__assert_field(fp, 'exists_in_wd', True, exists_in_wd) 475 | 476 | def test_status_equivalence(self): 477 | for f_st in self.curr_b.status(): 478 | self.assertEqual(f_st, self.curr_b.status_file(f_st.fp)) 479 | 480 | def test_status_nonexistent_fp(self): 481 | self.assertRaises(KeyError, self.curr_b.status_file, NONEXISTENT_FP) 482 | self.assertRaises( 483 | KeyError, self.curr_b.status_file, NONEXISTENT_FP_WITH_SPACE) 484 | 485 | def test_status_modify(self): 486 | utils_lib.write_file(TRACKED_FP, contents='contents') 487 | st = self.curr_b.status_file(TRACKED_FP) 488 | self.assertTrue(st.modified) 489 | utils_lib.write_file(TRACKED_FP, contents=TRACKED_FP_CONTENTS_2) 490 | st = self.curr_b.status_file(TRACKED_FP) 491 | self.assertFalse(st.modified) 492 | 493 | def test_status_rm(self): 494 | os.remove(TRACKED_FP) 495 | st = self.curr_b.status_file(TRACKED_FP) 496 | self.assertEqual(core.GL_STATUS_TRACKED, st.type) 497 | self.assertTrue(st.modified) 498 | self.assertTrue(st.exists_at_head) 499 | self.assertFalse(st.exists_in_wd) 500 | 501 | utils_lib.write_file(TRACKED_FP, contents=TRACKED_FP_CONTENTS_2) 502 | st = self.curr_b.status_file(TRACKED_FP) 503 | self.assertEqual(core.GL_STATUS_TRACKED, st.type) 504 | self.assertFalse(st.modified) 505 | self.assertTrue(st.exists_at_head) 506 | self.assertTrue(st.exists_in_wd) 507 | 508 | def test_status_track_rm(self): 509 | self.curr_b.track_file(UNTRACKED_FP) 510 | st = self.curr_b.status_file(UNTRACKED_FP) 511 | self.assertEqual(core.GL_STATUS_TRACKED, st.type) 512 | self.assertTrue(st.modified) 513 | 514 | os.remove(UNTRACKED_FP) 515 | self.assertRaises(KeyError, self.curr_b.status_file, UNTRACKED_FP) 516 | 517 | def test_status_track_untrack(self): 518 | self.curr_b.track_file(UNTRACKED_FP) 519 | st = self.curr_b.status_file(UNTRACKED_FP) 520 | self.assertEqual(core.GL_STATUS_TRACKED, st.type) 521 | self.assertTrue(st.modified) 522 | 523 | self.curr_b.untrack_file(UNTRACKED_FP) 524 | st = self.curr_b.status_file(UNTRACKED_FP) 525 | self.assertEqual(core.GL_STATUS_UNTRACKED, st.type) 526 | self.assertTrue(st.modified) 527 | 528 | def test_status_unignore(self): 529 | utils_lib.write_file('.gitignore', contents='') 530 | st = self.curr_b.status_file(IGNORED_FP) 531 | self.assertEqual(core.GL_STATUS_UNTRACKED, st.type) 532 | 533 | st = self.curr_b.status_file(IGNORED_FP_WITH_SPACE) 534 | self.assertEqual(core.GL_STATUS_UNTRACKED, st.type) 535 | 536 | def test_status_ignore(self): 537 | contents = utils_lib.read_file('.gitignore') + '\n' + TRACKED_FP 538 | utils_lib.write_file('.gitignore', contents=contents) 539 | # Tracked files can't be ignored 540 | st = self.curr_b.status_file(TRACKED_FP) 541 | self.assertEqual(core.GL_STATUS_TRACKED, st.type) 542 | 543 | def test_status_untrack_tracked_modify(self): 544 | self.curr_b.untrack_file(TRACKED_FP) 545 | st = self.curr_b.status_file(TRACKED_FP) 546 | self.assertEqual(core.GL_STATUS_UNTRACKED, st.type) 547 | # self.assertFalse(st.modified) 548 | 549 | utils_lib.write_file(TRACKED_FP, contents='contents') 550 | st = self.curr_b.status_file(TRACKED_FP) 551 | self.assertEqual(core.GL_STATUS_UNTRACKED, st.type) 552 | self.assertTrue(st.modified) 553 | 554 | def test_status_untrack_tracked_rm(self): 555 | self.curr_b.untrack_file(TRACKED_FP) 556 | st = self.curr_b.status_file(TRACKED_FP) 557 | self.assertEqual(core.GL_STATUS_UNTRACKED, st.type) 558 | 559 | os.remove(TRACKED_FP) 560 | st = self.curr_b.status_file(TRACKED_FP) 561 | self.assertEqual(core.GL_STATUS_UNTRACKED, st.type) 562 | self.assertTrue(st.modified) 563 | self.assertFalse(st.exists_in_wd) 564 | self.assertTrue(st.exists_at_head) 565 | 566 | def test_status_ignore_tracked(self): 567 | """Assert that ignoring a tracked file has no effect.""" 568 | utils_lib.append_to_file('.gitignore', contents='\n' + TRACKED_FP + '\n') 569 | st = self.curr_b.status_file(TRACKED_FP) 570 | self.__assert_type(TRACKED_FP, core.GL_STATUS_TRACKED, st.type) 571 | 572 | def test_status_ignore_untracked(self): 573 | """Assert that ignoring a untracked file makes it ignored.""" 574 | utils_lib.append_to_file('.gitignore', contents='\n' + UNTRACKED_FP + '\n') 575 | st = self.curr_b.status_file(UNTRACKED_FP) 576 | self.__assert_type(UNTRACKED_FP, core.GL_STATUS_IGNORED, st.type) 577 | 578 | def __assert_type(self, fp, expected, got): 579 | self.assertEqual( 580 | expected, got, 581 | 'Incorrect type for {0}: expected {1}, got {2}'.format( 582 | fp, expected, got)) 583 | 584 | def __assert_field(self, fp, field, expected, got): 585 | self.assertEqual( 586 | expected, got, 587 | 'Incorrect status for {0}: expected {1}={2}, got {3}={4}'.format( 588 | fp, field, expected, field, got)) 589 | 590 | 591 | class TestFileDiff(TestFile): 592 | 593 | @assert_status_unchanged( 594 | UNTRACKED_FP, UNTRACKED_FP_WITH_SPACE, 595 | IGNORED_FP, IGNORED_FP_WITH_SPACE) 596 | def test_diff_nontracked(self): 597 | fps = [ 598 | UNTRACKED_FP, UNTRACKED_FP_WITH_SPACE, 599 | IGNORED_FP, IGNORED_FP_WITH_SPACE] 600 | for fp in fps: 601 | utils_lib.write_file(fp, contents='new contents') 602 | patch = self.curr_b.diff_file(fp) 603 | 604 | self.assertEqual(1, patch.line_stats[1]) 605 | self.assertEqual(0, patch.line_stats[2]) 606 | 607 | self.assertEqual(1, len(patch.hunks)) 608 | hunk = list(patch.hunks)[0] 609 | 610 | self.assertEqual(2, len(hunk.lines)) 611 | self.assertEqual('+', hunk.lines[0].origin) 612 | self.assertEqual('new contents', hunk.lines[0].content) 613 | 614 | def test_diff_nonexistent_fp(self): 615 | self.assertRaises(KeyError, self.curr_b.diff_file, NONEXISTENT_FP) 616 | self.assertRaises( 617 | KeyError, self.curr_b.diff_file, NONEXISTENT_FP_WITH_SPACE) 618 | 619 | @assert_no_side_effects(TRACKED_FP) 620 | def test_empty_diff(self): 621 | patch = self.curr_b.diff_file(TRACKED_FP) 622 | self.assertEqual(0, len(list(patch.hunks))) 623 | self.assertEqual(0, patch.line_stats[1]) 624 | self.assertEqual(0, patch.line_stats[2]) 625 | 626 | def test_diff_basic(self): 627 | utils_lib.write_file(TRACKED_FP, contents='new contents') 628 | patch = self.curr_b.diff_file(TRACKED_FP) 629 | 630 | self.assertEqual(1, patch.line_stats[1]) 631 | self.assertEqual(1, patch.line_stats[2]) 632 | 633 | self.assertEqual(1, len(patch.hunks)) 634 | hunk = list(patch.hunks)[0] 635 | 636 | self.assertEqual(3, len(hunk.lines)) 637 | self.assertEqual('-', hunk.lines[0].origin) 638 | self.assertEqual(TRACKED_FP_CONTENTS_2, hunk.lines[0].content) 639 | 640 | self.assertEqual('+', hunk.lines[1].origin) 641 | self.assertEqual('new contents', hunk.lines[1].content) 642 | 643 | def test_diff_append(self): 644 | utils_lib.append_to_file(TRACKED_FP, contents='new contents') 645 | patch = self.curr_b.diff_file(TRACKED_FP) 646 | 647 | self.assertEqual(1, patch.line_stats[1]) 648 | self.assertEqual(0, patch.line_stats[2]) 649 | 650 | self.assertEqual(1, len(patch.hunks)) 651 | hunk = list(patch.hunks)[0] 652 | 653 | self.assertEqual(3, len(hunk.lines)) 654 | self.assertEqual(' ', hunk.lines[0].origin) 655 | self.assertEqual(TRACKED_FP_CONTENTS_2, hunk.lines[0].content) 656 | 657 | self.assertEqual('+', hunk.lines[1].origin) 658 | self.assertEqual('new contents', hunk.lines[1].content) 659 | 660 | def test_diff_new_fp(self): 661 | fp = 'new' 662 | new_fp_contents = 'new fp contents\n' 663 | utils_lib.write_file(fp, contents=new_fp_contents) 664 | self.curr_b.track_file(fp) 665 | patch = self.curr_b.diff_file(fp) 666 | 667 | self.assertEqual(1, patch.line_stats[1]) 668 | self.assertEqual(0, patch.line_stats[2]) 669 | 670 | self.assertEqual(1, len(patch.hunks)) 671 | hunk = list(patch.hunks)[0] 672 | 673 | self.assertEqual(1, len(hunk.lines)) 674 | self.assertEqual('+', hunk.lines[0].origin) 675 | self.assertEqual(new_fp_contents, hunk.lines[0].content) 676 | 677 | # Now let's add some change to the file and check that diff notices it 678 | utils_lib.append_to_file(fp, contents='new line') 679 | patch = self.curr_b.diff_file(fp) 680 | 681 | self.assertEqual(2, patch.line_stats[1]) 682 | self.assertEqual(0, patch.line_stats[2]) 683 | 684 | self.assertEqual(1, len(patch.hunks)) 685 | hunk = list(patch.hunks)[0] 686 | 687 | self.assertEqual(3, len(hunk.lines)) 688 | self.assertEqual('+', hunk.lines[0].origin) 689 | self.assertEqual(new_fp_contents, hunk.lines[0].content) 690 | 691 | self.assertEqual('+', hunk.lines[1].origin) 692 | self.assertEqual('new line', hunk.lines[1].content) 693 | 694 | def test_diff_non_ascii(self): 695 | if sys.platform == 'win32': 696 | # Skip this test on Windows until we fix Unicode support 697 | return 698 | fp = 'new' 699 | new_fp_contents = '’◕‿◕’©Ä☺’ಠ_ಠ’\n' 700 | utils_lib.write_file(fp, contents=new_fp_contents) 701 | self.curr_b.track_file(fp) 702 | patch = self.curr_b.diff_file(fp) 703 | 704 | self.assertEqual(1, patch.line_stats[1]) 705 | self.assertEqual(0, patch.line_stats[2]) 706 | 707 | self.assertEqual(1, len(patch.hunks)) 708 | hunk = list(patch.hunks)[0] 709 | 710 | self.assertEqual(1, len(hunk.lines)) 711 | self.assertEqual('+', hunk.lines[0].origin) 712 | self.assertEqual(new_fp_contents, hunk.lines[0].content) 713 | 714 | utils_lib.append_to_file(fp, contents='new line') 715 | patch = self.curr_b.diff_file(fp) 716 | 717 | self.assertEqual(2, patch.line_stats[1]) 718 | self.assertEqual(0, patch.line_stats[2]) 719 | 720 | self.assertEqual(1, len(patch.hunks)) 721 | hunk = list(patch.hunks)[0] 722 | 723 | self.assertEqual(3, len(hunk.lines)) 724 | self.assertEqual('+', hunk.lines[0].origin) 725 | self.assertEqual(new_fp_contents, hunk.lines[0].content) 726 | 727 | self.assertEqual('+', hunk.lines[1].origin) 728 | self.assertEqual('new line', hunk.lines[1].content) 729 | 730 | 731 | class TestFileResolve(TestFile): 732 | 733 | def setUp(self): 734 | super(TestFileResolve, self).setUp() 735 | 736 | # Generate a conflict 737 | bname = 'branch' 738 | utils_lib.git('checkout', '-b', bname) 739 | utils_lib.write_file(FP_IN_CONFLICT, contents=bname) 740 | utils_lib.write_file(DIR_FP_IN_CONFLICT, contents=bname) 741 | utils_lib.git('add', FP_IN_CONFLICT, DIR_FP_IN_CONFLICT) 742 | utils_lib.git('commit', FP_IN_CONFLICT, DIR_FP_IN_CONFLICT, '-m', bname) 743 | utils_lib.git('checkout', 'master') 744 | utils_lib.write_file(FP_IN_CONFLICT, contents='master') 745 | utils_lib.write_file(DIR_FP_IN_CONFLICT, contents='master') 746 | utils_lib.git('add', FP_IN_CONFLICT, DIR_FP_IN_CONFLICT) 747 | utils_lib.git('commit', FP_IN_CONFLICT, DIR_FP_IN_CONFLICT, '-m', 'master') 748 | try: 749 | utils_lib.git('merge', bname) 750 | raise Exception('The merge should have failed') 751 | except CalledProcessError as e: 752 | # we expect the merge to fail 753 | pass 754 | 755 | @assert_no_side_effects(TRACKED_FP) 756 | def test_resolve_fp_with_no_conflicts(self): 757 | self.assertRaisesRegexp( 758 | ValueError, 'no conflicts', self.curr_b.resolve_file, TRACKED_FP) 759 | 760 | def __assert_resolve_fp(self, *fps): 761 | for fp in fps: 762 | self.curr_b.resolve_file(fp) 763 | st = self.curr_b.status_file(fp) 764 | self.assertFalse(st.in_conflict) 765 | 766 | @assert_contents_unchanged(FP_IN_CONFLICT, DIR_FP_IN_CONFLICT) 767 | def test_resolve_fp_with_conflicts(self): 768 | self.__assert_resolve_fp(FP_IN_CONFLICT, DIR_FP_IN_CONFLICT) 769 | 770 | def test_resolve_relative(self): 771 | self.__assert_resolve_fp(DIR_FP_IN_CONFLICT) 772 | os.chdir(DIR) 773 | st = self.curr_b.status_file(DIR_FP_IN_CONFLICT) 774 | self.assertFalse(st.in_conflict) 775 | self.assertRaisesRegexp( 776 | ValueError, 'no conflicts', 777 | self.curr_b.resolve_file, DIR_FP_IN_CONFLICT) 778 | 779 | class TestFilePathProcessor(TestFile): 780 | 781 | def setUp(self): 782 | super(TestFilePathProcessor, self).setUp() 783 | self.parser = gl.build_parser([gl_track], self.repo) 784 | 785 | def test_path_processor_track_git(self): 786 | argv = ['track', REPO_DIR] 787 | args = self.parser.parse_args(argv) 788 | files = [fp for fp in args.files] 789 | # Should be empty in this case 790 | self.assertFalse(files) 791 | 792 | @assert_contents_unchanged(REPO_FP) 793 | def test_path_processor_track_git_file(self): 794 | argv = ['track', REPO_FP] 795 | args = self.parser.parse_args(argv) 796 | files = [fp for fp in args.files] 797 | self.assertFalse(files) 798 | 799 | @assert_no_side_effects(GITIGNORE_FP) 800 | def test_path_processor_track_gitignore(self): 801 | argv = ['track', GITIGNORE_FP] 802 | args = self.parser.parse_args(argv) 803 | files = [fp for fp in args.files] 804 | 805 | self.assertEqual(len(files), 1) 806 | self.assertTrue(GITIGNORE_FP in files) 807 | 808 | @assert_no_side_effects(GITTEST_FP) 809 | def test_path_processor_track_gittest_dir(self): 810 | argv = ['track', GITTEST_DIR] 811 | args = self.parser.parse_args(argv) 812 | files = [fp for fp in args.files] 813 | 814 | self.assertEqual(len(files), 1) 815 | self.assertTrue(GITTEST_FP in files) 816 | 817 | @assert_no_side_effects(GITTEST_FP) 818 | def test_path_processor_track_gittest_fp(self): 819 | argv = ['track', GITTEST_FP] 820 | args = self.parser.parse_args(argv) 821 | files = [fp for fp in args.files] 822 | 823 | self.assertEqual(len(files), 1) 824 | self.assertTrue(GITTEST_FP in files) 825 | 826 | @assert_no_side_effects(SYMLINK_TARGET_FP) 827 | def test_path_processor_track_symlink(self): 828 | argv = ['track', SYMLINK_FP] 829 | args = self.parser.parse_args(argv) 830 | files = [fp for fp in args.files] 831 | 832 | if os.path.exists(SYMLINK_FP): 833 | self.assertEqual(len(files), 1) 834 | self.assertTrue(SYMLINK_FP in files) 835 | self.assertFalse(SYMLINK_TARGET_FP in files) 836 | 837 | @assert_no_side_effects(SYMLINK_TARGET_FP) 838 | def test_path_processor_track_symlink_dir(self): 839 | argv = ['track', SYMLINK_DIR] 840 | args = self.parser.parse_args(argv) 841 | files = [fp for fp in args.files] 842 | 843 | if os.path.exists(SYMLINK_FP): 844 | self.assertEqual(len(files), 1) 845 | self.assertTrue(SYMLINK_FP in files) 846 | self.assertFalse(SYMLINK_TARGET_FP in files) 847 | 848 | 849 | # Unit tests for branch related operations 850 | 851 | class TestBranch(TestCore): 852 | """Base class for branch tests.""" 853 | 854 | def setUp(self): 855 | super(TestBranch, self).setUp() 856 | 857 | # Build up an interesting mock repo. 858 | utils_lib.write_file(TRACKED_FP, contents=TRACKED_FP_CONTENTS_1) 859 | utils_lib.git('add', TRACKED_FP) 860 | utils_lib.git('commit', TRACKED_FP, '-m', '1') 861 | utils_lib.write_file(TRACKED_FP, contents=TRACKED_FP_CONTENTS_2) 862 | utils_lib.git('commit', TRACKED_FP, '-m', '2') 863 | utils_lib.write_file(UNTRACKED_FP, contents=UNTRACKED_FP_CONTENTS) 864 | utils_lib.write_file('.gitignore', contents='{0}'.format(IGNORED_FP)) 865 | utils_lib.write_file(IGNORED_FP) 866 | utils_lib.git('branch', BRANCH) 867 | 868 | self.curr_b = self.repo.current_branch 869 | 870 | 871 | class TestBranchCreate(TestBranch): 872 | 873 | def _assert_value_error(self, name, regexp): 874 | self.assertRaisesRegexp( 875 | ValueError, regexp, self.repo.create_branch, name, 876 | self.repo.current_branch.head) 877 | 878 | def test_create_invalid_name(self): 879 | assert_invalid_name = lambda n: self._assert_value_error(n, 'not valid') 880 | assert_invalid_name('') 881 | assert_invalid_name('\t') 882 | assert_invalid_name(' ') 883 | 884 | def test_create_existent_name(self): 885 | self.repo.create_branch('branch1', self.repo.current_branch.head) 886 | self._assert_value_error('branch1', 'exists') 887 | 888 | def test_create(self): 889 | self.repo.create_branch('branch1', self.repo.current_branch.head) 890 | self.repo.switch_current_branch(self.repo.lookup_branch('branch1')) 891 | self.assertTrue(os.path.exists(TRACKED_FP)) 892 | self.assertEqual(TRACKED_FP_CONTENTS_2, utils_lib.read_file(TRACKED_FP)) 893 | self.assertFalse(os.path.exists(UNTRACKED_FP)) 894 | self.assertFalse(os.path.exists(IGNORED_FP)) 895 | self.assertFalse(os.path.exists('.gitignore')) 896 | 897 | def test_create_from_prev_commit(self): 898 | self.repo.create_branch('branch1', self.repo.revparse_single('HEAD^')) 899 | self.repo.switch_current_branch(self.repo.lookup_branch('branch1')) 900 | self.assertTrue(os.path.exists(TRACKED_FP)) 901 | self.assertEqual(TRACKED_FP_CONTENTS_1, utils_lib.read_file(TRACKED_FP)) 902 | self.assertFalse(os.path.exists(UNTRACKED_FP)) 903 | self.assertFalse(os.path.exists(IGNORED_FP)) 904 | self.assertFalse(os.path.exists('.gitignore')) 905 | 906 | 907 | class TestBranchDelete(TestBranch): 908 | 909 | def test_delete(self): 910 | self.repo.lookup_branch(BRANCH).delete() 911 | self.assertRaises( 912 | core.BranchIsCurrentError, 913 | self.repo.lookup_branch('master').delete) 914 | 915 | 916 | class TestBranchSwitch(TestBranch): 917 | 918 | def test_switch_contents_still_there_untrack_tracked(self): 919 | self.curr_b.untrack_file(TRACKED_FP) 920 | utils_lib.write_file(TRACKED_FP, contents='contents') 921 | self.repo.switch_current_branch(self.repo.lookup_branch(BRANCH)) 922 | self.assertEqual(TRACKED_FP_CONTENTS_2, utils_lib.read_file(TRACKED_FP)) 923 | self.repo.switch_current_branch(self.repo.lookup_branch('master')) 924 | self.assertEqual('contents', utils_lib.read_file(TRACKED_FP)) 925 | 926 | def test_switch_contents_still_there_untracked(self): 927 | self.repo.switch_current_branch(self.repo.lookup_branch(BRANCH)) 928 | utils_lib.write_file(UNTRACKED_FP, contents='contents') 929 | self.repo.switch_current_branch(self.repo.lookup_branch('master')) 930 | self.assertEqual(UNTRACKED_FP_CONTENTS, utils_lib.read_file(UNTRACKED_FP)) 931 | self.repo.switch_current_branch(self.repo.lookup_branch(BRANCH)) 932 | self.assertEqual('contents', utils_lib.read_file(UNTRACKED_FP)) 933 | 934 | def test_switch_contents_still_there_ignored(self): 935 | self.repo.switch_current_branch(self.repo.lookup_branch(BRANCH)) 936 | utils_lib.write_file(IGNORED_FP, contents='contents') 937 | self.repo.switch_current_branch(self.repo.lookup_branch('master')) 938 | self.assertEqual(IGNORED_FP, utils_lib.read_file(IGNORED_FP)) 939 | self.repo.switch_current_branch(self.repo.lookup_branch(BRANCH)) 940 | self.assertEqual('contents', utils_lib.read_file(IGNORED_FP)) 941 | 942 | def test_switch_contents_still_there_tracked_commit(self): 943 | utils_lib.write_file(TRACKED_FP, contents='commit') 944 | utils_lib.git('commit', TRACKED_FP, '-m', 'comment') 945 | self.repo.switch_current_branch(self.repo.lookup_branch(BRANCH)) 946 | self.assertEqual(TRACKED_FP_CONTENTS_2, utils_lib.read_file(TRACKED_FP)) 947 | self.repo.switch_current_branch(self.repo.lookup_branch('master')) 948 | self.assertEqual('commit', utils_lib.read_file(TRACKED_FP)) 949 | 950 | def test_switch_file_classification_is_mantained(self): 951 | self.curr_b.untrack_file(TRACKED_FP) 952 | self.repo.switch_current_branch(self.repo.lookup_branch(BRANCH)) 953 | st = self.curr_b.status_file(TRACKED_FP) 954 | self.assertTrue(st) 955 | self.assertEqual(core.GL_STATUS_TRACKED, st.type) 956 | self.repo.switch_current_branch(self.repo.lookup_branch('master')) 957 | st = self.curr_b.status_file(TRACKED_FP) 958 | self.assertTrue(st) 959 | self.assertEqual(core.GL_STATUS_UNTRACKED, st.type) 960 | 961 | def test_switch_with_hidden_files(self): 962 | hf = '.file' 963 | utils_lib.write_file(hf) 964 | self.repo.switch_current_branch(self.repo.lookup_branch(BRANCH)) 965 | utils_lib.write_file(hf, contents='contents') 966 | self.repo.switch_current_branch(self.repo.lookup_branch('master')) 967 | self.assertEqual(hf, utils_lib.read_file(hf)) 968 | self.repo.switch_current_branch(self.repo.lookup_branch(BRANCH)) 969 | self.assertEqual('contents', utils_lib.read_file(hf)) 970 | 971 | 972 | # Unit tests for remote related operations 973 | 974 | class TestRemote(TestCore): 975 | """Base class for remote tests.""" 976 | 977 | def setUp(self): 978 | """Creates temporary local Git repo to use as the remote.""" 979 | super(TestRemote, self).setUp() 980 | 981 | # Create a repo to use as the remote 982 | self.remote_path = tempfile.mkdtemp(prefix='gl-remote-test') 983 | os.chdir(self.remote_path) 984 | remote_repo = core.init_repository() 985 | remote_repo.create_branch( 986 | REMOTE_BRANCH, remote_repo.revparse_single('HEAD')) 987 | 988 | # Go back to the original repo 989 | os.chdir(self.path) 990 | self.remotes = self.repo.remotes 991 | 992 | def tearDown(self): 993 | """Removes the temporary dir.""" 994 | super(TestRemote, self).tearDown() 995 | utils_lib.rmtree(self.remote_path) 996 | 997 | 998 | class TestRemoteCreate(TestRemote): 999 | 1000 | def test_create_new(self): 1001 | self.remotes.create('remote', self.remote_path) 1002 | 1003 | def test_create_existing(self): 1004 | self.remotes.create('remote', self.remote_path) 1005 | self.assertRaises( 1006 | ValueError, self.remotes.create, 'remote', self.remote_path) 1007 | 1008 | def test_create_invalid_name(self): 1009 | self.assertRaises(ValueError, self.remotes.create, 'rem/ote', 'url') 1010 | 1011 | def test_create_invalid_url(self): 1012 | self.assertRaises(ValueError, self.remotes.create, 'remote', '') 1013 | 1014 | 1015 | class TestRemoteList(TestRemote): 1016 | 1017 | def test_list_all(self): 1018 | self.remotes.create('remote1', self.remote_path) 1019 | self.remotes.create('remote2', self.remote_path) 1020 | self.assertCountEqual( 1021 | ['remote1', 'remote2'], [r.name for r in self.remotes]) 1022 | 1023 | 1024 | class TestRemoteDelete(TestRemote): 1025 | 1026 | def test_delete(self): 1027 | self.remotes.create('remote', self.remote_path) 1028 | self.remotes.delete('remote') 1029 | 1030 | def test_delete_nonexistent(self): 1031 | self.assertRaises(KeyError, self.remotes.delete, 'remote') 1032 | self.remotes.create('remote', self.remote_path) 1033 | self.remotes.delete('remote') 1034 | self.assertRaises(KeyError, self.remotes.delete, 'remote') 1035 | 1036 | 1037 | class TestRemoteSync(TestRemote): 1038 | 1039 | def setUp(self): 1040 | super(TestRemoteSync, self).setUp() 1041 | 1042 | utils_lib.write_file('foo', contents='foo') 1043 | utils_lib.git('add', 'foo') 1044 | utils_lib.git('commit', 'foo', '-m', 'msg') 1045 | 1046 | self.repo.remotes.create('remote', self.remote_path) 1047 | self.remote = self.repo.remotes['remote'] 1048 | 1049 | def test_sync_changes(self): 1050 | master_head_before = self.remote.lookup_branch('master').head 1051 | remote_branch = self.remote.lookup_branch(REMOTE_BRANCH) 1052 | remote_branch_head_before = remote_branch.head 1053 | 1054 | current_b = self.repo.current_branch 1055 | # It is not a ff so it should fail 1056 | self.assertRaises(core.GlError, current_b.publish, remote_branch) 1057 | # Get the changes 1058 | utils_lib.git('rebase', str(remote_branch)) 1059 | # Retry (this time it should work) 1060 | current_b.publish(remote_branch) 1061 | 1062 | self.assertCountEqual( 1063 | ['master', REMOTE_BRANCH], self.remote.listall_branches()) 1064 | self.assertEqual( 1065 | master_head_before.id, self.remote.lookup_branch('master').head.id) 1066 | 1067 | self.assertNotEqual( 1068 | remote_branch_head_before.id, 1069 | remote_branch.head.id) 1070 | self.assertEqual(current_b.head.id, remote_branch.head.id) 1071 | -------------------------------------------------------------------------------- /gitless/tests/test_e2e.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Gitless - a version control system built on top of Git 3 | # Licensed under MIT 4 | 5 | """End-to-end test.""" 6 | 7 | 8 | import logging 9 | import os 10 | import re 11 | import time 12 | from subprocess import CalledProcessError 13 | import sys 14 | 15 | from gitless.tests import utils 16 | 17 | try: 18 | text = unicode 19 | except NameError: 20 | text = str 21 | 22 | 23 | class TestEndToEnd(utils.TestBase): 24 | 25 | def setUp(self): 26 | super(TestEndToEnd, self).setUp('gl-e2e-test') 27 | utils.gl('init') 28 | # Disable colored output so that we don't need to worry about ANSI escape 29 | # codes 30 | utils.git('config', 'color.ui', 'False') 31 | # Disable paging so that we don't have to use sh's _tty_out option, which is 32 | # not available on pbs 33 | if sys.platform != 'win32': 34 | utils.git('config', 'core.pager', 'cat') 35 | else: 36 | # On Windows, we need to call 'type' through cmd.exe (with 'cmd'). The /C 37 | # is so that the command window gets closed after 'type' finishes 38 | utils.git('config', 'core.pager', 'cmd /C type') 39 | utils.set_test_config() 40 | 41 | 42 | class TestNotInRepo(utils.TestBase): 43 | 44 | def setUp(self): 45 | super(TestNotInRepo, self).setUp('gl-e2e-test') 46 | 47 | def test_not_in_repo(self): 48 | def assert_not_in_repo(*cmds): 49 | for cmd in cmds: 50 | self.assertRaisesRegexp( 51 | CalledProcessError, 'not in a Gitless\'s repository', utils.gl, cmd) 52 | 53 | assert_not_in_repo( 54 | 'status', 'diff', 'commit', 'branch', 'merge', 'fuse', 'remote', 55 | 'publish', 'history') 56 | 57 | 58 | class TestBasic(TestEndToEnd): 59 | 60 | def test_basic_functionality(self): 61 | utils.write_file('file1', 'Contents of file1') 62 | # Track 63 | utils.gl('track', 'file1') 64 | self.assertRaises(CalledProcessError, utils.gl, 'track', 'file1') 65 | self.assertRaises(CalledProcessError, utils.gl, 'track', 'non-existent') 66 | # Untrack 67 | utils.gl('untrack', 'file1') 68 | self.assertRaises(CalledProcessError, utils.gl, 'untrack', 'file1') 69 | self.assertRaises(CalledProcessError, utils.gl, 'untrack', 'non-existent') 70 | # Commit 71 | utils.gl('track', 'file1') 72 | utils.gl('commit', '-m', 'file1 commit') 73 | self.assertRaises( 74 | CalledProcessError, utils.gl, 'commit', '-m', 'nothing to commit') 75 | # History 76 | if 'file1 commit' not in utils.gl('history'): 77 | self.fail('Commit didn\'t appear in history') 78 | # Branch 79 | # Make some changes to file1 and branch out 80 | utils.write_file('file1', 'New contents of file1') 81 | utils.gl('branch', '-c', 'branch1') 82 | utils.gl('switch', 'branch1') 83 | if 'New' in utils.read_file('file1'): 84 | self.fail('Branch not independent!') 85 | # Switch back to master branch, check that contents are the same as before. 86 | utils.gl('switch', 'master') 87 | if 'New' not in utils.read_file('file1'): 88 | self.fail('Branch not independent!') 89 | out = utils.gl('branch') 90 | if '* master' not in out: 91 | self.fail('Branch status output wrong: {0}'.format(out)) 92 | if 'branch1' not in out: 93 | self.fail('Branch status output wrong: {0}'.format(out)) 94 | 95 | utils.gl('branch', '-c', 'branch2') 96 | utils.gl('branch', '-c', 'branch-conflict1') 97 | utils.gl('branch', '-c', 'branch-conflict2') 98 | utils.gl('commit', '-m', 'New contents commit') 99 | 100 | # Fuse 101 | utils.gl('switch', 'branch1') 102 | self.assertRaises(CalledProcessError, utils.gl, 'fuse') # no upstream set 103 | try: 104 | utils.gl('fuse', 'master') 105 | except CalledProcessError as e: 106 | self.fail(utils.stderr(e)) 107 | out = utils.gl('history') 108 | if 'file1 commit' not in out: 109 | self.fail(out) 110 | 111 | # Merge 112 | utils.gl('switch', 'branch2') 113 | self.assertRaises(CalledProcessError, utils.gl, 'merge') # no upstream set 114 | utils.gl('merge', 'master') 115 | out = utils.gl('history') 116 | if 'file1 commit' not in out: 117 | self.fail(out) 118 | 119 | # Conflicting fuse 120 | utils.gl('switch', 'branch-conflict1') 121 | utils.write_file('file1', 'Conflicting changes to file1') 122 | utils.gl('commit', '-m', 'changes in branch-conflict1') 123 | try: 124 | utils.gl('fuse', 'master') 125 | except CalledProcessError as e: 126 | # expected 127 | err = e.stderr 128 | if 'conflict' not in err: 129 | self.fail(err) 130 | 131 | out = utils.gl('status') 132 | if 'file1 (with conflicts)' not in out: 133 | self.fail(out) 134 | 135 | # Try aborting 136 | utils.gl('fuse', '--abort') 137 | out = utils.gl('status') 138 | if 'file1' in out: 139 | self.fail(out) 140 | 141 | # Ok, now let's fix the conflicts 142 | try: 143 | utils.gl('fuse', 'master') 144 | except CalledProcessError as e: 145 | # expected 146 | err = e.stderr 147 | if 'conflict' not in err: 148 | self.fail(err) 149 | 150 | out = utils.gl('status') 151 | if 'file1 (with conflicts)' not in out: 152 | self.fail(out) 153 | 154 | utils.write_file('file1', 'Fixed conflicts!') 155 | self.assertRaises( 156 | CalledProcessError, utils.gl, 'commit', '-m', 'resolve not called') 157 | self.assertRaises( 158 | CalledProcessError, utils.gl, 'resolve', 'non-existent') 159 | utils.gl('resolve', 'file1') 160 | utils.gl('commit', '-m', 'fixed conflicts') 161 | 162 | 163 | class TestCommit(TestEndToEnd): 164 | 165 | TRACKED_FP = 'file1' 166 | DIR_TRACKED_FP = 'dir/dir_file' 167 | UNTRACKED_FP = 'file2' 168 | FPS = [TRACKED_FP, DIR_TRACKED_FP, UNTRACKED_FP] 169 | DIR = 'dir' 170 | 171 | def setUp(self): 172 | super(TestCommit, self).setUp() 173 | utils.write_file(self.TRACKED_FP) 174 | utils.write_file(self.DIR_TRACKED_FP) 175 | utils.write_file(self.UNTRACKED_FP) 176 | utils.gl('track', self.TRACKED_FP, self.DIR_TRACKED_FP) 177 | 178 | def test_commit(self): 179 | utils.gl('commit', '-m', 'msg') 180 | self.__assert_commit(self.TRACKED_FP, self.DIR_TRACKED_FP) 181 | 182 | def test_commit_relative(self): 183 | os.chdir(self.DIR) 184 | utils.gl('commit', '-m', 'msg') 185 | self.__assert_commit(self.TRACKED_FP, self.DIR_TRACKED_FP) 186 | 187 | def test_commit_only(self): 188 | utils.gl('commit', self.TRACKED_FP, '-m', 'msg') 189 | self.__assert_commit(self.TRACKED_FP) 190 | 191 | def test_commit_only_relative(self): 192 | os.chdir(self.DIR) 193 | self.assertRaises( 194 | CalledProcessError, utils.gl, 'commit', self.TRACKED_FP, '-m', 'msg') 195 | utils.gl('commit', '../' + self.TRACKED_FP, '-m', 'msg') 196 | self.__assert_commit(self.TRACKED_FP) 197 | 198 | def test_commit_only_untrack(self): 199 | utils.gl('commit', '-m', 'msg', self.UNTRACKED_FP) 200 | self.__assert_commit(self.UNTRACKED_FP) 201 | 202 | def test_commit_only_untrack_relative(self): 203 | os.chdir(self.DIR) 204 | self.assertRaises( 205 | CalledProcessError, utils.gl, 'commit', self.UNTRACKED_FP, '-m', 'msg') 206 | utils.gl('commit', '../' + self.UNTRACKED_FP, '-m', 'msg') 207 | self.__assert_commit(self.UNTRACKED_FP) 208 | 209 | def test_commit_include(self): 210 | utils.gl('commit', '-m', 'msg', '--include', self.UNTRACKED_FP) 211 | self.__assert_commit( 212 | self.TRACKED_FP, self.DIR_TRACKED_FP, self.UNTRACKED_FP) 213 | 214 | def test_commit_exclude_include(self): 215 | utils.gl( 216 | 'commit', '-m', 'msg', 217 | '--include', self.UNTRACKED_FP, '--exclude', self.TRACKED_FP) 218 | self.__assert_commit(self.UNTRACKED_FP, self.DIR_TRACKED_FP) 219 | 220 | def test_commit_no_files(self): 221 | self.assertRaises( 222 | CalledProcessError, utils.gl, 'commit', '--exclude', 223 | self.TRACKED_FP, self.DIR_TRACKED_FP, '-m', 'msg') 224 | self.assertRaises( 225 | CalledProcessError, utils.gl, 'commit', 'non-existent', '-m', 'msg') 226 | self.assertRaises( 227 | CalledProcessError, utils.gl, 'commit', '-m', 'msg', 228 | '--exclude', 'non-existent') 229 | self.assertRaises( 230 | CalledProcessError, utils.gl, 'commit', '-m', 'msg', 231 | '--include', 'non-existent') 232 | 233 | def test_commit_dir(self): 234 | fp = 'dir/f' 235 | utils.write_file(fp) 236 | utils.gl('commit', fp, '-m', 'msg') 237 | self.__assert_commit('dir/f') 238 | 239 | def __assert_commit(self, *expected_committed): 240 | h = utils.gl('history', '-v') 241 | for fp in expected_committed: 242 | if fp not in h: 243 | self.fail('{0} was apparently not committed!'.format(fp)) 244 | expected_not_committed = [ 245 | fp for fp in self.FPS if fp not in expected_committed] 246 | for fp in expected_not_committed: 247 | if fp in h: 248 | self.fail('{0} was apparently committed!'.format(fp)) 249 | 250 | 251 | class TestStatus(TestEndToEnd): 252 | 253 | DIR = 'dir' 254 | TRACKED_DIR_FP = os.path.join('dir', 'file1') 255 | UNTRACKED_DIR_FP = os.path.join('dir', 'file2') 256 | 257 | def setUp(self): 258 | super(TestStatus, self).setUp() 259 | utils.write_file(self.TRACKED_DIR_FP) 260 | utils.write_file(self.UNTRACKED_DIR_FP) 261 | utils.gl('commit', self.TRACKED_DIR_FP, '-m', 'commit') 262 | 263 | def test_status_relative(self): 264 | utils.write_file(self.TRACKED_DIR_FP, contents='some modifications') 265 | st = utils.gl('status') 266 | if self.TRACKED_DIR_FP not in st: 267 | self.fail() 268 | if self.UNTRACKED_DIR_FP not in st: 269 | self.fail() 270 | 271 | os.chdir(self.DIR) 272 | 273 | st = utils.gl('status') 274 | rel_tracked = os.path.relpath(self.TRACKED_DIR_FP, self.DIR) 275 | rel_untracked = os.path.relpath(self.UNTRACKED_DIR_FP, self.DIR) 276 | if (self.TRACKED_DIR_FP in st) or (rel_tracked not in st): 277 | self.fail() 278 | if (self.UNTRACKED_DIR_FP in st) or (rel_untracked not in st): 279 | self.fail() 280 | 281 | 282 | class TestBranch(TestEndToEnd): 283 | 284 | BRANCH_1 = 'branch1' 285 | BRANCH_2 = 'branch2' 286 | 287 | def setUp(self): 288 | super(TestBranch, self).setUp() 289 | utils.write_file('f') 290 | utils.gl('commit', 'f', '-m', 'commit') 291 | 292 | def test_create(self): 293 | utils.gl('branch', '-c', self.BRANCH_1) 294 | self.assertRaises( 295 | CalledProcessError, utils.gl, 'branch', '-c', self.BRANCH_1) 296 | self.assertRaises( 297 | CalledProcessError, utils.gl, 'branch', '-c', 'evil*named*branch') 298 | if self.BRANCH_1 not in utils.gl('branch'): 299 | self.fail() 300 | 301 | def test_remove(self): 302 | utils.gl('branch', '-c', self.BRANCH_1) 303 | utils.gl('switch', self.BRANCH_1) 304 | self.assertRaises( 305 | CalledProcessError, utils.gl, 'branch', '-d', self.BRANCH_1, _in='y') 306 | utils.gl('branch', '-c', self.BRANCH_2) 307 | utils.gl('switch', self.BRANCH_2) 308 | utils.gl('branch', '-d', self.BRANCH_1, _in='n') 309 | utils.gl('branch', '-d', self.BRANCH_1, _in='y') 310 | if self.BRANCH_1 in utils.gl('branch'): 311 | self.fail() 312 | 313 | def test_upstream(self): 314 | self.assertRaises(CalledProcessError, utils.gl, 'branch', '-uu') 315 | self.assertRaises( 316 | CalledProcessError, utils.gl, 'branch', '-su', 'non-existent') 317 | self.assertRaises( 318 | CalledProcessError, utils.gl, 'branch', '-su', 'non-existent/non-existent') 319 | 320 | def test_list(self): 321 | utils.gl('branch', '-c', self.BRANCH_1) 322 | utils.gl('branch', '-c', self.BRANCH_2) 323 | branch_out = utils.gl('branch') 324 | self.assertTrue( 325 | branch_out.find(self.BRANCH_1) < branch_out.find(self.BRANCH_2)) 326 | 327 | 328 | class TestTag(TestEndToEnd): 329 | 330 | TAG_1 = 'tag1' 331 | TAG_2 = 'tag2' 332 | 333 | def setUp(self): 334 | super(TestTag, self).setUp() 335 | utils.write_file('f') 336 | utils.gl('commit', 'f', '-m', 'commit') 337 | 338 | def test_create(self): 339 | utils.gl('tag', '-c', self.TAG_1) 340 | self.assertRaises(CalledProcessError, utils.gl, 'tag', '-c', self.TAG_1) 341 | self.assertRaises( 342 | CalledProcessError, utils.gl, 'tag', '-c', 'evil*named*tag') 343 | if self.TAG_1 not in utils.gl('tag'): 344 | self.fail() 345 | 346 | def test_remove(self): 347 | utils.gl('tag', '-c', self.TAG_1) 348 | utils.gl('tag', '-d', self.TAG_1, _in='n') 349 | utils.gl('tag', '-d', self.TAG_1, _in='y') 350 | if self.TAG_1 in utils.gl('tag'): 351 | self.fail() 352 | 353 | def test_list(self): 354 | utils.gl('tag', '-c', self.TAG_1) 355 | utils.gl('tag', '-c', self.TAG_2) 356 | tag_out = utils.gl('tag') 357 | self.assertTrue( 358 | tag_out.find(self.TAG_1) < tag_out.find(self.TAG_2)) 359 | 360 | 361 | class TestDiffFile(TestEndToEnd): 362 | 363 | TRACKED_FP = 't_fp' 364 | DIR_TRACKED_FP = os.path.join('dir', 't_fp') 365 | UNTRACKED_FP = 'u_fp' 366 | DIR = 'dir' 367 | 368 | def setUp(self): 369 | super(TestDiffFile, self).setUp() 370 | utils.write_file(self.TRACKED_FP) 371 | utils.write_file(self.DIR_TRACKED_FP) 372 | utils.gl('commit', self.TRACKED_FP, self.DIR_TRACKED_FP, '-m', 'commit') 373 | utils.write_file(self.UNTRACKED_FP) 374 | 375 | def test_empty_diff(self): 376 | if 'No files to diff' not in utils.gl('diff'): 377 | self.fail() 378 | 379 | def test_diff_nonexistent_fp(self): 380 | try: 381 | utils.gl('diff', 'file') 382 | except CalledProcessError as e: 383 | # expected 384 | err = e.stderr 385 | if 'doesn\'t exist' not in err: 386 | self.fail() 387 | 388 | def test_basic_diff(self): 389 | utils.write_file(self.TRACKED_FP, contents='contents') 390 | out1 = utils.gl('diff') 391 | if '+contents' not in out1: 392 | self.fail() 393 | out2 = utils.gl('diff', self.TRACKED_FP) 394 | if '+contents' not in out2: 395 | self.fail() 396 | self.assertEqual(out1, out2) 397 | 398 | def test_basic_diff_relative(self): 399 | utils.write_file(self.TRACKED_FP, contents='contents_tracked') 400 | utils.write_file(self.DIR_TRACKED_FP, contents='contents_dir_tracked') 401 | os.chdir(self.DIR) 402 | out1 = utils.gl('diff') 403 | if '+contents_tracked' not in out1: 404 | self.fail() 405 | if '+contents_dir_tracked' not in out1: 406 | self.fail() 407 | rel_dir_tracked_fp = os.path.relpath(self.DIR_TRACKED_FP, self.DIR) 408 | out2 = utils.gl('diff', rel_dir_tracked_fp) 409 | if '+contents_dir_tracked' not in out2: 410 | self.fail() 411 | 412 | def test_diff_dir(self): 413 | fp = 'dir/dir/f' 414 | utils.write_file(fp, contents='contents') 415 | out = utils.gl('diff', fp) 416 | if '+contents' not in out: 417 | self.fail() 418 | 419 | def test_diff_non_ascii(self): 420 | if sys.platform == 'win32': 421 | # Skip this test on Windows until we fix Unicode support 422 | return 423 | contents = '’◕‿◕’©Ä☺’ಠ_ಠ’' 424 | utils.write_file(self.TRACKED_FP, contents=contents) 425 | out1 = utils.gl('diff') 426 | if '+' + contents not in out1: 427 | self.fail('out is ' + out1) 428 | out2 = utils.gl('diff', self.TRACKED_FP) 429 | if '+' + contents not in out2: 430 | self.fail('out is ' + out2) 431 | self.assertEqual(out1, out2) 432 | 433 | 434 | class TestOp(TestEndToEnd): 435 | 436 | COMMITS_NUMBER = 4 437 | OTHER = 'other' 438 | MASTER_FILE = 'master_file' 439 | OTHER_FILE = 'other_file' 440 | 441 | def setUp(self): 442 | super(TestOp, self).setUp() 443 | 444 | self.commits = {} 445 | def create_commits(branch_name, fp): 446 | self.commits[branch_name] = [] 447 | utils.append_to_file(fp, contents='contents {0}\n'.format(0)) 448 | out = utils.gl( 449 | 'commit', '-m', 'ci 0 in {0}'.format(branch_name), '--include', fp) 450 | self.commits[branch_name].append( 451 | re.search(r'Commit Id: (\S*)', out, re.UNICODE).group(1)) 452 | for i in range(1, self.COMMITS_NUMBER): 453 | utils.append_to_file(fp, contents='contents {0}\n'.format(i)) 454 | out = utils.gl('commit', '-m', 'ci {0} in {1}'.format(i, branch_name)) 455 | self.commits[branch_name].append( 456 | re.search(r'Commit Id: (\S*)', out, re.UNICODE).group(1)) 457 | 458 | utils.gl('branch', '-c', self.OTHER) 459 | create_commits('master', self.MASTER_FILE) 460 | try: 461 | utils.gl('switch', self.OTHER) 462 | except CalledProcessError as e: 463 | raise Exception(e.stderr) 464 | create_commits(self.OTHER, self.OTHER_FILE) 465 | utils.gl('switch', 'master') 466 | 467 | 468 | class TestFuse(TestOp): 469 | 470 | def __assert_history(self, expected): 471 | out = utils.gl('history') 472 | cids = list(reversed(re.findall(r'ci (.*) in (\S*)', out, re.UNICODE))) 473 | self.assertCountEqual( 474 | cids, expected, 'cids is ' + text(cids) + ' exp ' + text(expected)) 475 | 476 | st_out = utils.gl('status') 477 | self.assertFalse('fuse' in st_out) 478 | 479 | def __build(self, branch_name, cids=None): 480 | if not cids: 481 | cids = range(self.COMMITS_NUMBER) 482 | return [(text(ci), branch_name) for ci in cids] 483 | 484 | def test_basic(self): 485 | utils.gl('fuse', self.OTHER) 486 | self.__assert_history(self.__build(self.OTHER) + self.__build('master')) 487 | 488 | def test_only_errors(self): 489 | self.assertRaises( 490 | CalledProcessError, utils.gl, 'fuse', self.OTHER, '-o', 'non-existent-id') 491 | self.assertRaises( 492 | CalledProcessError, utils.gl, 'fuse', self.OTHER, 493 | '-o', self.commits['master'][1]) 494 | 495 | def test_only_one(self): 496 | utils.gl('fuse', self.OTHER, '-o', self.commits[self.OTHER][0]) 497 | self.__assert_history( 498 | self.__build(self.OTHER, cids=[0]) + self.__build('master')) 499 | 500 | def test_only_some(self): 501 | utils.gl('fuse', self.OTHER, '-o', *self.commits[self.OTHER][:2]) 502 | self.__assert_history( 503 | self.__build(self.OTHER, [0, 1]) + self.__build('master')) 504 | 505 | def test_exclude_errors(self): 506 | self.assertRaises( 507 | CalledProcessError, utils.gl, 'fuse', self.OTHER, '-e', 'non-existent-id') 508 | self.assertRaises( 509 | CalledProcessError, utils.gl, 'fuse', self.OTHER, 510 | '-e', self.commits['master'][1]) 511 | 512 | def test_exclude_one(self): 513 | last_ci = self.COMMITS_NUMBER - 1 514 | utils.gl('fuse', self.OTHER, '-e', self.commits[self.OTHER][last_ci]) 515 | self.__assert_history( 516 | self.__build(self.OTHER, range(0, last_ci)) + self.__build('master')) 517 | 518 | def test_exclude_some(self): 519 | utils.gl('fuse', self.OTHER, '-e', *self.commits[self.OTHER][1:]) 520 | self.__assert_history( 521 | self.__build(self.OTHER, cids=[0]) + self.__build('master')) 522 | 523 | def test_ip_dp(self): 524 | utils.gl('fuse', self.OTHER, '--insertion-point', 'dp') 525 | self.__assert_history(self.__build(self.OTHER) + self.__build('master')) 526 | 527 | def test_ip_head(self): 528 | utils.gl('fuse', self.OTHER, '--insertion-point', 'HEAD') 529 | self.__assert_history(self.__build('master') + self.__build(self.OTHER)) 530 | 531 | def test_ip_commit(self): 532 | utils.gl('fuse', self.OTHER, '--insertion-point', self.commits['master'][1]) 533 | self.__assert_history( 534 | self.__build('master', [0, 1]) + self.__build(self.OTHER) + 535 | self.__build('master', range(2, self.COMMITS_NUMBER))) 536 | 537 | def test_conflicts(self): 538 | def trigger_conflicts(): 539 | self.assertRaisesRegexp( 540 | CalledProcessError, 'conflicts', utils.gl, 'fuse', 541 | self.OTHER, '-e', self.commits[self.OTHER][0]) 542 | 543 | # Abort 544 | trigger_conflicts() 545 | utils.gl('fuse', '-a') 546 | self.__assert_history(self.__build('master')) 547 | 548 | # Fix conflicts 549 | trigger_conflicts() 550 | utils.gl('resolve', self.OTHER_FILE) 551 | utils.gl('commit', '-m', 'ci 1 in other') 552 | self.__assert_history( 553 | self.__build(self.OTHER, range(1, self.COMMITS_NUMBER)) + 554 | self.__build('master')) 555 | 556 | def test_conflicts_switch(self): 557 | utils.gl('switch', 'other') 558 | utils.write_file(self.OTHER_FILE, contents='uncommitted') 559 | utils.gl('switch', 'master') 560 | try: 561 | utils.gl('fuse', self.OTHER, '-e', self.commits[self.OTHER][0]) 562 | self.fail() 563 | except CalledProcessError: 564 | pass 565 | 566 | # Switch 567 | utils.gl('switch', 'other') 568 | self.__assert_history(self.__build('other')) 569 | st_out = utils.gl('status') 570 | self.assertTrue('fuse' not in st_out) 571 | self.assertTrue('conflict' not in st_out) 572 | 573 | utils.gl('switch', 'master') 574 | st_out = utils.gl('status') 575 | self.assertTrue('fuse' in st_out) 576 | self.assertTrue('conflict' in st_out) 577 | 578 | # Check that we are able to complete the fuse after switch 579 | utils.gl('resolve', self.OTHER_FILE) 580 | utils.gl('commit', '-m', 'ci 1 in other') 581 | self.__assert_history( 582 | self.__build(self.OTHER, range(1, self.COMMITS_NUMBER)) + 583 | self.__build('master')) 584 | 585 | utils.gl('switch', 'other') 586 | self.assertEqual('uncommitted', utils.read_file(self.OTHER_FILE)) 587 | 588 | def test_conflicts_multiple(self): 589 | utils.gl('branch', '-c', 'tmp', '--divergent-point', 'HEAD~2') 590 | utils.gl('switch', 'tmp') 591 | utils.append_to_file(self.MASTER_FILE, contents='conflict') 592 | utils.gl('commit', '-m', 'will conflict 0') 593 | utils.append_to_file(self.MASTER_FILE, contents='conflict') 594 | utils.gl('commit', '-m', 'will conflict 1') 595 | 596 | self.assertRaisesRegexp( 597 | CalledProcessError, 'conflicts', utils.gl, 'fuse', 'master') 598 | utils.gl('resolve', self.MASTER_FILE) 599 | self.assertRaisesRegexp( 600 | CalledProcessError, 'conflicts', utils.gl, 'commit', '-m', 'ci 0 in tmp') 601 | utils.gl('resolve', self.MASTER_FILE) 602 | utils.gl('commit', '-m', 'ci 1 in tmp') # this one should finalize the fuse 603 | 604 | self.__assert_history( 605 | self.__build('master') + self.__build('tmp', range(2))) 606 | 607 | def test_conflicts_multiple_uncommitted_changes(self): 608 | utils.gl('branch', '-c', 'tmp', '--divergent-point', 'HEAD~2') 609 | utils.gl('switch', 'tmp') 610 | utils.append_to_file(self.MASTER_FILE, contents='conflict') 611 | utils.gl('commit', '-m', 'will conflict 0') 612 | utils.append_to_file(self.MASTER_FILE, contents='conflict') 613 | utils.gl('commit', '-m', 'will conflict 1') 614 | utils.write_file(self.MASTER_FILE, contents='uncommitted') 615 | 616 | self.assertRaisesRegexp( 617 | CalledProcessError, 'conflicts', utils.gl, 'fuse', 'master') 618 | utils.gl('resolve', self.MASTER_FILE) 619 | self.assertRaisesRegexp( 620 | CalledProcessError, 'conflicts', utils.gl, 'commit', '-m', 'ci 0 in tmp') 621 | utils.gl('resolve', self.MASTER_FILE) 622 | self.assertRaisesRegexp( 623 | CalledProcessError, 'failed to apply', utils.gl, 624 | 'commit', '-m', 'ci 1 in tmp') 625 | 626 | self.__assert_history( 627 | self.__build('master') + self.__build('tmp', range(2))) 628 | self.assertTrue('Stashed' in utils.read_file(self.MASTER_FILE)) 629 | 630 | def test_nothing_to_fuse(self): 631 | self.assertRaisesRegexp( 632 | CalledProcessError, 'No commits to fuse', utils.gl, 'fuse', 633 | self.OTHER, '-e', *self.commits[self.OTHER]) 634 | 635 | def test_ff(self): 636 | utils.gl('branch', '-c', 'tmp', '--divergent-point', 'HEAD~2') 637 | utils.gl('switch', 'tmp') 638 | 639 | utils.gl('fuse', 'master') 640 | self.__assert_history(self.__build('master')) 641 | 642 | def test_ff_ip_head(self): 643 | utils.gl('branch', '-c', 'tmp', '--divergent-point', 'HEAD~2') 644 | utils.gl('switch', 'tmp') 645 | 646 | utils.gl('fuse', 'master', '--insertion-point', 'HEAD') 647 | self.__assert_history(self.__build('master')) 648 | 649 | def test_uncommitted_changes(self): 650 | utils.write_file(self.MASTER_FILE, contents='uncommitted') 651 | utils.write_file('master_untracked', contents='uncommitted') 652 | utils.gl('fuse', self.OTHER) 653 | self.assertEqual('uncommitted', utils.read_file(self.MASTER_FILE)) 654 | self.assertEqual('uncommitted', utils.read_file('master_untracked')) 655 | 656 | def test_uncommitted_tracked_changes_that_conflict(self): 657 | utils.gl('branch', '-c', 'tmp', '--divergent-point', 'HEAD~1') 658 | utils.gl('switch', 'tmp') 659 | utils.write_file(self.MASTER_FILE, contents='uncommitted') 660 | self.assertRaisesRegexp( 661 | CalledProcessError, 'failed to apply', utils.gl, 'fuse', 662 | 'master', '--insertion-point', 'HEAD') 663 | contents = utils.read_file(self.MASTER_FILE) 664 | self.assertTrue('uncommitted' in contents) 665 | self.assertTrue('contents 2' in contents) 666 | 667 | def test_uncommitted_tracked_changes_that_conflict_append(self): 668 | utils.gl('branch', '-c', 'tmp', '--divergent-point', 'HEAD~1') 669 | utils.gl('switch', 'tmp') 670 | utils.append_to_file(self.MASTER_FILE, contents='uncommitted') 671 | self.assertRaisesRegexp( 672 | CalledProcessError, 'failed to apply', utils.gl, 'fuse', 673 | 'master', '--insertion-point', 'HEAD') 674 | contents = utils.read_file(self.MASTER_FILE) 675 | self.assertTrue('uncommitted' in contents) 676 | self.assertTrue('contents 2' in contents) 677 | 678 | # def test_uncommitted_untracked_changes_that_conflict(self): 679 | # utils.write_file(self.OTHER_FILE, contents='uncommitted in master') 680 | # try: 681 | # utils.gl('fuse', self.OTHER) 682 | # self.fail() 683 | # except CalledProcessError as e: 684 | # self.assertTrue('failed to apply' in utils.stderr(e)) 685 | 686 | 687 | class TestMerge(TestOp): 688 | 689 | def test_uncommitted_changes(self): 690 | utils.write_file(self.MASTER_FILE, contents='uncommitted') 691 | utils.write_file('master_untracked', contents='uncommitted') 692 | utils.gl('merge', self.OTHER) 693 | self.assertEqual('uncommitted', utils.read_file(self.MASTER_FILE)) 694 | self.assertEqual('uncommitted', utils.read_file('master_untracked')) 695 | 696 | def test_uncommitted_tracked_changes_that_conflict(self): 697 | utils.gl('branch', '-c', 'tmp', '--divergent-point', 'HEAD~1') 698 | utils.gl('switch', 'tmp') 699 | utils.write_file(self.MASTER_FILE, contents='uncommitted') 700 | self.assertRaisesRegexp( 701 | CalledProcessError, 'failed to apply', utils.gl, 'merge', 'master') 702 | contents = utils.read_file(self.MASTER_FILE) 703 | self.assertTrue('uncommitted' in contents) 704 | self.assertTrue('contents 2' in contents) 705 | 706 | def test_uncommitted_tracked_changes_that_conflict_append(self): 707 | utils.gl('branch', '-c', 'tmp', '--divergent-point', 'HEAD~1') 708 | utils.gl('switch', 'tmp') 709 | utils.append_to_file(self.MASTER_FILE, contents='uncommitted') 710 | self.assertRaisesRegexp( 711 | CalledProcessError, 'failed to apply', utils.gl, 'merge', 'master') 712 | contents = utils.read_file(self.MASTER_FILE) 713 | self.assertTrue('uncommitted' in contents) 714 | self.assertTrue('contents 2' in contents) 715 | 716 | 717 | class TestPerformance(TestEndToEnd): 718 | 719 | FPS_QTY = 10000 720 | 721 | def setUp(self): 722 | super(TestPerformance, self).setUp() 723 | for i in range(0, self.FPS_QTY): 724 | fp = 'f' + text(i) 725 | utils.write_file(fp, fp) 726 | 727 | def test_status_performance(self): 728 | def assert_status_performance(): 729 | # The test fails if `gl status` takes more than 100 times 730 | # the time `git status` took. 731 | MAX_TOLERANCE = 100 732 | 733 | t = time.time() 734 | utils.gl('status') 735 | gl_t = time.time() - t 736 | 737 | t = time.time() 738 | utils.git('status') 739 | git_t = time.time() - t 740 | 741 | self.assertTrue( 742 | gl_t < git_t*MAX_TOLERANCE, 743 | msg='gl_t {0}, git_t {1}'.format(gl_t, git_t)) 744 | 745 | # All files are untracked 746 | assert_status_performance() 747 | # Track all files, repeat 748 | logging.info('Doing a massive git add, this might take a while') 749 | utils.git('add', '.') 750 | logging.info('Done') 751 | assert_status_performance() 752 | 753 | def test_branch_switch_performance(self): 754 | MAX_TOLERANCE = 100 755 | 756 | utils.gl('commit', 'f1', '-m', 'commit') 757 | 758 | t = time.time() 759 | utils.gl('branch', '-c', 'develop') 760 | utils.gl('switch', 'develop') 761 | gl_t = time.time() - t 762 | 763 | # go back to previous state 764 | utils.gl('switch', 'master') 765 | 766 | # do the same for git 767 | t = time.time() 768 | utils.git('branch', 'gitdev') 769 | utils.git('stash', 'save', '--all') 770 | utils.git('checkout', 'gitdev') 771 | git_t = time.time() - t 772 | 773 | self.assertTrue( 774 | gl_t < git_t*MAX_TOLERANCE, 775 | msg='gl_t {0}, git_t {1}'.format(gl_t, git_t)) 776 | -------------------------------------------------------------------------------- /gitless/tests/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Gitless - a version control system built on top of Git 3 | # Licensed under MIT 4 | 5 | """Utility library for tests.""" 6 | 7 | 8 | import io 9 | from locale import getpreferredencoding 10 | import logging 11 | import os 12 | import re 13 | import shutil 14 | import stat 15 | import sys 16 | import tempfile 17 | import unittest 18 | 19 | from subprocess import run, CalledProcessError 20 | 21 | 22 | ENCODING = getpreferredencoding() or 'utf-8' 23 | 24 | 25 | class TestBase(unittest.TestCase): 26 | 27 | def setUp(self, prefix_for_tmp_repo): 28 | """Creates temporary dir and cds to it.""" 29 | logging.basicConfig(stream=sys.stdout, level=logging.INFO) 30 | self.path = tempfile.mkdtemp(prefix=prefix_for_tmp_repo) 31 | logging.debug('Created temporary directory {0}'.format(self.path)) 32 | os.chdir(self.path) 33 | 34 | def tearDown(self): 35 | """Removes the temporary dir.""" 36 | rmtree(self.path) 37 | 38 | def assertRaisesRegexp(self, exc, r, fun, *args, **kwargs): 39 | try: 40 | fun(*args, **kwargs) 41 | self.fail('Exception not raised') 42 | except exc as e: 43 | msg = e.stderr if isinstance(e, CalledProcessError) else str(e) 44 | if not re.search(r, msg): 45 | self.fail('No "{0}" found in "{1}"'.format(r, msg)) 46 | 47 | 48 | def rmtree(path): 49 | # On Windows, running shutil.rmtree on a folder that contains read-only 50 | # files throws errors. To workaround this, if removing a path fails, we make 51 | # the path writable and then try again 52 | def onerror(func, path, unused_exc_info): # error handler for rmtree 53 | if not os.access(path, os.W_OK): 54 | os.chmod(path, stat.S_IWUSR) 55 | func(path) 56 | else: 57 | # Swallow errors for now (on Windows there seems to be something weird 58 | # going on and we can't remove the temp directory even after all files 59 | # in it have been successfully removed) 60 | pass 61 | 62 | shutil.rmtree(path, onerror=onerror) 63 | logging.debug('Removed dir {0}'.format(path)) 64 | 65 | 66 | def symlink(src, dst): 67 | try: 68 | os.symlink(src, dst) 69 | except (AttributeError, NotImplementedError, OSError): 70 | # Swallow the exceptions, because Windows is very weird about creating 71 | # symlinks. Python 2 does not have a symlink method on in the os module, 72 | # AttributeError will handle that. Python 3 does have a symlink method in 73 | # the os module, however, it has some quirks. NotImplementedError handles 74 | # the case where the Windows version is prior to Vista. OSError handles the 75 | # case where python doesn't have permissions to create a symlink on 76 | # windows. In all cases, it's not necessary to test this, so skip it. 77 | # See: https://docs.python.org/3.5/library/os.html#os.symlink and 78 | # https://docs.python.org/2.7/library/os.html#os.symlink for full details. 79 | pass 80 | 81 | 82 | def write_file(fp, contents=''): 83 | _x_file('w', fp, contents=contents) 84 | 85 | 86 | def append_to_file(fp, contents=''): 87 | _x_file('a', fp, contents=contents) 88 | 89 | 90 | def set_test_config(): 91 | git('config', 'user.name', 'test') 92 | git('config', 'user.email', 'test@test.com') 93 | 94 | 95 | def read_file(fp): 96 | with io.open(fp, mode='r', encoding=ENCODING) as f: 97 | ret = f.read() 98 | return ret 99 | 100 | 101 | def git(*args, cwd=None, _in=None): 102 | p = run( 103 | ['git', '--no-pager', *args], capture_output=True, check=True, cwd=cwd, 104 | input=_in, encoding=ENCODING) 105 | return p.stdout 106 | 107 | 108 | def gl(*args, cwd=None, _in=None): 109 | p = run( 110 | ['gl', *args], capture_output=True, check=True, cwd=cwd, 111 | input=_in, encoding=ENCODING) 112 | return p.stdout 113 | 114 | 115 | # Private functions 116 | 117 | 118 | def _x_file(x, fp, contents=''): 119 | if not contents: 120 | contents = fp 121 | dirs, _ = os.path.split(fp) 122 | if dirs and not os.path.exists(dirs): 123 | os.makedirs(dirs) 124 | with io.open(fp, mode=x, encoding=ENCODING) as f: 125 | f.write(contents) 126 | -------------------------------------------------------------------------------- /gl.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # Gitless - a version control system built on top of Git 4 | # Licensed under MIT 5 | 6 | # This file is for PyInstaller 7 | 8 | import sys 9 | 10 | from gitless.cli import gl 11 | 12 | 13 | if __name__ == '__main__': 14 | sys.exit(gl.main()) 15 | -------------------------------------------------------------------------------- /gl.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python -*- 2 | import os 3 | 4 | a = Analysis(['gl.py'], 5 | pathex=[os.getcwd()], 6 | hiddenimports=[ 7 | # https://github.com/pyinstaller/pyinstaller/issues/3198 8 | # remove this when dropping support for Python < 3.7 9 | '_sysconfigdata', 10 | '_cffi_backend'], 11 | hookspath=None, 12 | runtime_hooks=None) 13 | 14 | 15 | pyz = PYZ(a.pure) 16 | exe = EXE(pyz, 17 | a.scripts, 18 | a.binaries, 19 | a.zipfiles, 20 | a.datas, 21 | name='gl', 22 | debug=False, 23 | strip=None, 24 | upx=True, 25 | console=True ) 26 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # make sure to update setup.py if you make any changes to this file 2 | 3 | argcomplete>=1.11.1 4 | pygit2==1.4.0 # requires libgit2 1.1.x 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | 5 | import ast 6 | import re 7 | import sys 8 | 9 | from setuptools import setup 10 | 11 | 12 | _version_re = re.compile(r'__version__\s+=\s+(.*)') 13 | 14 | 15 | with open('gitless/cli/gl.py', 'rb') as f: 16 | version = str(ast.literal_eval(_version_re.search( 17 | f.read().decode('utf-8')).group(1))) 18 | 19 | 20 | # Build helper 21 | if sys.argv[-1] == 'gl-build': 22 | from subprocess import run 23 | import shutil 24 | import tarfile 25 | import platform 26 | 27 | rel = 'gl-v{0}-{1}-{2}'.format( 28 | version, platform.system().lower(), platform.machine()) 29 | 30 | print('running pyinstaller...') 31 | run( 32 | ['pyinstaller', 'gl.spec', '--clean', '--distpath', rel], 33 | stdout=sys.stdout, stderr=sys.stderr) 34 | print('success!! gl binary should be at {0}/gl'.format(rel)) 35 | 36 | print('creating tar.gz file') 37 | shutil.copy('README.md', rel) 38 | shutil.copy('LICENSE.md', rel) 39 | 40 | with tarfile.open(rel + '.tar.gz', 'w:gz') as tar: 41 | tar.add(rel) 42 | print('success!! binary release at {0}'.format(rel + '.tar.gz')) 43 | 44 | sys.exit() 45 | 46 | 47 | ld = """ 48 | Gitless is a version control system built on top of Git, that is easy to learn 49 | and use. It features a simple commit workflow, independent branches, and 50 | a friendly command-line interface. Because Gitless is implemented on top of 51 | Git, you can always fall back on Git. And your coworkers you share a repo with 52 | need never know that you're not a Git aficionado. 53 | 54 | More info, downloads and documentation @ `Gitless's 55 | website `__. 56 | """ 57 | 58 | setup( 59 | name='gitless', 60 | version=version, 61 | description='A simple version control system built on top of Git', 62 | long_description=ld, 63 | author='Santiago Perez De Rosso', 64 | author_email='sperezde@csail.mit.edu', 65 | url='https://gitless.com', 66 | packages=['gitless', 'gitless.cli'], 67 | install_requires=[ 68 | # make sure install_requires is consistent with requirements.txt 69 | 'pygit2==1.4.0', # requires libgit2 1.1.x 70 | 'argcomplete>=1.11.1' 71 | ], 72 | license='MIT', 73 | classifiers=[ 74 | 'Development Status :: 2 - Pre-Alpha', 75 | 'Intended Audience :: Developers', 76 | 'License :: OSI Approved :: MIT License', 77 | 'Natural Language :: English', 78 | 'Programming Language :: Python', 79 | 'Topic :: Software Development :: Version Control'], 80 | entry_points={ 81 | 'console_scripts': [ 82 | 'gl = gitless.cli.gl:main' 83 | ]}, 84 | test_suite='gitless.tests') 85 | -------------------------------------------------------------------------------- /snap/snapcraft.yaml: -------------------------------------------------------------------------------- 1 | # obligatory fields 2 | 3 | name: gitless 4 | version: git 5 | summary: A simple version control system built on top of Git 6 | description: | 7 | Gitless is a version control system built on top of Git, that is easy to learn 8 | and use. It features a simple commit workflow, independent branches, and 9 | a friendly command-line interface. Because Gitless is implemented on top of 10 | Git, you can always fall back on Git. And your coworkers you share a repo with 11 | need never know that you're not a Git aficionado. 12 | 13 | 14 | # Base snap for snapd that is based on Ubuntu 18.04 15 | base: core18 16 | 17 | grade: devel # 'stable' for stable/candidate upload 18 | confinement: devmode # 'strict' after right plugs and slots 19 | 20 | # 'optional' fields 21 | 22 | apps: 23 | gl: 24 | command: bin/gl 25 | 26 | parts: 27 | libgit2: 28 | plugin: cmake 29 | # https://www.pygit2.org/install.html#version-numbers 30 | source: https://github.com/libgit2/libgit2/archive/v1.1.0.tar.gz 31 | build-packages: 32 | - libssl-dev 33 | 34 | gitless-cli: 35 | plugin: python 36 | source: . 37 | after: [libgit2] 38 | # need git until https://github.com/sdg-mit/gitless/issues/176 39 | stage-packages: 40 | - git 41 | build-packages: 42 | - git 43 | --------------------------------------------------------------------------------