├── .gitignore ├── LICENSE ├── Makefile ├── README ├── bin ├── stupidgit └── stupidgit-askpass ├── debian ├── changelog ├── compat ├── control ├── copyright ├── install ├── pycompat └── rules ├── icon ├── README ├── icon.icns ├── icon.ico ├── icon_16x16.png ├── icon_32x32.png └── icon_48x48.png ├── resources ├── commit.png ├── discard.png ├── fetch.png ├── modules.png ├── push.png ├── refresh.png ├── stupidgit.fbp ├── stupidgit.xrc └── switch.png ├── setup.iss ├── setup.py ├── setup └── osx │ ├── Info.plist │ ├── PkgInfo │ ├── StupidGit │ ├── StupidGit-askpass │ └── buildapp ├── stupidgit.desktop └── stupidgit_gui ├── AboutDialog.py ├── CommitList.py ├── Dialogs.py ├── DiffViewer.py ├── FetchDialogs.py ├── HiddenWindow.py ├── HistoryTab.py ├── IndexTab.py ├── MainWindow.py ├── PasswordDialog.py ├── PushDialogs.py ├── SwitchWizard.py ├── Wizard.py ├── __init__.py ├── git.py ├── platformspec.py ├── run.py ├── util.py └── wxutil.py /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | dist/* 3 | build/* 4 | Output/* 5 | tmp/* 6 | StupidGit.egg-info 7 | MANIFEST 8 | *.swp 9 | *.pyc 10 | debian/python-module-stampdir/ 11 | debian/stupidgit.debhelper.log 12 | debian/stupidgit.postinst.debhelper 13 | debian/stupidgit.prerm.debhelper 14 | debian/stupidgit.substvars 15 | debian/stupidgit/ 16 | debian/files 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 Ákos Gyimesi 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included 12 | in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PYTHON=`which python` 2 | DESTDIR=/ 3 | BUILDIR=$(CURDIR)/debian/stupidgit 4 | PROJECT=stupidgit 5 | VERSION=0.1.0 6 | 7 | all: 8 | @echo "make source - Create source package" 9 | @echo "make install - Install on local system" 10 | @echo "make buildrpm - Generate a rpm package" 11 | @echo "make builddeb - Generate a deb package" 12 | @echo "make clean - Get rid of scratch and byte files" 13 | 14 | source: 15 | $(PYTHON) setup.py sdist $(COMPILE) 16 | 17 | install: 18 | $(PYTHON) setup.py install --root $(DESTDIR) $(COMPILE) 19 | 20 | buildapp: 21 | setup/osx/buildapp 22 | 23 | buildrpm: 24 | $(PYTHON) setup.py bdist_rpm --post-install=rpm/postinstall --pre-uninstall=rpm/preuninstall 25 | 26 | builddeb: 27 | # build the source package in the parent directory 28 | # then rename it to project_version.orig.tar.gz 29 | $(PYTHON) setup.py sdist $(COMPILE) --dist-dir=../ 30 | rename -f 's/$(PROJECT)-(.*)\.tar\.gz/$(PROJECT)_$$1\.orig\.tar\.gz/' ../* 31 | # build the package 32 | dpkg-buildpackage -i -I -rfakeroot 33 | 34 | clean: 35 | $(PYTHON) setup.py clean 36 | $(MAKE) -f $(CURDIR)/debian/rules clean 37 | rm -rf build/ MANIFEST 38 | find . -name '*.pyc' -delete 39 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | The easiest way to install StupidGit is to download one of its binary releases: 5 | http://github.com/gyim/stupidgit/downloads 6 | 7 | If you want to run the latest, unreleased version of StupidGit, simply clone 8 | the StupidGit repository to any place you want. You can either run StupidGit 9 | from this directory or create a binary release with one of the following 10 | commands: 11 | 12 | - Windows: 13 | - setup.py py2exe 14 | - setup.iss (press F9 and then Ctrl-F9 to create a setup package) 15 | - Ubuntu Linux: make builddeb 16 | - Mac OS X: make buildapp 17 | - Other systems: python setup.py install 18 | 19 | For creating binary releases you will need to install Python >=2.5, wxPython, 20 | setuptools and py2exe+InnoSetup/debhelper scripts/py2app, respectively. 21 | 22 | Running StupidGit 23 | ================= 24 | 25 | To run StupidGit, you will need the following packages: 26 | - Python >= 2.5 27 | - Windows: http://www.python.org/download/ 28 | - Ubuntu Linux: installed by default 29 | - OS X: installed by default 30 | - wxPython >= 2.8 31 | - Windows: http://www.wxpython.org/download.php 32 | - Ubuntu Linux: apt-get install python-wxgtk2.8 33 | - OS X Leopard: installed by default. 34 | - OS X Snow Leopard: installed by default, but you should make sure 35 | that Python is running in 32 bit mode. 36 | - Git >= 1.6 37 | ... if this is not installed yet, you probably don't need this program ;) 38 | StupidGit is tested with the standard git-core package on Ubuntu, 39 | the MacPorts git-core package on OS X and msysgit on Windows. 40 | 41 | StupidGit will search git binary on the following locations: 42 | - PATH 43 | - on Unix platforms: /opt/local/bin (for MacPorts), /usr/local/git/bin (OSX build) 44 | - on Windows: C:\Program Files\Git\bin (default path for msysgit) 45 | 46 | On unix systems it is useful to create a symlink to 47 | /bin/stupidgit from a directory which is in the PATH. If you 48 | invoke stupidgit from a directory which is inside a git repository, it will 49 | open that repo by default (just as gitk does). 50 | 51 | External merge tools: 52 | ===================== 53 | 54 | It is advised to install an external merge tool for StupidGit. Currently the 55 | following tools are supported: 56 | 57 | - Windows: WinMerge 58 | - OS X: DiffMerge 59 | - Unix (including OS X): meld 60 | 61 | Merge tools are searched in PATH and in their default installation 62 | directories. 63 | 64 | Development 65 | =========== 66 | 67 | You are welcome to contribute to this project! See the wiki on GitHub for 68 | details. 69 | 70 | -------------------------------------------------------------------------------- /bin/stupidgit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 3 | 4 | import sys 5 | from os.path import realpath, dirname, join, exists 6 | 7 | # For development version: put .. into module path 8 | try: 9 | dir = dirname(realpath(__file__)) 10 | if exists(join(dir, '..', 'stupidgit_gui', 'run.py')): 11 | sys.path.insert(0, realpath(join(dir, '..'))) 12 | except NameError: 13 | # we are in py2exe => no __file__ 14 | pass 15 | 16 | # Run stupidgit 17 | import stupidgit_gui.run 18 | stupidgit_gui.run.main() 19 | -------------------------------------------------------------------------------- /bin/stupidgit-askpass: -------------------------------------------------------------------------------- 1 | stupidgit -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- 1 | stupidgit (0.1.1) unstable; urgency=low 2 | 3 | * Added /usr/local/git/bin to default git paths 4 | 5 | -- Akos Gyimesi Fri, 20 Nov 2009 11:39:52 +0100 6 | 7 | stupidgit (0.1.0) unstable; urgency=low 8 | 9 | * Initial release. 10 | 11 | -- Akos Gyimesi Fri, 06 Nov 2009 00:05:02 +0100 12 | -------------------------------------------------------------------------------- /debian/compat: -------------------------------------------------------------------------------- 1 | 7 2 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: stupidgit 2 | Section: devel 3 | Priority: optional 4 | Maintainer: Akos Gyimesi (gyim) 5 | Build-Depends: debhelper (>=7.0.0), python-support (>= 0.6), cdbs (>= 0.4.49), python-all-dev 6 | XS-Python-Version: >=2.5 7 | Standards-Version: 3.8.1 8 | 9 | Package: stupidgit 10 | Architecture: all 11 | Homepage: http://github.com/gyim/stupidgit 12 | XB-Python-Version: ${python:Versions} 13 | Depends: ${misc:Depends}, ${python:Depends}, git-core (>= 1.6.0), python-wxgtk2.8, meld (>= 1.2) 14 | Description: A cross-platform git GUI with strong support for submodules 15 | StupidGit is a simple but powerful git GUI with minimalistic look 16 | and strong support for submodules. Its features include: 17 | - History graph with diff viewer 18 | - Index editor (stage/unstage changes) 19 | - Commit / amend commit 20 | - Easy-to-use switch tool that also switches submodule versions if asked 21 | - Merge, resolve conflicts with external merge tool (meld) 22 | - Cherry-pick, revert 23 | -------------------------------------------------------------------------------- /debian/copyright: -------------------------------------------------------------------------------- 1 | This package was debianized by Akos Gyimesi on 2 | Tue, 3 Nov 2009 22:45:14 +0100. 3 | 4 | It was downloaded from: http://github.com/gyim/stupidgit 5 | 6 | Upstream author: Akos Gyimesi 7 | 8 | Copyright (c) 2009 Ákos Gyimesi 9 | 10 | Permission is hereby granted, free of charge, to any person obtaining 11 | a copy of this software and associated documentation files (the 12 | "Software"), to deal in the Software without restriction, including 13 | without limitation the rights to use, copy, modify, merge, publish, 14 | distribute, sublicense, and/or sell copies of the Software, and to 15 | permit persons to whom the Software is furnished to do so, subject to 16 | the following conditions: 17 | 18 | The above copyright notice and this permission notice shall be included 19 | in all copies or substantial portions of the Software. 20 | 21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 22 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 23 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 24 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 25 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 26 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 27 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 28 | -------------------------------------------------------------------------------- /debian/install: -------------------------------------------------------------------------------- 1 | resources/* usr/share/stupidgit/ 2 | -------------------------------------------------------------------------------- /debian/pycompat: -------------------------------------------------------------------------------- 1 | 2 2 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | # -*- makefile -*- 3 | 4 | DEB_PYTHON_SYSTEM := pysupport 5 | 6 | include /usr/share/cdbs/1/rules/debhelper.mk 7 | include /usr/share/cdbs/1/class/python-distutils.mk 8 | 9 | clean:: 10 | rm -rf build build-stamp configure-stamp build/ MANIFEST 11 | dh_clean 12 | -------------------------------------------------------------------------------- /icon/README: -------------------------------------------------------------------------------- 1 | These icons are copied from the official git GUI, and therefore 2 | they are subject to the General Public License v2. 3 | 4 | You can download the original version from here: 5 | git://git.kernel.org/pub/scm/git/git.git 6 | -------------------------------------------------------------------------------- /icon/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gyim/stupidgit/2364ed3ec3a9ca5fed315bd0b79bdccf47ec5ff1/icon/icon.icns -------------------------------------------------------------------------------- /icon/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gyim/stupidgit/2364ed3ec3a9ca5fed315bd0b79bdccf47ec5ff1/icon/icon.ico -------------------------------------------------------------------------------- /icon/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gyim/stupidgit/2364ed3ec3a9ca5fed315bd0b79bdccf47ec5ff1/icon/icon_16x16.png -------------------------------------------------------------------------------- /icon/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gyim/stupidgit/2364ed3ec3a9ca5fed315bd0b79bdccf47ec5ff1/icon/icon_32x32.png -------------------------------------------------------------------------------- /icon/icon_48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gyim/stupidgit/2364ed3ec3a9ca5fed315bd0b79bdccf47ec5ff1/icon/icon_48x48.png -------------------------------------------------------------------------------- /resources/commit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gyim/stupidgit/2364ed3ec3a9ca5fed315bd0b79bdccf47ec5ff1/resources/commit.png -------------------------------------------------------------------------------- /resources/discard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gyim/stupidgit/2364ed3ec3a9ca5fed315bd0b79bdccf47ec5ff1/resources/discard.png -------------------------------------------------------------------------------- /resources/fetch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gyim/stupidgit/2364ed3ec3a9ca5fed315bd0b79bdccf47ec5ff1/resources/fetch.png -------------------------------------------------------------------------------- /resources/modules.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gyim/stupidgit/2364ed3ec3a9ca5fed315bd0b79bdccf47ec5ff1/resources/modules.png -------------------------------------------------------------------------------- /resources/push.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gyim/stupidgit/2364ed3ec3a9ca5fed315bd0b79bdccf47ec5ff1/resources/push.png -------------------------------------------------------------------------------- /resources/refresh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gyim/stupidgit/2364ed3ec3a9ca5fed315bd0b79bdccf47ec5ff1/resources/refresh.png -------------------------------------------------------------------------------- /resources/stupidgit.xrc: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 600,650 6 | StupidGit 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | -1,-1 103 | 1 104 | 5 105 | 106 | 107 | 108 | 109 | 110 | switch.png 111 | 112 | 113 | 114 | 115 | 116 | 117 | commit.png 118 | 119 | 120 | 121 | 122 | 123 | discard.png 124 | 125 | 126 | 127 | 128 | 129 | 130 | fetch.png 131 | 132 | 133 | 134 | 135 | 136 | push.png 137 | 138 | 139 | 140 | 141 | 142 | 143 | refresh.png 144 | 145 | 146 | 147 | 148 | 149 | 0 150 | 151 | 152 | 153 | wxVERTICAL 154 | 155 | 156 | wxEXPAND 157 | 5 158 | 159 | 160 | 200 161 | 0 162 | 0 163 | horizontal 164 | 165 | 166 | 167 | wxVERTICAL 168 | 169 | 170 | 171 | 172 | 173 | wxVERTICAL 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 0 184 | 185 | 186 | 187 | wxVERTICAL 188 | 189 | 190 | wxEXPAND 191 | 5 192 | 193 | 194 | 200 195 | 0 196 | 0 197 | horizontal 198 | 199 | 200 | 201 | wxHORIZONTAL 202 | 203 | 204 | wxEXPAND 205 | 5 206 | 207 | wxVERTICAL 208 | 209 | 210 | 211 | 212 | 213 | wxEXPAND | wxALL 214 | 5 215 | 216 | 217 | 218 | wxVERTICAL 219 | 220 | 221 | wxALIGN_CENTER_HORIZONTAL|wxALL|wxEXPAND 222 | 5 223 | 224 | 225 | 0 226 | 227 | 228 | 229 | 230 | wxALIGN_CENTER_HORIZONTAL|wxALL|wxEXPAND 231 | 5 232 | 233 | 234 | 0 235 | 236 | 237 | 238 | 239 | 240 | 5 241 | 0,20 242 | 243 | 244 | 245 | wxALIGN_CENTER_HORIZONTAL|wxALL|wxEXPAND 246 | 5 247 | 248 | 249 | 0 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | wxEXPAND 258 | 5 259 | 260 | wxVERTICAL 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | wxVERTICAL 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 1 281 | 282 | 283 | 284 | 285 | 400,250 286 | Push Commit... 287 | 288 | wxVERTICAL 289 | 290 | 291 | wxEXPAND | wxALL 292 | 10 293 | 294 | 295 | 296 | wxVERTICAL 297 | 298 | 299 | wxEXPAND 300 | 5 301 | 302 | wxHORIZONTAL 303 | 304 | 305 | wxALIGN_CENTER_VERTICAL|wxALL 306 | 5 307 | 308 | -1,20 309 | 310 | 311 | 312 | 313 | 314 | wxALIGN_CENTER_VERTICAL|wxALL 315 | 5 316 | 317 | 0 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | wxEXPAND 326 | 5 327 | 328 | wxHORIZONTAL 329 | 330 | 331 | wxALIGN_CENTER_VERTICAL|wxALL 332 | 5 333 | 334 | -1,20 335 | 336 | 337 | 338 | 339 | 340 | wxALIGN_CENTER_VERTICAL|wxALL 341 | 5 342 | 343 | 0 344 | 345 | 346 | 347 | 348 | 349 | wxALIGN_CENTER_VERTICAL|wxALL 350 | 5 351 | 352 | 353 | 0 354 | 355 | 356 | 357 | 358 | 359 | 360 | wxALL 361 | 5 362 | 363 | 364 | 0 365 | 366 | 367 | 368 | 369 | wxEXPAND 370 | 5 371 | 372 | wxHORIZONTAL 373 | 374 | 375 | wxALIGN_CENTER_VERTICAL|wxALL 376 | 5 377 | 378 | -1,20 379 | 380 | 381 | 382 | 383 | 384 | wxALL 385 | 10 386 | 387 | 388 | 0 389 | 390 | 391 | 392 | 393 | 394 | 395 | wxEXPAND 396 | 5 397 | 0,10 398 | 399 | 400 | 401 | wxEXPAND 402 | 5 403 | 404 | 405 | wxALIGN_CENTER_HORIZONTAL|wxALL 406 | 5 407 | 408 | 409 | 410 | 411 | 412 | wxALIGN_CENTER_HORIZONTAL|wxALL 413 | 5 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 300,150 428 | 429 | 430 | wxVERTICAL 431 | 432 | 433 | wxEXPAND | wxALL 434 | 10 435 | 436 | 437 | 438 | wxVERTICAL 439 | 440 | 441 | wxALL 442 | 5 443 | 444 | 445 | 446 | 447 | 448 | 449 | wxALL|wxEXPAND 450 | 5 451 | 452 | 453 | 100 454 | 0 455 | 456 | 457 | 458 | 459 | wxALIGN_RIGHT|wxALL 460 | 5 461 | 462 | 463 | 0 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | 500,300 474 | 475 | 476 | 477 | 478 | 479 | 480 | 481 | 482 | 483 | 484 | 485 | 486 | 487 | 488 | 489 | 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 | 498 | 499 | 500 | 501 | 502 | 503 | -------------------------------------------------------------------------------- /resources/switch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gyim/stupidgit/2364ed3ec3a9ca5fed315bd0b79bdccf47ec5ff1/resources/switch.png -------------------------------------------------------------------------------- /setup.iss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gyim/stupidgit/2364ed3ec3a9ca5fed315bd0b79bdccf47ec5ff1/setup.iss -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | py2app/py2exe build script for stupidgit. 3 | 4 | Usage (Mac OS X): 5 | python setup.py py2app 6 | 7 | Usage (Windows): 8 | python setup.py py2exe 9 | """ 10 | import sys 11 | import os 12 | import distutils.core 13 | import distutils.command.install 14 | 15 | if sys.platform == 'darwin': 16 | from setuptools import setup 17 | else: 18 | from distutils.core import setup 19 | 20 | if sys.platform == 'darwin': 21 | extra_options = dict( 22 | setup_requires=['py2app'], 23 | app=['stupidgit_gui/run.py'], 24 | # Cross-platform applications generally expect sys.argv to 25 | # be used for opening files. 26 | options=dict( 27 | py2app=dict( 28 | packages='wx', 29 | site_packages=True, 30 | argv_emulation=True 31 | ) 32 | ), 33 | ) 34 | elif sys.platform == 'win32': 35 | import py2exe 36 | 37 | if sys.version >= '2.6': 38 | print "Due to a py2exe bug StupidGit can be built only from Python 2.5!" 39 | os.exit() 40 | 41 | # Workaround py2exe bug 42 | origIsSystemDLL = py2exe.build_exe.isSystemDLL 43 | 44 | def isSystemDLL(pathname): 45 | if os.path.basename(pathname).lower() in ("msvcp71.dll", "dwmapi.dll"): 46 | return 0 47 | return origIsSystemDLL(pathname) 48 | 49 | py2exe.build_exe.isSystemDLL = isSystemDLL 50 | 51 | extra_options = dict( 52 | setup_requires=['py2exe'], 53 | windows=[{ 54 | 'script': 'bin/stupidgit', 55 | 'icon_resources': [(0,'icon/icon.ico')], 56 | 'dest_base': 'stupidgit' 57 | }], 58 | ) 59 | elif os.name == 'posix': 60 | extra_options = dict( 61 | data_files = [ 62 | ('/usr/share/stupidgit', ['icon/icon_48x48.png']), 63 | ('/usr/share/applications', ['stupidgit.desktop']) 64 | ] 65 | ) 66 | else: 67 | extra_options=dict() 68 | 69 | setup( 70 | name='StupidGit', 71 | version='0.1.1', 72 | description='A cross-platform git GUI with strong support for submodules', 73 | author='Akos Gyimesi', 74 | author_email='gyimesi.akos@gmail.com', 75 | packages=['stupidgit_gui'], 76 | scripts=['bin/stupidgit'], 77 | 78 | **extra_options 79 | ) 80 | 81 | -------------------------------------------------------------------------------- /setup/osx/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | English 7 | CFBundleDisplayName 8 | StupidGit 9 | CFBundleDocumentTypes 10 | 11 | 12 | CFBundleTypeOSTypes 13 | 14 | **** 15 | fold 16 | disk 17 | 18 | CFBundleTypeRole 19 | Viewer 20 | 21 | 22 | CFBundleExecutable 23 | StupidGit 24 | CFBundleIconFile 25 | icon.icns 26 | CFBundleIdentifier 27 | com.gy-i-m.stupidgit 28 | CFBundleInfoDictionaryVersion 29 | 6.0 30 | CFBundleName 31 | StupidGit 32 | CFBundlePackageType 33 | APPL 34 | CFBundleShortVersionString 35 | 0.1.1 36 | CFBundleSignature 37 | ???? 38 | CFBundleVersion 39 | 0.1.1 40 | LSHasLocalizedDisplayName 41 | 42 | NSAppleScriptEnabled 43 | 44 | NSHumanReadableCopyright 45 | Copyright not specified 46 | NSMainNibFile 47 | MainMenu 48 | NSPrincipalClass 49 | NSApplication 50 | 51 | 52 | -------------------------------------------------------------------------------- /setup/osx/PkgInfo: -------------------------------------------------------------------------------- 1 | APPL???? -------------------------------------------------------------------------------- /setup/osx/StupidGit: -------------------------------------------------------------------------------- 1 | #!/System/Library/Frameworks/Python.framework/Versions/2.5/Resources/Python.app/Contents/MacOS/Python 2 | import sys, os 3 | execdir = os.path.dirname(sys.argv[0]) 4 | executable = os.path.join(execdir, "Python") 5 | resdir = os.path.join(os.path.dirname(execdir), "Resources") 6 | libdir = os.path.join(os.path.dirname(execdir), "Frameworks") 7 | mainprogram = os.path.join(resdir, "lib", "stupidgit_gui", "run.py") 8 | 9 | sys.argv.insert(1, mainprogram) 10 | pypath = os.getenv("PYTHONPATH", "") 11 | if pypath: 12 | pypath = ":" + pypath 13 | os.environ["PYTHONPATH"] = resdir + pypath 14 | os.environ["PYTHONEXECUTABLE"] = executable 15 | os.environ["DYLD_LIBRARY_PATH"] = libdir 16 | os.environ["DYLD_FRAMEWORK_PATH"] = libdir 17 | os.environ['STUPIDGIT_RESOURCES'] = resdir 18 | os.execve(executable, sys.argv, os.environ) 19 | -------------------------------------------------------------------------------- /setup/osx/StupidGit-askpass: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2.5 2 | 3 | import os 4 | import os.path 5 | import sys 6 | 7 | # Setup directories 8 | resource_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'Resources')) 9 | os.environ['STUPIDGIT_RESOURCES'] = resource_dir 10 | 11 | module_dir = os.path.join(resource_dir, 'lib') 12 | sys.path.append(module_dir) 13 | 14 | # Run StupidGit 15 | from stupidgit_gui.run import main 16 | main() 17 | -------------------------------------------------------------------------------- /setup/osx/buildapp: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ ! -d stupidgit_gui ] 4 | then 5 | echo "You must run buildapp from stupidgit root directory!" 6 | exit 1 7 | fi 8 | 9 | if [ -d dist ] 10 | then 11 | rm -rf dist/StupidGit.app 12 | else 13 | mkdir dist 14 | fi 15 | 16 | # Create app directories 17 | appdir=dist/StupidGit.app/Contents/ 18 | bindir=$appdir/MacOS 19 | resource_dir=$appdir/Resources 20 | 21 | mkdir -p $bindir 22 | mkdir -p $resource_dir/lib 23 | 24 | # Copy core files 25 | cp setup/osx/Info.plist setup/osx/PkgInfo $appdir/ 26 | cp setup/osx/StupidGit $bindir/ 27 | cp setup/osx/StupidGit-askpass $bindir/StupidGit-askpass 28 | ln -s /System/Library/Frameworks/Python.framework/Versions/2.5/Resources/Python.app/Contents/MacOS/Python $bindir/ 29 | cp -r stupidgit_gui $resource_dir/lib/stupidgit_gui 30 | rm -f $resource_dir/lib/stupidgit_gui/*.pyc 31 | 32 | # Copy resources 33 | cp icon/icon.icns $resource_dir/ 34 | resources="commit.png discard.png fetch.png push.png refresh.png switch.png stupidgit.xrc" 35 | for res in $resources 36 | do 37 | cp resources/$res $resource_dir/ 38 | done 39 | -------------------------------------------------------------------------------- /stupidgit.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Version=0.1 3 | Name=StupidGit 4 | Icon=/usr/share/stupidgit/icon_48x48.png 5 | Comment=Cross-platform git GUI with submodule support 6 | Exec=/usr/bin/stupidgit 7 | Terminal=false 8 | Type=Application 9 | Categories=GNOME;GTK;Development; 10 | StartupNotify=false 11 | Name[en_US]=StupidGit 12 | 13 | -------------------------------------------------------------------------------- /stupidgit_gui/AboutDialog.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | import wx 3 | 4 | STUPIDGIT_VERSION = "v0.1.1" 5 | 6 | license_text = u''' 7 | Copyright (c) 2009 Ákos Gyimesi 8 | 9 | Permission is hereby granted, free of charge, to 10 | any person obtaining a copy of this software and 11 | associated documentation files (the "Software"), 12 | to deal in the Software without restriction, 13 | including without limitation the rights to use, 14 | copy, modify, merge, publish, distribute, 15 | sublicense, and/or sell copies of the Software, 16 | and to permit persons to whom the Software is 17 | furnished to do so, subject to the following 18 | conditions: 19 | 20 | The above copyright notice and this permission 21 | notice shall be included in all copies or 22 | substantial portions of the Software. 23 | 24 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY 25 | OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT 26 | LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 27 | FITNESS FOR A PARTICULAR PURPOSE AND 28 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 29 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES 30 | OR OTHER LIABILITY, WHETHER IN AN ACTION OF 31 | CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF 32 | OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 33 | OTHER DEALINGS IN THE SOFTWARE. 34 | ''' 35 | 36 | def ShowAboutDialog(): 37 | info = wx.AboutDialogInfo() 38 | info.SetName("stupidgit") 39 | info.SetDescription("A cross-platform git GUI with strong submodule support.\n\nHomepage: http://github.com/gyim/stupidgit") 40 | info.SetVersion(STUPIDGIT_VERSION) 41 | info.SetCopyright(u"(c) Ákos Gyimesi, 2009.") 42 | info.SetLicense(license_text) 43 | 44 | wx.AboutBox(info) 45 | -------------------------------------------------------------------------------- /stupidgit_gui/CommitList.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import git 3 | import platformspec 4 | from util import * 5 | 6 | COLW = 12 # Column width 7 | LINH = 16 # Line height 8 | COMW = 8 # Commit width 9 | 10 | EDGE_COLORS = [ 11 | ( 0, 0, 96, 200), 12 | ( 0, 96, 0, 200), 13 | ( 96, 0, 0, 200), 14 | 15 | ( 64, 64, 0, 200), 16 | ( 64, 0, 64, 200), 17 | ( 0, 64, 64, 200), 18 | 19 | (128, 192, 0, 200), 20 | (192, 128, 0, 200), 21 | ( 64, 0, 128, 200), 22 | ( 0, 160, 96, 200), 23 | ( 0, 96, 160, 200) 24 | ] 25 | 26 | class CommitList(wx.ScrolledWindow): 27 | def __init__(self, parent, id, allowMultiple=False): 28 | wx.ScrolledWindow.__init__(self, parent, -1, style=wx.SUNKEN_BORDER) 29 | self.SetBackgroundColour('WHITE') 30 | self.Bind(wx.EVT_PAINT, self.OnPaint) 31 | self.Bind(wx.EVT_LEFT_DOWN, self.OnLeftClick) 32 | self.Bind(wx.EVT_LEFT_UP, self.OnLeftRelease) 33 | self.Bind(wx.EVT_RIGHT_DOWN, self.OnRightClick) 34 | self.Bind(wx.EVT_KEY_DOWN, self.OnKeyPressed) 35 | self.Bind(wx.EVT_MOTION, self.OnMouseMove) 36 | self.Bind(wx.EVT_LEAVE_WINDOW, self.OnMouseLeave) 37 | self.repo = None 38 | self.rows = [] 39 | self.selection = [] 40 | self.mainRepo = None 41 | self.mainRepoSelection = [] 42 | self.allowMultiple = allowMultiple 43 | self.authorColumnPos = 200 44 | 45 | self.normalCursor = wx.NullCursor 46 | self.resizeCursor = wx.StockCursor(wx.CURSOR_SIZEWE) 47 | self.currentCursor = self.normalCursor 48 | self.resizing = False 49 | 50 | def SetRepo(self, repo): 51 | # Save selection if the last repo was the main repo 52 | if self.repo and self.repo == self.mainRepo: 53 | self.mainRepo = self.repo 54 | self.mainRepoSelection = [ self.rows[row][0].commit.sha1 for row in self.selection ] 55 | 56 | # Clear selection, scroll to top 57 | repo_changed = (self.repo != repo) 58 | if repo_changed: 59 | self.selection = [] 60 | self.Scroll(0, 0) 61 | 62 | # Save main repo 63 | if not repo.parent: 64 | self.mainRepo = repo 65 | 66 | # Load commits 67 | self.repo = repo 68 | self.commits = self.repo.get_log(['--topo-order', '--all']) 69 | self.CreateLogGraph() 70 | 71 | # If this is a submodule, select versions that are referenced 72 | # by the parent module 73 | if repo_changed and self.repo != self.mainRepo: 74 | for version in self.mainRepoSelection: 75 | submodule_version = self.repo.parent.get_submodule_version(self.repo.name, version) 76 | if submodule_version: 77 | rows = [ r for r in self.rows if r[0].commit.sha1 == submodule_version ] 78 | if rows: 79 | self.selection.append(self.rows.index(rows[0])) 80 | 81 | # Setup UI 82 | self.SetVirtualSize((-1, (len(self.rows)+1) * LINH)) 83 | self.SetScrollRate(LINH, LINH) 84 | self.Refresh() 85 | 86 | def CreateLogGraph(self): 87 | rows = [] # items: (node, edges) 88 | nodes = {} # commit => GraphNode 89 | lanes = [] 90 | color = 0 91 | 92 | self.rows = rows 93 | self.columns = 0 94 | self.nodes = nodes 95 | 96 | for y in xrange(len(self.commits)): 97 | # 1. Create node 98 | commit = self.commits[y] 99 | node = GraphNode(commit) 100 | nodes[commit] = node 101 | node.y = y 102 | rows.append((node, [])) 103 | 104 | # 2. Determine column 105 | x = None 106 | 107 | # 2.1. search for a commit in lanes whose parent is c 108 | for i in xrange(len(lanes)): 109 | if lanes[i] and commit in lanes[i].commit.parents: 110 | x = i 111 | node.color = lanes[i].color 112 | break 113 | 114 | # 2.2. if there is no such commit, put to the first empty place 115 | if x == None: 116 | node.color = color 117 | color += 1 118 | if None in lanes: 119 | x = lanes.index(None) 120 | else: 121 | x = len(lanes) 122 | lanes.append(None) 123 | 124 | node.x = x 125 | self.columns = max(self.columns, x) 126 | 127 | # 3. Create edges 128 | for child_commit in commit.children: 129 | child = nodes[child_commit] 130 | edge = GraphEdge(node, child) 131 | node.child_edges.append(edge) 132 | child.parent_edges.append(edge) 133 | 134 | # 3.1. Determine edge style 135 | if child.x == node.x and lanes[x] == child: 136 | edge.style = EDGE_DIRECT 137 | edge.x = node.x 138 | edge.color = child.color 139 | elif len(child_commit.parents) == 1: 140 | edge.style = EDGE_BRANCH 141 | edge.x = child.x 142 | edge.color = child.color 143 | else: 144 | edge.style = EDGE_MERGE 145 | edge.color = node.color 146 | 147 | # Determine column for merge edges 148 | edge.x = max(node.x, child.x+1) 149 | success = False 150 | while not success: 151 | success = True 152 | for yy in xrange(node.y, child.y, -1): 153 | n, edges = rows[yy] 154 | if (yy < node.y and n.x == edge.x) or (len(edges) > edge.x and edges[edge.x] != None): 155 | edge.x += 1 156 | success = False 157 | break 158 | 159 | # 3.2. Register edge in rows 160 | for yy in xrange(node.y, child.y, -1): 161 | n, edges = rows[yy] 162 | if len(edges) < edge.x+1: 163 | edges += [None] * (edge.x+1 - len(edges)) 164 | edges[edge.x] = edge 165 | 166 | self.columns = max(self.columns, edge.x) 167 | 168 | # 4. End those lanes whose parents are already drawn 169 | for i in xrange(len(lanes)): 170 | if lanes[i] and len(lanes[i].parent_edges) == len(lanes[i].commit.parents): 171 | lanes[i] = None 172 | 173 | lanes[x] = node 174 | 175 | # References 176 | if self.repo.current_branch: 177 | self._add_reference(self.repo.head, self.repo.current_branch, REF_HEADBRANCH) 178 | else: 179 | self._add_reference(self.repo.head, 'DETACHED HEAD', REF_DETACHEDHEAD) 180 | 181 | if self.repo.main_ref: 182 | self._add_reference(self.repo.main_ref, 'MAIN/HEAD', REF_MODULE) 183 | if self.repo.main_merge_ref: 184 | self._add_reference(self.repo.main_merge_ref, 'MAIN/MERGE_HEAD', REF_MODULE) 185 | 186 | for branch,commit_id in self.repo.branches.iteritems(): 187 | if branch != self.repo.current_branch: 188 | self._add_reference(commit_id, branch, REF_BRANCH) 189 | for branch,commit_id in self.repo.remote_branches.iteritems(): 190 | self._add_reference(commit_id, branch, REF_REMOTE) 191 | for tag,commit_id in self.repo.tags.iteritems(): 192 | self._add_reference(commit_id, tag, REF_TAG) 193 | 194 | def _add_reference(self, commit_id, refname, reftype): 195 | if commit_id not in git.commit_pool: 196 | return 197 | 198 | commit = git.commit_pool[commit_id] 199 | if commit not in self.nodes: 200 | return 201 | 202 | self.nodes[commit].references.append((refname, reftype)) 203 | 204 | def OnPaint(self, evt): 205 | evt.Skip(False) 206 | 207 | if not self.repo: 208 | return 209 | 210 | # Setup drawing context 211 | pdc = wx.PaintDC(self) 212 | try: 213 | dc = wx.GCDC(pdc) 214 | except: 215 | dc = pdc 216 | 217 | dc.BeginDrawing() 218 | 219 | # Get basic drawing context details 220 | size = self.GetClientSize() 221 | clientWidth, clientHeight = size.GetWidth(), size.GetHeight() 222 | 223 | # Determine which commits to draw 224 | x, y, width, height = self.GetUpdateRegion().GetBox() 225 | start_x, start_y = self.CalcUnscrolledPosition(x, y) 226 | start_row, end_row = max(0, start_y/LINH-1), (start_y+height)/LINH+1 227 | 228 | # Setup pens, brushes and fonts 229 | commit_pen = wx.Pen(wx.Colour(0,0,0,255), width=2) 230 | commit_brush = wx.Brush(wx.Colour(255,255,255,255)) 231 | commit_font = platformspec.Font(12) 232 | 233 | commit_textcolor_normal = wx.SystemSettings_GetColour(wx.SYS_COLOUR_WINDOWTEXT) 234 | commit_textcolor_highlight = wx.SystemSettings_GetColour(wx.SYS_COLOUR_HIGHLIGHTTEXT) 235 | 236 | edge_pens = [ wx.Pen(wx.Colour(*c), width=2) for c in EDGE_COLORS ] 237 | 238 | background_pen = wx.NullPen 239 | background_brush = wx.Brush(wx.SystemSettings_GetColour(wx.SYS_COLOUR_WINDOW)) 240 | 241 | selection_pen = wx.NullPen 242 | selection_brush = wx.Brush(wx.SystemSettings_GetColour(wx.SYS_COLOUR_HIGHLIGHT)) 243 | 244 | separator_pen = wx.Pen(wx.SystemSettings_GetColour(wx.SYS_COLOUR_3DLIGHT), width=1) 245 | 246 | ref_pens = [ 247 | wx.Pen(wx.Colour(128,128,192,255), width=1), # REF_BRANCH 248 | wx.Pen(wx.Colour(0,255,0,255), width=1), # REF_REMOTE 249 | wx.Pen(wx.Colour(128,128,0,255), width=1), # REF_TAG 250 | wx.Pen(wx.Colour(255,128,128,255), width=1), # REF_HEADBRANCH 251 | wx.Pen(wx.Colour(255,0,0,255), width=1), # REF_DETACHEDHEAD 252 | wx.Pen(wx.Colour(160,160,160,255), width=1) # REF_MODULE 253 | ] 254 | ref_brushes = [ 255 | wx.Brush(wx.Colour(160,160,255,255)), # REF_BRANCH 256 | wx.Brush(wx.Colour(128,255,128,255)), # REF_REMOTE 257 | wx.Brush(wx.Colour(255,255,128,255)), # REF_TAG 258 | wx.Brush(wx.Colour(255,160,160,255)), # REF_HEADBRANCH 259 | wx.Brush(wx.Colour(255,128,128,255)), # REF_DETACHEDHEAD 260 | wx.Brush(wx.Colour(192,192,192,255)) # REF_MODULE 261 | ] 262 | ref_font = platformspec.Font(9) 263 | ref_textcolor = wx.Colour(0,0,0,255) 264 | 265 | # Draw selection 266 | dc.SetPen(selection_pen) 267 | dc.SetBrush(selection_brush) 268 | for row in self.selection: 269 | if start_row <= row <= end_row: 270 | x, y = self.CalcScrolledPosition(0, (row+1)*LINH) 271 | dc.DrawRectangle(0, y-LINH/2, clientWidth, LINH) 272 | 273 | # Offsets 274 | offx = COLW 275 | offy = LINH 276 | 277 | # Draw edges 278 | edges = set() 279 | for node,row_edges in self.rows[start_row:end_row+1]: 280 | edges.update(row_edges) 281 | for edge in edges: 282 | if not edge: continue 283 | 284 | dc.SetPen(edge_pens[edge.color % len(EDGE_COLORS)]) 285 | if edge.style == EDGE_DIRECT: 286 | x1, y1 = self.CalcScrolledPosition( edge.src.x*COLW+offx, edge.src.y*LINH+offy ) 287 | x2, y2 = self.CalcScrolledPosition( edge.dst.x*COLW+offx, edge.dst.y*LINH+offy ) 288 | dc.DrawLine(x1, y1, x2, y2) 289 | elif edge.style == EDGE_BRANCH: 290 | x1, y1 = self.CalcScrolledPosition( edge.src.x*COLW+offx, edge.src.y*LINH+offy ) 291 | x2, y2 = self.CalcScrolledPosition( edge.dst.x*COLW+offx, edge.src.y*LINH+offy-7 ) 292 | x3, y3 = self.CalcScrolledPosition( edge.dst.x*COLW+offx, edge.dst.y*LINH+offy ) 293 | dc.DrawLine(x1, y1, x2, y2) 294 | dc.DrawLine(x2, y2, x3, y3) 295 | elif edge.style == EDGE_MERGE: 296 | x1, y1 = self.CalcScrolledPosition( edge.src.x*COLW+offx, edge.src.y*LINH+offy ) 297 | x2, y2 = self.CalcScrolledPosition( edge.x*COLW+offx, edge.src.y*LINH+offy-7 ) 298 | x3, y3 = self.CalcScrolledPosition( edge.x*COLW+offx, edge.dst.y*LINH+offy+7 ) 299 | x4, y4 = self.CalcScrolledPosition( edge.dst.x*COLW+offx, edge.dst.y*LINH+offy ) 300 | dc.DrawLine(x1, y1, x2, y2) 301 | dc.DrawLine(x2, y2, x3, y3) 302 | dc.DrawLine(x3, y3, x4, y4) 303 | 304 | # Draw commits 305 | dc.SetPen(commit_pen) 306 | dc.SetBrush(commit_brush) 307 | for node,edges in self.rows[start_row:end_row+1]: 308 | # Background pen & brush 309 | if self.rows.index((node, edges)) in self.selection: 310 | commit_bg_pen = selection_pen 311 | commit_bg_brush = selection_brush 312 | else: 313 | commit_bg_pen = background_pen 314 | commit_bg_brush = background_brush 315 | 316 | # Draw commit circle/rectangle 317 | if node.style == NODE_MERGE: 318 | x = node.x*COLW + offx - COMW/2 319 | y = node.y*LINH + offy - COMW/2 320 | xx, yy = self.CalcScrolledPosition(x, y) 321 | dc.DrawRectangle(xx, yy, COMW, COMW) 322 | else: 323 | x = node.x*COLW + offx 324 | y = node.y*LINH + offy 325 | xx, yy = self.CalcScrolledPosition(x, y) 326 | dc.DrawCircle(xx, yy, COMW/2) 327 | 328 | # Calculate column 329 | if node.y < len(self.rows)-1: 330 | text_column = max(len(edges), len(self.rows[node.y+1][1])) 331 | else: 332 | text_column = len(edges) if len(edges) > 0 else 1 333 | 334 | # Draw references 335 | msg_offset = 0 336 | 337 | for refname,reftype in node.references: 338 | dc.SetPen(ref_pens[reftype]) 339 | dc.SetBrush(ref_brushes[reftype]) 340 | dc.SetFont(ref_font) 341 | dc.SetTextForeground(ref_textcolor) 342 | 343 | x = text_column*COLW + offx + msg_offset 344 | y = node.y*LINH + offy - LINH/2 + 1 345 | width,height = dc.GetTextExtent(refname) 346 | 347 | if reftype in [REF_HEADBRANCH, REF_DETACHEDHEAD, REF_MODULE]: 348 | points = [ 349 | (x, y+LINH/2-1), 350 | (x+6, y), 351 | (x+10 + width, y), 352 | (x+10 + width, y+LINH-3), 353 | (x+6, y+LINH-3) 354 | ] 355 | x += 6 356 | points = [ self.CalcScrolledPosition(*p) for p in points ] 357 | points = [ wx.Point(*p) for p in points ] 358 | 359 | dc.DrawPolygon(points) 360 | msg_offset += width+14 361 | else: 362 | xx, yy = self.CalcScrolledPosition(x, y) 363 | dc.DrawRoundedRectangle(xx, yy, width + 4, LINH-2, 2) 364 | msg_offset += width+8 365 | 366 | dc.SetPen(commit_pen) 367 | dc.SetBrush(commit_brush) 368 | xx, yy = self.CalcScrolledPosition(x+2, y+1) 369 | dc.DrawText(safe_unicode(refname), xx, yy) 370 | 371 | # Draw message 372 | dc.SetFont(commit_font) 373 | x = text_column*COLW + offx + msg_offset 374 | y = node.y*LINH + offy - LINH/2 375 | xx, yy = self.CalcScrolledPosition(x, y) 376 | 377 | if self.rows.index((node, edges)) in self.selection: 378 | dc.SetTextForeground(commit_textcolor_highlight) 379 | else: 380 | dc.SetTextForeground(commit_textcolor_normal) 381 | 382 | dc.DrawText(safe_unicode(node.commit.short_msg), xx, yy) 383 | 384 | # Draw author & date 385 | x = clientWidth - self.authorColumnPos 386 | 387 | dc.SetBrush(commit_bg_brush) 388 | 389 | dc.SetPen(commit_bg_pen) 390 | xx, yy = self.CalcScrolledPosition(x, y) 391 | dc.DrawRectangle(xx-4, yy, clientWidth-x+4, LINH) 392 | 393 | dc.SetPen(separator_pen) 394 | dc.DrawLine(xx, yy, xx, yy+LINH) 395 | 396 | dc.SetPen(commit_pen) 397 | dc.SetBrush(commit_brush) 398 | author_text = u'%s, %s' % (safe_unicode(node.commit.author_name), safe_unicode(node.commit.author_date)) 399 | dc.DrawText(author_text, xx+4, yy) 400 | 401 | dc.EndDrawing() 402 | 403 | def OnMouseMove(self, e): 404 | if self.resizing: 405 | self.authorColumnPos = self.GetClientSize().GetWidth() - e.m_x 406 | self.Refresh() 407 | else: 408 | pos = self.GetClientSize().GetWidth() - self.authorColumnPos 409 | if pos-2 <= e.m_x <= pos+2: 410 | if self.currentCursor != self.resizeCursor: 411 | self.SetCursor(self.resizeCursor) 412 | self.currentCursor = self.resizeCursor 413 | else: 414 | if self.currentCursor != self.normalCursor: 415 | self.SetCursor(self.normalCursor) 416 | self.currentCursor = self.normalCursor 417 | 418 | def OnLeftClick(self, e): 419 | e.StopPropagation() 420 | self.SetFocus() 421 | 422 | # Column resize 423 | if self.currentCursor == self.resizeCursor: 424 | self.resizing = True 425 | return 426 | 427 | # Determine row number 428 | x, y = self.CalcUnscrolledPosition(*(e.GetPosition())) 429 | row = self.RowNumberByCoords(x, y) 430 | if row == None: 431 | return 432 | 433 | # Handle different type of clicks 434 | old_selection = list(self.selection) 435 | if self.allowMultiple and e.ShiftDown() and len(old_selection) >= 1: 436 | from_row = old_selection[0] 437 | to_row = row 438 | if to_row >= from_row: 439 | self.selection = range(from_row, to_row+1) 440 | else: 441 | self.selection = range(to_row, from_row+1) 442 | self.selection.reverse() 443 | elif self.allowMultiple and (e.ControlDown() or e.CmdDown()): 444 | if row not in self.selection: 445 | self.selection.insert(0, row) 446 | else: 447 | self.selection = [row] 448 | 449 | # Emit click event 450 | event = CommitListEvent(EVT_COMMITLIST_SELECT_type, self.GetId()) 451 | event.SetCurrentRow(row) 452 | event.SetSelection(self.selection) 453 | self.ProcessEvent(event) 454 | self.OnSelectionChanged(row, self.selection) 455 | 456 | # Redraw window 457 | self.Refresh() 458 | 459 | def OnLeftRelease(self, e): 460 | e.StopPropagation() 461 | self.resizing = False 462 | 463 | def OnMouseLeave(self, e): 464 | e.StopPropagation() 465 | self.resizing = False 466 | 467 | def OnRightClick(self, e): 468 | e.StopPropagation() 469 | self.SetFocus() 470 | 471 | # Determine row number 472 | x, y = self.CalcUnscrolledPosition(*(e.GetPosition())) 473 | row = self.RowNumberByCoords(x, y) 474 | if row == None: 475 | return 476 | 477 | self.SelectRow(row) 478 | 479 | # Emit right click event 480 | event = CommitListEvent(EVT_COMMITLIST_RIGHTCLICK_type, self.GetId()) 481 | event.SetCurrentRow(row) 482 | event.SetSelection(self.selection) 483 | event.SetCoords( (e.GetX(), e.GetY()) ) 484 | self.ProcessEvent(event) 485 | self.OnRightButtonClicked(row, self.selection) 486 | 487 | def OnKeyPressed(self, e): 488 | key = e.GetKeyCode() 489 | 490 | # Handle only UP and DOWN keys 491 | if key not in [wx.WXK_UP, wx.WXK_DOWN] or len(self.rows) == 0: 492 | e.Skip() 493 | return 494 | 495 | e.StopPropagation() 496 | 497 | # Get scrolling position 498 | start_col, start_row = self.GetViewStart() 499 | size = self.GetClientSize() 500 | height = size.GetHeight() / LINH 501 | 502 | if self.selection: 503 | # Process up/down keys 504 | current_row = self.selection[0] 505 | 506 | if key == wx.WXK_UP: 507 | next_row = max(current_row-1, 0) 508 | if key == wx.WXK_DOWN: 509 | next_row = min(current_row+1, len(self.rows)-1) 510 | 511 | # Process modifiers 512 | if e.ShiftDown() and self.allowMultiple: 513 | if next_row in self.selection: 514 | self.selection.remove(current_row) 515 | else: 516 | self.selection.insert(0, next_row) 517 | else: 518 | self.selection = [next_row] 519 | 520 | else: 521 | # Select topmost row of current view 522 | next_row = start_row 523 | if next_row < 0 or next_row > len(self.rows): 524 | return 525 | 526 | self.selection = [next_row] 527 | 528 | # Scroll selection if necessary 529 | if next_row < start_row: 530 | self.Scroll(start_col, next_row-1) 531 | elif next_row > start_row + height - 1: 532 | self.Scroll(start_col, next_row-height+2) 533 | 534 | # Emit selection event 535 | event = CommitListEvent(EVT_COMMITLIST_SELECT_type, self.GetId()) 536 | event.SetCurrentRow(next_row) 537 | event.SetSelection(self.selection) 538 | self.ProcessEvent(event) 539 | self.OnSelectionChanged(next_row, self.selection) 540 | 541 | self.Refresh() 542 | 543 | def RowNumberByCoords(self, x, y): 544 | row = (y+LINH/2) / LINH - 1 545 | 546 | if row < 0 or row >= len(self.rows): 547 | return None 548 | else: 549 | return row 550 | 551 | def CommitByRow(self, row): 552 | return self.rows[row][0].commit 553 | 554 | def GotoCommit(self, commit_id): 555 | matching_commits = [c for c in self.commits if c.sha1.startswith(commit_id)] 556 | if len(matching_commits) == 0: 557 | return "Commit id '%s' cannot be found" % commit_id 558 | elif len(matching_commits) > 1: 559 | return "Given commit ID (%s) is ambiguous" % commit_id 560 | else: 561 | self.SelectRow(self.commits.index(matching_commits[0])) 562 | return None 563 | 564 | def SelectRow(self, row): 565 | self.selection = [row] 566 | 567 | # Emit selection event 568 | event = CommitListEvent(EVT_COMMITLIST_SELECT_type, self.GetId()) 569 | event.SetCurrentRow(row) 570 | event.SetSelection(self.selection) 571 | self.ProcessEvent(event) 572 | self.OnSelectionChanged(row, self.selection) 573 | 574 | # Scroll to position 575 | start_col, start_row = self.GetViewStart() 576 | size = self.GetClientSize() 577 | height = size.GetHeight() / LINH 578 | 579 | if row < start_row or row > start_row+height-1: 580 | self.Scroll(start_col, max(row-2, 0)) 581 | 582 | self.Refresh() 583 | 584 | # Virtual event handlers 585 | def OnSelectionChanged(self, row, selection): 586 | pass 587 | 588 | def OnRightButtonClicked(self, row, selection): 589 | pass 590 | 591 | EVT_COMMITLIST_SELECT_type = wx.NewEventType() 592 | EVT_COMMITLIST_SELECT = wx.PyEventBinder(EVT_COMMITLIST_SELECT_type, 1) 593 | 594 | EVT_COMMITLIST_RIGHTCLICK_type = wx.NewEventType() 595 | EVT_COMMITLIST_RIGHTCLICK = wx.PyEventBinder(EVT_COMMITLIST_RIGHTCLICK_type, 1) 596 | 597 | class CommitListEvent(wx.PyCommandEvent): 598 | def __init__(self, eventType, id): 599 | wx.PyCommandEvent.__init__(self, eventType, id) 600 | self.selection = None 601 | self.currentRow = None 602 | self.coords = (None, None) 603 | 604 | def GetCurrentRow(self): 605 | return self.currentRow 606 | 607 | def SetCurrentRow(self, currentRow): 608 | self.currentRow = currentRow 609 | 610 | def GetSelection(self): 611 | return self.selection 612 | 613 | def SetSelection(self, selection): 614 | self.selection = selection 615 | 616 | def SetCoords(self, coords): 617 | self.coords = coords 618 | 619 | def GetCoords(self): 620 | return self.coords 621 | 622 | NODE_NORMAL = 0 623 | NODE_BRANCH = 1 624 | NODE_MERGE = 2 625 | NODE_JUNCTION = 3 626 | 627 | REF_BRANCH = 0 628 | REF_REMOTE = 1 629 | REF_TAG = 2 630 | REF_HEADBRANCH = 3 631 | REF_DETACHEDHEAD = 4 632 | REF_MODULE = 5 633 | class GraphNode(object): 634 | def __init__(self, commit): 635 | self.commit = commit 636 | self.x = None 637 | self.y = None 638 | self.color = None 639 | 640 | self.parent_edges = [] 641 | self.child_edges = [] 642 | self.references = [] 643 | 644 | if len(commit.parents) > 1 and len(commit.children) > 1: 645 | self.style = NODE_JUNCTION 646 | elif len(commit.parents) > 1: 647 | self.style = NODE_MERGE 648 | elif len(commit.children) > 1: 649 | self.style = NODE_BRANCH 650 | else: 651 | self.style = NODE_NORMAL 652 | 653 | EDGE_DIRECT = 0 654 | EDGE_BRANCH = 1 655 | EDGE_MERGE = 2 656 | class GraphEdge(object): 657 | def __init__(self, src, dst): 658 | self.src = src 659 | self.dst = dst 660 | 661 | self.style = None 662 | self.color = None 663 | self.x = None 664 | self.color = None 665 | 666 | -------------------------------------------------------------------------------- /stupidgit_gui/Dialogs.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import wx.lib.mixins.listctrl as listmixins 3 | import git 4 | 5 | from DiffViewer import DiffViewer 6 | from IndexTab import MOD_DESCS 7 | from util import * 8 | 9 | class AutosizedListCtrl(wx.ListCtrl, listmixins.ListCtrlAutoWidthMixin): 10 | def __init__(self, parent, ID, pos=wx.DefaultPosition, 11 | size=wx.DefaultSize, style=0): 12 | wx.ListCtrl.__init__(self, parent, ID, pos, size, style) 13 | listmixins.ListCtrlAutoWidthMixin.__init__(self) 14 | 15 | class DiffDialog(wx.Dialog): 16 | def __init__(self, parent, id, title='', message=''): 17 | wx.Dialog.__init__(self, parent, id, size=(600,600), style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER) 18 | self.SetTitle(title) 19 | 20 | self.sizer = wx.BoxSizer(wx.VERTICAL) 21 | self.SetSizer(self.sizer) 22 | 23 | # Splitter 24 | self.splitter = wx.SplitterWindow(self, -1, style=wx.SP_LIVE_UPDATE) 25 | self.sizer.Add(self.splitter, True, wx.EXPAND, wx.ALL) 26 | 27 | self.topPanel = wx.Panel(self.splitter, -1) 28 | self.topSizer = wx.BoxSizer(wx.VERTICAL) 29 | self.topPanel.SetSizer(self.topSizer) 30 | 31 | self.bottomPanel = wx.Panel(self.splitter, -1) 32 | self.bottomSizer = wx.BoxSizer(wx.VERTICAL) 33 | self.bottomPanel.SetSizer(self.bottomSizer) 34 | 35 | # Message 36 | self.messageTxt = wx.StaticText(self.topPanel, -1, message) 37 | self.topSizer.Add(self.messageTxt, 0, wx.EXPAND | wx.ALL, 5) 38 | 39 | # List 40 | self.listCtrl = AutosizedListCtrl(self.topPanel, -1, style=wx.LC_REPORT | wx.LC_SINGLE_SEL) 41 | self.listCtrl.Bind(wx.EVT_LIST_ITEM_SELECTED, self.OnListItemSelected) 42 | self.topSizer.Add(self.listCtrl, 1, wx.EXPAND | wx.ALL, 5) 43 | 44 | # DiffViewer 45 | self.diffViewer = DiffViewer(self.bottomPanel, -1) 46 | self.bottomSizer.Add(self.diffViewer, 1, wx.EXPAND | wx.ALL, 5) 47 | 48 | # Close button 49 | self.closeButton = wx.Button(self.bottomPanel, -1, 'Close') 50 | self.closeButton.Bind(wx.EVT_BUTTON, self.OnClose) 51 | self.bottomSizer.Add(self.closeButton, 0, wx.ALIGN_RIGHT | wx.ALL, 5) 52 | 53 | # Layout 54 | self.splitter.SetMinimumPaneSize(200) 55 | self.splitter.SplitHorizontally(self.topPanel, self.bottomPanel, 250) 56 | 57 | def SetMessage(self, message): 58 | self.messageTxt.SetLabel(message) 59 | 60 | def OnListItemSelected(self, e): 61 | pass 62 | 63 | def OnClose(self, e): 64 | self.EndModal(0) 65 | 66 | class CommitListDialog(DiffDialog): 67 | def __init__(self, parent, id, repo, commits, title='', message=''): 68 | DiffDialog.__init__(self, parent, id, title, message) 69 | self.repo = repo 70 | self.commits = commits 71 | 72 | # Setup list control 73 | self.listCtrl.InsertColumn(0, "Author") 74 | self.listCtrl.InsertColumn(1, "Commit message") 75 | self.listCtrl.InsertColumn(2, "Date") 76 | 77 | self.listCtrl.SetColumnWidth(0, 150) 78 | self.listCtrl.SetColumnWidth(1, 300) 79 | self.listCtrl.SetColumnWidth(2, wx.LIST_AUTOSIZE) 80 | 81 | # Fill list control 82 | n = 0 83 | for commit in commits: 84 | self.listCtrl.InsertStringItem(n, commit.author_name) 85 | self.listCtrl.SetStringItem(n, 1, commit.short_msg) 86 | self.listCtrl.SetStringItem(n, 2, commit.author_date) 87 | n += 1 88 | 89 | def OnListItemSelected(self, e): 90 | rowid = e.GetIndex() 91 | commit = self.commits[rowid] 92 | 93 | commit_diff = self.repo.run_cmd(['show', commit.sha1]) 94 | self.diffViewer.SetDiffText(commit_diff, commit_mode=True) 95 | 96 | class UncommittedFilesDialog(DiffDialog): 97 | def __init__(self, parent, id, repo, title='', message=''): 98 | DiffDialog.__init__(self, parent, id, title, message) 99 | self.repo = repo 100 | 101 | # Get status 102 | self.status = repo.get_unified_status() 103 | self.files = self.status.keys() 104 | self.files.sort() 105 | 106 | # Setup list control 107 | self.listCtrl.InsertColumn(0, "Filename") 108 | self.listCtrl.InsertColumn(1, "Modification") 109 | 110 | self.listCtrl.SetColumnWidth(0, 500) 111 | self.listCtrl.SetColumnWidth(1, wx.LIST_AUTOSIZE) 112 | 113 | # Fill list control 114 | n = 0 115 | for file in self.files: 116 | self.listCtrl.InsertStringItem(n, file) 117 | self.listCtrl.SetStringItem(n, 1, MOD_DESCS[self.status[file]]) 118 | n += 1 119 | 120 | def OnListItemSelected(self, e): 121 | rowid = e.GetIndex() 122 | file = self.files[rowid] 123 | 124 | if self.status[file] == git.FILE_UNTRACKED: 125 | commit_diff = git.diff_for_untracked_file(os.path.join(self.repo.dir, file)) 126 | else: 127 | commit_diff = self.repo.run_cmd(['diff', 'HEAD', file]) 128 | 129 | self.diffViewer.SetDiffText(commit_diff, commit_mode=False) 130 | 131 | -------------------------------------------------------------------------------- /stupidgit_gui/DiffViewer.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import wx.stc 3 | import platformspec 4 | from util import * 5 | 6 | STYLE_NORMAL = 1 7 | STYLE_COMMIT = 2 8 | STYLE_FILE = 3 9 | STYLE_HUNK = 4 10 | STYLE_ADD = 5 11 | STYLE_REMOVE = 6 12 | 13 | MARK_FILE = 1 14 | 15 | STYLE_COLORS = [ 16 | None, 17 | ('#000000', '#FFFFFF', wx.FONTWEIGHT_NORMAL), # STYLE_NORMAL 18 | ('#000000', '#FFFFFF', wx.FONTWEIGHT_BOLD), # STYLE_COMMIT 19 | ('#000000', '#AAAAAA', wx.FONTWEIGHT_BOLD), # STYLE_FILE 20 | ('#0000AA', '#FFFFFF', wx.FONTWEIGHT_NORMAL), # STYLE_HUNK 21 | ('#008800', '#FFFFFF', wx.FONTWEIGHT_NORMAL), # STYLE_ADD 22 | ('#AA0000', '#FFFFFF', wx.FONTWEIGHT_NORMAL) # STYLE_REMOVE 23 | ] 24 | 25 | class DiffViewer(wx.Panel): 26 | def __init__(self, parent, id): 27 | wx.Panel.__init__(self, parent, id) 28 | 29 | self.sizer = wx.BoxSizer(wx.VERTICAL) 30 | self.SetSizer(self.sizer) 31 | 32 | # Create text control 33 | self.textCtrl = wx.stc.StyledTextCtrl(self, -1) 34 | self.sizer.Add(self.textCtrl, True, wx.EXPAND) 35 | 36 | # Create markers 37 | self.textCtrl.MarkerDefine(MARK_FILE, 38 | wx.stc.STC_MARK_BACKGROUND, 39 | wx.Colour(0,0,0,255), 40 | wx.Colour(192,192,192,255) 41 | ) 42 | 43 | # Create text styles 44 | for style in xrange(1, len(STYLE_COLORS)): 45 | fg, bg, weight = STYLE_COLORS[style] 46 | font = platformspec.Font(10, wx.FONTFAMILY_TELETYPE) 47 | 48 | self.textCtrl.StyleSetFont(style, font) 49 | self.textCtrl.StyleSetForeground(style, fg) 50 | self.textCtrl.StyleSetBackground(style, bg) 51 | 52 | def Clear(self): 53 | self.textCtrl.SetReadOnly(False) 54 | self.textCtrl.SetText('') 55 | self.textCtrl.SetReadOnly(True) 56 | 57 | def SetDiffText(self, text, commit_mode=False): 58 | self.Clear() 59 | self.textCtrl.SetReadOnly(False) 60 | 61 | # Setup commit mode (when the text comes from the 62 | # output of git show, not git diff) 63 | if commit_mode: 64 | in_commit_header = True 65 | in_commit_msg = False 66 | else: 67 | in_commit_header = False 68 | in_commit_msg = False 69 | 70 | in_hunk = False 71 | style = STYLE_NORMAL 72 | pos = 0 73 | lineno = 0 74 | for line in text.split('\n'): 75 | # Determine line style 76 | if in_commit_header: 77 | if line == '': 78 | in_commit_header = False 79 | in_commit_msg = True 80 | style = STYLE_COMMIT 81 | elif in_commit_msg: 82 | if line == '': 83 | in_commit_msg = False 84 | style = STYLE_COMMIT 85 | elif in_hunk: 86 | if line.startswith('+'): 87 | style = STYLE_ADD 88 | elif line.startswith('-'): 89 | style = STYLE_REMOVE 90 | elif line.startswith('@'): 91 | style = STYLE_HUNK 92 | elif line.startswith(' '): 93 | style = STYLE_NORMAL 94 | else: 95 | in_hunk = False 96 | style = STYLE_FILE 97 | else: 98 | if line.startswith('@'): 99 | style = STYLE_HUNK 100 | in_hunk = True 101 | else: 102 | style = STYLE_FILE 103 | 104 | # Add line 105 | self.textCtrl.AddText(safe_unicode(line) + '\n') 106 | self.textCtrl.StartStyling(pos, 0xff) 107 | self.textCtrl.SetStyling(len(line), style) 108 | pos += len(line) + 1 109 | 110 | if style == STYLE_FILE and len(line) > 0: 111 | self.textCtrl.MarkerAdd(lineno, MARK_FILE) 112 | 113 | lineno += 1 114 | 115 | self.textCtrl.SetReadOnly(True) 116 | 117 | -------------------------------------------------------------------------------- /stupidgit_gui/FetchDialogs.py: -------------------------------------------------------------------------------- 1 | import wx 2 | from git import * 3 | 4 | class FetchSetupDialog(wx.Dialog): 5 | def __init__(self, parent, id, repo): 6 | wx.Dialog.__init__(self, parent) 7 | self.repo = repo 8 | 9 | self.SetTitle('Fetch objects from remote repository') 10 | self.Bind(wx.EVT_CLOSE, self.OnCancel) 11 | 12 | # Layout 13 | self.sizer = wx.BoxSizer(wx.VERTICAL) 14 | self.SetSizer(self.sizer) 15 | 16 | remoteSizer = wx.BoxSizer(wx.HORIZONTAL) 17 | self.sizer.Add(remoteSizer, 0, wx.EXPAND | wx.ALL, 5) 18 | 19 | # Remote selector 20 | remoteChooserText = wx.StaticText(self, -1, 'Remote repository: ') 21 | remoteSizer.Add(remoteChooserText, 0, wx.ALIGN_CENTRE_VERTICAL, wx.RIGHT, 5) 22 | 23 | self.remoteChoices = [name for name,url in self.repo.remotes.iteritems()] 24 | self.remoteChoices.sort() 25 | 26 | self.remoteChooser = wx.Choice(self, -1, choices=self.remoteChoices) 27 | topPadding = 4 if sys.platform == 'darwin' else 0 28 | remoteSizer.Add(self.remoteChooser, 0, wx.EXPAND | wx.ALIGN_CENTRE_VERTICAL | wx.TOP, topPadding) 29 | self.remoteChooser.Select(0) 30 | self.remoteChooser.Bind(wx.EVT_CHOICE, self.OnRemoteChosen) 31 | 32 | # Remote URL 33 | self.remoteURLText = wx.StaticText(self, -1, '', style=wx.ALIGN_LEFT) 34 | self.sizer.Add(self.remoteURLText, 0, wx.ALL, 5) 35 | 36 | # Include submodules 37 | if self.repo.submodules: 38 | self.submoduleChk = wx.CheckBox(self, -1, label='Also fetch submodule commits') 39 | self.submoduleChk.SetValue(True) 40 | self.submoduleChk.Bind(wx.EVT_CHECKBOX, self.OnSubmoduleCheck) 41 | self.sizer.Add(self.submoduleChk, 0, wx.ALL, 5) 42 | self.includeSubmodules = True 43 | else: 44 | self.includeSubmodules = False 45 | 46 | # Fetch tags 47 | self.tagsChk = wx.CheckBox(self, -1, label='Fetch remote tags') 48 | self.tagsChk.Bind(wx.EVT_CHECKBOX, self.OnTagsCheck) 49 | self.sizer.Add(self.tagsChk, 0, wx.ALL, 5) 50 | self.fetchTags = False 51 | 52 | self.OnRemoteChosen(None) 53 | 54 | # Buttons 55 | buttonSizer = wx.BoxSizer(wx.HORIZONTAL) 56 | self.sizer.Add(buttonSizer, 0, wx.ALIGN_RIGHT | wx.ALL, 10) 57 | 58 | okButton = wx.Button(self, -1, 'OK') 59 | okButton.Bind(wx.EVT_BUTTON, self.OnOk) 60 | buttonSizer.Add(okButton, 0, wx.RIGHT | wx.BOTTOM, 5) 61 | 62 | cancelButton = wx.Button(self, -1, 'Cancel') 63 | cancelButton.Bind(wx.EVT_BUTTON, self.OnCancel) 64 | buttonSizer.Add(cancelButton, 0, wx.LEFT | wx.BOTTOM, 5) 65 | 66 | self.Fit() 67 | 68 | def OnRemoteChosen(self, e): 69 | remoteIndex = self.remoteChooser.GetSelection() 70 | self.selectedRemote = self.remoteChoices[remoteIndex] 71 | 72 | # Update labels 73 | self.remoteURLText.SetLabel('URL: %s' % self.repo.remotes[self.selectedRemote]) 74 | 75 | if self.repo.submodules: 76 | self.submoduleChk.SetLabel('Also fetch submodule commits from remote "%s"' % self.selectedRemote) 77 | 78 | # Fetch tags by default if the remote name is 'origin' 79 | self.fetchTags = (self.selectedRemote == 'origin') 80 | self.tagsChk.SetValue(self.fetchTags) 81 | 82 | # Update window size 83 | textSize = self.remoteURLText.GetSize() 84 | winSize = self.GetClientSize() 85 | self.SetClientSize( (max(winSize[0],textSize[0]+20), winSize[1]) ) 86 | self.Layout() 87 | 88 | def OnSubmoduleCheck(self, e): 89 | self.includeSubmodules = self.submoduleChk.GetValue() 90 | 91 | def OnTagsCheck(self, e): 92 | self.fetchTags = self.tagsChk.GetValue() 93 | 94 | def OnOk(self, e): 95 | self.EndModal(1) 96 | 97 | def OnCancel(self, e): 98 | self.EndModal(0) 99 | 100 | class FetchProgressDialog(wx.Dialog): 101 | def __init__(self, parent, id, repo, remote, includeSubmodules, fetchTags): 102 | wx.Dialog.__init__(self, parent, id) 103 | self.repo = repo 104 | self.remote = remote 105 | self.includeSubmodules = includeSubmodules 106 | self.fetchTags = fetchTags 107 | 108 | # Repositories 109 | self.repos = [ repo ] 110 | self.repoIndex = 0 111 | if includeSubmodules: 112 | self.repos += [ m for m in repo.submodules if remote in m.remotes ] 113 | 114 | # Layout 115 | self.SetTitle('Fetching from remote %s...' % remote) 116 | self.sizer = wx.BoxSizer(wx.VERTICAL) 117 | self.SetSizer(self.sizer) 118 | 119 | # Submodule progress 120 | if len(self.repos) > 1: 121 | self.submoduleText = wx.StaticText(self, -1, '') 122 | self.sizer.Add(self.submoduleText, 0, wx.ALL, 10) 123 | 124 | self.submoduleProgress = wx.Gauge(self, -1) 125 | self.submoduleProgress.SetRange(len(self.repos)-1) 126 | self.sizer.Add(self.submoduleProgress, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10) 127 | else: 128 | self.submoduleText = None 129 | 130 | # Progress text 131 | self.progressText = wx.StaticText(self, -1, 'Connecting to remote repository...') 132 | self.sizer.Add(self.progressText, 0, wx.ALL, 10) 133 | 134 | # Progress bar 135 | self.progressBar = wx.Gauge(self, -1) 136 | self.progressBar.SetRange(100) 137 | self.sizer.Add(self.progressBar, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10) 138 | 139 | # Cancel button 140 | self.cancelButton = wx.Button(self, -1, 'Cancel') 141 | self.cancelButton.Bind(wx.EVT_BUTTON, self.OnCancel) 142 | self.sizer.Add(self.cancelButton, 0, wx.ALIGN_RIGHT | wx.ALL, 5) 143 | 144 | self.Bind(wx.EVT_CLOSE, self.OnCancel) 145 | 146 | # Set dialog size 147 | self.Fit() 148 | w,h = self.GetClientSize() 149 | if w < 350: 150 | self.SetClientSize((350, h)) 151 | self.Layout() 152 | 153 | def ShowModal(self): 154 | self.StartRepo() 155 | return wx.Dialog.ShowModal(self) 156 | 157 | def StartRepo(self): 158 | repo = self.repos[self.repoIndex] 159 | 160 | if self.submoduleText: 161 | self.submoduleText.SetLabel('Fetching commits for %s...' % repo.name) 162 | self.submoduleProgress.SetValue(self.repoIndex) 163 | 164 | # Resize window if necessary 165 | tw,th = self.submoduleText.GetClientSize() 166 | w,h = self.GetClientSize() 167 | if w < tw+20: 168 | self.SetClientSize((tw+20, h)) 169 | self.Layout() 170 | 171 | self.progressText.SetLabel('Connecting to remote repository...') 172 | self.progressBar.Pulse() 173 | self.fetchThread = repo.fetch_bg(self.remote, self.ProgressCallback, self.fetchTags) 174 | self.repoIndex += 1 175 | 176 | def ProgressCallback(self, event, param): 177 | if event == TRANSFER_COUNTING: 178 | wx.CallAfter(self.progressText.SetLabel, "Counting objects: %d" % param) 179 | elif event == TRANSFER_COMPRESSING: 180 | wx.CallAfter(self.progressText.SetLabel, "Compressing objects...") 181 | wx.CallAfter(self.progressBar.SetValue, param) 182 | elif event == TRANSFER_RECEIVING: 183 | wx.CallAfter(self.progressText.SetLabel, "Receiving objects...") 184 | wx.CallAfter(self.progressBar.SetValue, param) 185 | elif event == TRANSFER_RESOLVING: 186 | wx.CallAfter(self.progressText.SetLabel, "Resolving deltas...") 187 | wx.CallAfter(self.progressBar.SetValue, param) 188 | elif event == TRANSFER_ENDED: 189 | wx.CallAfter(self.OnFetchEnded, param) 190 | 191 | def OnFetchEnded(self, param): 192 | self.fetchThread.join() 193 | self.fetchThread = None 194 | 195 | if type(param) in [str, unicode]: 196 | # Error 197 | wx.MessageBox(safe_unicode(param), 'Error', style=wx.OK|wx.ICON_ERROR) 198 | self.EndModal(0) 199 | else: 200 | # Success 201 | if len(self.repos) > self.repoIndex: 202 | self.StartRepo() 203 | else: 204 | self.EndModal(1) 205 | 206 | def OnCancel(self, e): 207 | if self.fetchThread: 208 | self.fetchThread.abort() 209 | self.fetchThread.join() 210 | 211 | self.EndModal(0) 212 | 213 | -------------------------------------------------------------------------------- /stupidgit_gui/HiddenWindow.py: -------------------------------------------------------------------------------- 1 | import wx 2 | from MainWindow import * 3 | from wxutil import * 4 | from AboutDialog import ShowAboutDialog 5 | 6 | class HiddenWindow(object): 7 | def __init__(self): 8 | super(HiddenWindow, self).__init__() 9 | self.frame = LoadFrame(None, 'HiddenWindow') 10 | 11 | SetupEvents(self.frame, [ 12 | (None, wx.EVT_CLOSE, self.OnWindowClosed), 13 | ('quitMenuItem', wx.EVT_MENU, self.OnExit), 14 | ('openMenuItem', wx.EVT_MENU, self.OnOpenRepository), 15 | ('newWindowMenuItem', wx.EVT_MENU, self.OnNewWindow), 16 | ('aboutMenuItem', wx.EVT_MENU, self.OnAbout), 17 | ]) 18 | 19 | def ShowMenu(self): 20 | self.frame.SetPosition((-10000,-10000)) 21 | self.frame.Show() 22 | self.frame.Hide() 23 | 24 | def OnWindowClosed(self, e): 25 | # Do nothing 26 | pass 27 | 28 | def OnNewWindow(self, e): 29 | win = MainWindow(None) 30 | win.Show(True) 31 | 32 | def OnOpenRepository(self, e): 33 | repodir = wx.DirSelector("Open repository") 34 | if not repodir: return 35 | 36 | try: 37 | repo = Repository(repodir) 38 | new_win = MainWindow(repo) 39 | new_win.Show(True) 40 | except GitError, msg: 41 | wx.MessageBox(str(msg), 'Error', style=wx.OK|wx.ICON_ERROR) 42 | 43 | def OnExit(self, e): 44 | wx.TheApp.ExitApp() 45 | 46 | def OnAbout(self, e): 47 | ShowAboutDialog() 48 | -------------------------------------------------------------------------------- /stupidgit_gui/HistoryTab.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import os 3 | import os.path 4 | import MainWindow 5 | from CommitList import CommitList, EVT_COMMITLIST_SELECT, EVT_COMMITLIST_RIGHTCLICK 6 | from DiffViewer import DiffViewer 7 | from SwitchWizard import SwitchWizard 8 | from Wizard import * 9 | from FetchDialogs import FetchSetupDialog, FetchProgressDialog 10 | from PushDialogs import PushSetupDialog, PushProgressDialog 11 | import git 12 | from git import GitError 13 | from util import * 14 | from wxutil import * 15 | 16 | # Menu item ids 17 | MENU_SWITCH_TO_COMMIT = 10000 18 | MENU_MERGE_COMMIT = 10001 19 | MENU_CHERRYPICK_COMMIT = 10002 20 | MENU_REVERT_COMMIT = 10003 21 | 22 | MENU_CREATE_BRANCH = 11000 23 | MENU_DELETE_BRANCH = 12000 24 | 25 | # This array is used to provide unique ids for menu items 26 | # that refer to a branch 27 | branch_indexes = [] 28 | 29 | class HistoryTab(object): 30 | def __init__(self, mainController): 31 | self.mainController = mainController 32 | self.mainWindow = self.mainController.frame 33 | 34 | # Commit list 35 | browserPanel = GetWidget(self.mainWindow, 'historyBrowserPanel') 36 | browserSizer = browserPanel.GetSizer() 37 | 38 | self.commitList = CommitList(browserPanel, -1, False) 39 | self.commitList.authorColumnPos = self.mainController.config.ReadInt('CommitListAuthorColumnPosition', 200) 40 | self.commitList.Bind(EVT_COMMITLIST_SELECT, self.OnCommitSelected, self.commitList) 41 | self.commitList.Bind(EVT_COMMITLIST_RIGHTCLICK, self.OnCommitRightClick, self.commitList) 42 | browserSizer.Add(self.commitList, 1, wx.EXPAND) 43 | 44 | # Diff viewer 45 | diffPanel = GetWidget(self.mainWindow, "historyDiffPanel") 46 | diffSizer = diffPanel.GetSizer() 47 | 48 | self.diffViewer = DiffViewer(diffPanel, -1) 49 | diffSizer.Add(self.diffViewer, 1, wx.EXPAND) 50 | 51 | # Splitter 52 | self.splitter = GetWidget(self.mainWindow, "historySplitter") 53 | 54 | # Context menu 55 | self.contextCommit = None 56 | self.contextMenu = wx.Menu() 57 | wx.EVT_MENU(self.mainWindow, MENU_SWITCH_TO_COMMIT, self.OnSwitchToCommit) 58 | wx.EVT_MENU(self.mainWindow, MENU_CREATE_BRANCH, self.OnCreateBranch) 59 | wx.EVT_MENU(self.mainWindow, MENU_MERGE_COMMIT, self.OnMerge) 60 | wx.EVT_MENU(self.mainWindow, MENU_CHERRYPICK_COMMIT, self.OnCherryPick) 61 | wx.EVT_MENU(self.mainWindow, MENU_REVERT_COMMIT, self.OnRevert) 62 | 63 | # Other events 64 | SetupEvents(self.mainWindow, [ 65 | ('fetchTool', wx.EVT_TOOL, self.OnFetch), 66 | ('pushTool', wx.EVT_TOOL, self.OnPushCommit), 67 | ('switchTool', wx.EVT_TOOL, self.OnSwitchToCommit), 68 | ('switchMenuItem', wx.EVT_MENU, self.OnSwitchToCommit), 69 | ('createBranchMenuItem', wx.EVT_MENU, self.OnCreateBranch), 70 | ('mergeMenuItem', wx.EVT_MENU, self.OnMerge), 71 | ('cherryPickMenuItem', wx.EVT_MENU, self.OnCherryPick), 72 | ('revertMenuItem', wx.EVT_MENU, self.OnRevert), 73 | ('gotoCommitMenuItem', wx.EVT_MENU, self.OnGotoCommit), 74 | ]) 75 | 76 | def OnCreated(self): 77 | self.splitter.SetSashPosition(self.mainController.config.ReadInt('HistorySplitterPosition', 200)) 78 | 79 | def SetRepo(self, repo): 80 | # Branch indexes 81 | global branch_indexes 82 | for branch in repo.branches: 83 | if branch not in branch_indexes: 84 | branch_indexes.append(branch) 85 | 86 | # Menu events for branches 87 | for index in xrange(len(branch_indexes)): 88 | wx.EVT_MENU(self.mainWindow, MENU_DELETE_BRANCH + index, self.OnDeleteBranch) 89 | 90 | self.repo = repo 91 | self.commitList.SetRepo(repo) 92 | 93 | difftext = self.repo.run_cmd(['show', 'HEAD^']) 94 | self.diffViewer.Clear() 95 | 96 | def OnCommitSelected(self, e): 97 | self.contextCommit = self.commitList.CommitByRow(e.currentRow) 98 | 99 | # Show in diff viewer 100 | commit_diff = self.repo.run_cmd(['show', self.contextCommit.sha1]) 101 | self.diffViewer.SetDiffText(commit_diff, commit_mode=True) 102 | 103 | def OnCommitRightClick(self, e): 104 | self.contextCommit = self.commitList.CommitByRow(e.currentRow) 105 | self.SetupContextMenu(self.contextCommit) 106 | self.commitList.PopupMenu(self.contextMenu, e.coords) 107 | 108 | def OnSwitchToCommit(self, e): 109 | if not self.contextCommit or self.mainController.selectedTab != MainWindow.TAB_HISTORY: 110 | return 111 | 112 | wizard = SwitchWizard(self.mainWindow, -1, self.repo, self.contextCommit) 113 | result = wizard.RunWizard() 114 | 115 | if result > 0: 116 | self.mainController.ReloadRepo() 117 | 118 | # Check for unmerged changes 119 | unmerged = False 120 | unstaged, staged = self.repo.get_status() 121 | for f in unstaged: 122 | if unstaged[f] == git.FILE_UNMERGED: 123 | unmerged = True 124 | 125 | # Show error if checkout failed 126 | if wizard.error: 127 | wx.MessageBox(safe_unicode(wizard.error), 'Could not switch to this version', style=wx.OK|wx.ICON_ERROR) 128 | return 129 | 130 | # Show warning if necessary 131 | msg = '' 132 | if unmerged: 133 | msg = u'- Repository contains unmerged files. You have to merge them manually.' 134 | 135 | if wizard.submoduleWarnings: 136 | submodules = wizard.submoduleWarnings.keys() 137 | submodules.sort() 138 | 139 | if msg: 140 | msg += '\n- ' 141 | 142 | if len(submodules) == 1: 143 | submodule = submodules[0] 144 | msg += u"Submodule '%s' could not be switched to the referenced version:%s" \ 145 | % (submodule, safe_unicode(wizard.submoduleWarnings[submodule])) 146 | else: 147 | msg += u"Some submodules could not be switched to the referenced version:\n\n" 148 | for submodule in submodules: 149 | msg += u" - %s: %s\n" % (submodule, safe_unicode(wizard.submoduleReasons[submodule])) 150 | 151 | if msg: 152 | if len(msg.split('\n')) == 1: 153 | msg = msg[2:] # remove '- ' from the beginning 154 | 155 | wx.MessageBox(msg, 'Warning', style=wx.OK|wx.ICON_ERROR) 156 | 157 | def OnCreateBranch(self, e): 158 | if not self.contextCommit or self.mainController.selectedTab != MainWindow.TAB_HISTORY: 159 | return 160 | 161 | dialog = wx.TextEntryDialog(self.mainWindow, "Enter branch name:", "Create branch...") 162 | if dialog.ShowModal() == wx.ID_OK: 163 | branch_name = dialog.GetValue() 164 | self.GitCommand(['branch', branch_name, self.contextCommit.sha1]) 165 | 166 | def OnDeleteBranch(self, e): 167 | branch = branch_indexes[e.GetId() % 1000] 168 | msg = wx.MessageDialog( 169 | self.mainWindow, 170 | "By deleting branch '%s' all commits that are not referenced by another branch will be lost.\n\nDo you really want to continue?" % branch, 171 | "Warning", 172 | wx.ICON_EXCLAMATION | wx.YES_NO | wx.YES_DEFAULT 173 | ) 174 | if msg.ShowModal() == wx.ID_YES: 175 | self.GitCommand(['branch', '-D', branch]) 176 | 177 | def OnMerge(self, e): 178 | if not self.contextCommit or self.mainController.selectedTab != MainWindow.TAB_HISTORY: 179 | return 180 | 181 | # Default merge message 182 | if self.repo.current_branch: 183 | local_branch = self.repo.current_branch 184 | else: 185 | local_branch = "HEAD" 186 | 187 | remote_sha1 = self.contextCommit.sha1 188 | if remote_sha1 in self.repo.branches_by_sha1: 189 | remote_branch = "branch '%s'" % self.repo.branches_by_sha1[remote_sha1][0] 190 | elif remote_sha1 in self.repo.remote_branches_by_sha1: 191 | remote_branch = "remote branch '%s'" % self.repo.remote_branches_by_sha1[remote_sha1][0] 192 | else: 193 | remote_branch = "commit '%s'" % self.contextCommit.abbrev 194 | 195 | mergeMsg = "merge %s into %s" % (remote_branch, local_branch) 196 | 197 | # Show merge message dialog 198 | msg = wx.TextEntryDialog( 199 | self.mainWindow, 200 | "Enter merge message:", 201 | "Merge", 202 | mergeMsg, 203 | wx.ICON_QUESTION | wx.OK | wx.CANCEL 204 | ) 205 | if msg.ShowModal() == wx.ID_OK: 206 | retcode, stdout, stderr = self.repo.run_cmd(['merge', self.contextCommit.sha1, '-m', mergeMsg], with_retcode=True, with_stderr=True) 207 | self.mainController.ReloadRepo() 208 | 209 | if retcode != 0: 210 | if 'CONFLICT' in stdout: 211 | # Create MERGE_MSG 212 | f = open(os.path.join(self.repo.dir, '.git', 'MERGE_MSG'), 'w') 213 | f.write("%s\n\nConflicts:\n" % mergeMsg) 214 | unstaged, staged = self.repo.get_status() 215 | unmerged_files = [ fn for fn,status in unstaged.iteritems() if status == git.FILE_UNMERGED ] 216 | for fn in unmerged_files: 217 | f.write("\t%s\n" % fn) 218 | f.close() 219 | 220 | # Show warning 221 | warningTitle = "Warning: conflicts during merge" 222 | warningMsg = \ 223 | "Some files or submodules could not be automatically merged. " + \ 224 | "You have to resolve these conflicts by hand and then stage " + \ 225 | "these files/submodules.\n\n" + \ 226 | "If you want to abort merge, press \"Discard all changes\" on Index page." 227 | else: 228 | warningTitle = "Error" 229 | warningMsg = "Git returned the following error:\n\n" + stdout + stderr 230 | 231 | wx.MessageBox(warningMsg, warningTitle, style=wx.OK|wx.ICON_ERROR) 232 | 233 | def OnCherryPick(self, e): 234 | if not self.contextCommit or self.mainController.selectedTab != MainWindow.TAB_HISTORY: 235 | return 236 | 237 | confirmMsg = "Do you really want to cherry-pick this commit?" 238 | msg = wx.MessageDialog( 239 | self.mainWindow, 240 | confirmMsg, 241 | "Confirmation", 242 | wx.ICON_EXCLAMATION | wx.YES_NO | wx.YES_DEFAULT 243 | ) 244 | if msg.ShowModal() == wx.ID_YES: 245 | retcode, stdout, stderr = self.repo.run_cmd(['cherry-pick', self.contextCommit.sha1], with_retcode=True, with_stderr=True) 246 | self.mainController.ReloadRepo() 247 | 248 | if retcode != 0: 249 | if 'Automatic cherry-pick failed' in stderr: 250 | warningTitle = "Warning: conflicts during cherry-picking" 251 | warningMsg = \ 252 | "Some files or submodules could not be automatically cherry-picked. " + \ 253 | "You have to resolve these conflicts by hand and then stage " + \ 254 | "these files/submodules.\n\n" + \ 255 | "If you want to abort cherry-picking, press \"Discard all changes\" on Index page." 256 | else: 257 | warningTitle = "Error" 258 | warningMsg = "Git returned the following error:\n\n" + stdout + stderr 259 | 260 | wx.MessageBox(warningMsg, warningTitle, style=wx.OK|wx.ICON_ERROR) 261 | 262 | def OnRevert(self, e): 263 | if not self.contextCommit or self.mainController.selectedTab != MainWindow.TAB_HISTORY: 264 | return 265 | 266 | confirmMsg = "Do you really want to revert this commit?" 267 | msg = wx.MessageDialog( 268 | self.mainWindow, 269 | confirmMsg, 270 | "Confirmation", 271 | wx.ICON_EXCLAMATION | wx.YES_NO | wx.YES_DEFAULT 272 | ) 273 | if msg.ShowModal() == wx.ID_YES: 274 | retcode, stdout, stderr = self.repo.run_cmd(['revert', self.contextCommit.sha1], with_retcode=True, with_stderr=True) 275 | self.mainController.ReloadRepo() 276 | 277 | if retcode != 0: 278 | if 'Automatic reverting failed' in stderr: 279 | warningTitle = "Warning: conflicts during reverting" 280 | warningMsg = \ 281 | "Some files or submodules could not be automatically reverted. " + \ 282 | "You have to resolve these conflicts by hand and then stage " + \ 283 | "these files/submodules.\n\n" + \ 284 | "If you want to abort reverting, press \"Discard all changes\" on Index page." 285 | else: 286 | warningTitle = "Error" 287 | warningMsg = "Git returned the following error:\n\n" + stdout + stderr 288 | 289 | wx.MessageBox(warningMsg, warningTitle, style=wx.OK|wx.ICON_ERROR) 290 | 291 | def OnFetch(self, e): 292 | # Setup dialog 293 | setupDialog = FetchSetupDialog(self.mainWindow, -1, self.repo) 294 | result = setupDialog.ShowModal() 295 | 296 | # Progress dialog 297 | if result: 298 | progressDialog = FetchProgressDialog(self.mainWindow, -1, self.repo, setupDialog.selectedRemote, setupDialog.includeSubmodules, setupDialog.fetchTags) 299 | if progressDialog.ShowModal(): 300 | self.mainController.ReloadRepo() 301 | 302 | def OnFetchProgress(self, eventType, eventParam): 303 | pass 304 | 305 | def OnPushCommit(self, e): 306 | # Require context commit 307 | if not self.contextCommit or self.mainController.selectedTab != MainWindow.TAB_HISTORY: 308 | wx.MessageBox( 309 | 'Select the commit to be pushed first!', 310 | 'Warning', 311 | style=wx.OK|wx.ICON_WARNING 312 | ) 313 | return 314 | 315 | # Progress dialog 316 | setupDialog = PushSetupDialog(self.mainWindow, -1, self.repo) 317 | if setupDialog.ShowModal() == wx.ID_OK: 318 | remote = setupDialog.selectedRemote 319 | commit = self.contextCommit 320 | branch = setupDialog.selectedBranch 321 | forcePush = setupDialog.forcePush 322 | 323 | if len(remote) and len(branch): 324 | progressDialog = PushProgressDialog(self.mainWindow, -1, self.repo, remote, commit, branch, forcePush) 325 | if progressDialog.ShowModal(): 326 | self.mainController.ReloadRepo() 327 | 328 | def OnGotoCommit(self, e): 329 | if self.mainController.selectedTab != MainWindow.TAB_HISTORY: 330 | return 331 | 332 | msg = wx.TextEntryDialog( 333 | self.mainWindow, 334 | "Enter reference name or commit ID:", 335 | "Go to Version", 336 | "", 337 | wx.ICON_QUESTION | wx.OK | wx.CANCEL 338 | ) 339 | msg.ShowModal() 340 | refname = msg.GetValue() 341 | 342 | if refname: 343 | commit_id = self.repo.run_cmd(['rev-parse', refname]).strip() 344 | if commit_id: 345 | error = self.commitList.GotoCommit(commit_id) 346 | else: 347 | error = "Cannot find reference or commit ID: '%s'" % refname 348 | 349 | if error: 350 | wx.MessageBox( 351 | error, 352 | "Error", 353 | style=wx.OK|wx.ICON_ERROR 354 | ) 355 | 356 | def SaveState(self): 357 | self.mainController.config.WriteInt('HistorySplitterPosition', self.splitter.GetSashPosition()) 358 | self.mainController.config.WriteInt('CommitListAuthorColumnPosition', self.commitList.authorColumnPos) 359 | 360 | def SetupContextMenu(self, commit): 361 | branches = self.repo.branches_by_sha1.get(commit.sha1, []) 362 | 363 | # Delete old items 364 | menuItems = self.contextMenu.GetMenuItems() 365 | for item in menuItems: 366 | self.contextMenu.Delete(item.GetId()) 367 | 368 | # Switch to this version... 369 | self.contextMenu.Append(MENU_SWITCH_TO_COMMIT, "Switch to this version...") 370 | 371 | # Create branch 372 | self.contextMenu.Append(MENU_CREATE_BRANCH, "Create branch here...") 373 | 374 | # Delete branch 375 | if branches: 376 | self.contextMenu.AppendSeparator() 377 | 378 | for branch in branches: 379 | menu_id = MENU_DELETE_BRANCH + branch_indexes.index(branch) 380 | self.contextMenu.Append(menu_id, "Delete branch '%s'" % branch) 381 | 382 | # Merge 383 | self.contextMenu.AppendSeparator() 384 | self.contextMenu.Append(MENU_MERGE_COMMIT, "Merge into current HEAD") 385 | 386 | # Cherry-pick 387 | self.contextMenu.Append(MENU_CHERRYPICK_COMMIT, "Apply this commit to HEAD (cherry-pick)") 388 | self.contextMenu.Append(MENU_REVERT_COMMIT, "Apply the inverse of this commit to HEAD (revert)") 389 | 390 | def GitCommand(self, cmd, check_submodules=False, **opts): 391 | try: 392 | retval = self.repo.run_cmd(cmd, raise_error=True, **opts) 393 | self.mainController.ReloadRepo() 394 | 395 | # Check submodules 396 | if check_submodules and self.repo.submodules: 397 | for submodule in self.repo.submodules: 398 | if submodule.main_ref != submodule.head: 399 | wx.MessageBox( 400 | "One or more submodule versions differ from the version " + 401 | "that is referenced by the current HEAD. If this is not " + 402 | "what you want, you need to checkout them to the proper version.", 403 | 'Warning', 404 | style=wx.OK|wx.ICON_WARNING 405 | ) 406 | break 407 | 408 | return retval 409 | except GitError, msg: 410 | wx.MessageBox(str(msg), 'Error', style=wx.OK|wx.ICON_ERROR) 411 | return False 412 | 413 | -------------------------------------------------------------------------------- /stupidgit_gui/IndexTab.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | import wx 4 | import wx.html 5 | from wx.lib.mixins.listctrl import ListCtrlAutoWidthMixin, ListCtrlSelectionManagerMix 6 | import os, os.path 7 | import sys 8 | 9 | import MainWindow 10 | import Wizard 11 | from DiffViewer import DiffViewer 12 | from git import * 13 | from util import * 14 | from wxutil import * 15 | 16 | MOD_DESCS = { 17 | FILE_ADDED : 'added', 18 | FILE_MODIFIED : 'modified', 19 | FILE_DELETED : 'deleted', 20 | FILE_COPIED : 'copied', 21 | FILE_RENAMED : 'renamed', 22 | FILE_UNMERGED : 'unmerged', 23 | FILE_TYPECHANGED : 'type changed', 24 | FILE_UNTRACKED : 'untracked', 25 | FILE_BROKEN : 'BROKEN', 26 | FILE_UNKNOWN : 'UNKNOWN' 27 | } 28 | 29 | if sys.platform in ['win32', 'cygwin']: 30 | LABEL_STAGE = u"Stage >" 31 | LABEL_UNSTAGE = u"< Unstage" 32 | LABEL_DISCARD = u"× Discard" 33 | else: 34 | LABEL_STAGE = u"Stage ⇒" 35 | LABEL_UNSTAGE = u"⇐ Unstage" 36 | LABEL_DISCARD = u"× Discard" 37 | 38 | MENU_MERGE_FILE = 20000 39 | MENU_TAKE_LOCAL = 20001 40 | MENU_TAKE_REMOTE = 20002 41 | 42 | class FileList(wx.ListCtrl, ListCtrlAutoWidthMixin, ListCtrlSelectionManagerMix): 43 | def __init__(self, parent, ID, pos=wx.DefaultPosition, size=wx.DefaultSize, style=wx.LC_REPORT | wx.LC_NO_HEADER): 44 | wx.ListCtrl.__init__(self, parent, ID, pos, size, style) 45 | ListCtrlAutoWidthMixin.__init__(self) 46 | ListCtrlSelectionManagerMix.__init__(self) 47 | 48 | self.InsertColumn(0, "File") 49 | 50 | def GetSelections(self): 51 | return [ i for i in xrange(self.GetItemCount()) if self.GetItemState(i, wx.LIST_STATE_SELECTED) == wx.LIST_STATE_SELECTED ] 52 | 53 | class IndexTab(object): 54 | def __init__(self, mainController): 55 | self.mainController = mainController 56 | self.mainWindow = mainController.frame 57 | self.listPanel = GetWidget(self.mainWindow, 'indexListPanel') 58 | self.listSizer = self.listPanel.GetSizer() 59 | 60 | # Splitter 61 | self.splitter = GetWidget(self.mainWindow, 'indexSplitter') 62 | 63 | # Unstaged list 64 | self.unstagedList = FileList(self.listPanel, -1) 65 | unstagedSizer = self.listPanel.GetSizer().GetItem(0).GetSizer() 66 | unstagedSizer.Add(self.unstagedList, 1, wx.EXPAND|wx.ALL, 0) 67 | self.unstagedList.Bind(wx.EVT_LIST_ITEM_SELECTED, self.OnUnstagedListSelect, self.unstagedList) 68 | self.unstagedList.Bind(wx.EVT_LIST_ITEM_RIGHT_CLICK, self.OnUnstagedRightClick, self.unstagedList) 69 | self.listPanel.Layout() 70 | 71 | self.unstagedMenu = wx.Menu() 72 | self.unstagedMenu.Append(MENU_MERGE_FILE, "Merge file") 73 | self.unstagedMenu.Append(MENU_TAKE_LOCAL, "Take local version") 74 | self.unstagedMenu.Append(MENU_TAKE_REMOTE, "Take remote version") 75 | wx.EVT_MENU(self.mainWindow, MENU_MERGE_FILE, self.OnMergeFile) 76 | wx.EVT_MENU(self.mainWindow, MENU_TAKE_LOCAL, self.OnTakeLocal) 77 | wx.EVT_MENU(self.mainWindow, MENU_TAKE_REMOTE, self.OnTakeRemote) 78 | 79 | # Staged changes 80 | self.stagedList = FileList(self.listPanel, -1) 81 | stagedSizer = self.listPanel.GetSizer().GetItem(2).GetSizer() 82 | stagedSizer.Add(self.stagedList, 1, wx.EXPAND|wx.ALL, 0) 83 | self.stagedList.Bind(wx.EVT_LIST_ITEM_SELECTED, self.OnStagedListSelect, self.stagedList) 84 | self.listPanel.Layout() 85 | 86 | # Diff viewer 87 | diffPanel = GetWidget(self.mainWindow, 'indexDiffPanel') 88 | diffSizer = diffPanel.GetSizer() 89 | 90 | self.diffViewer = DiffViewer(diffPanel, -1) 91 | diffSizer.Add(self.diffViewer, 1, wx.EXPAND) 92 | diffPanel.Layout() 93 | 94 | # Events 95 | SetupEvents(self.mainWindow, [ 96 | ('stageButton', wx.EVT_BUTTON, self.OnStage), 97 | ('unstageButton', wx.EVT_BUTTON, self.OnUnstage), 98 | ('discardButton', wx.EVT_BUTTON, self.OnDiscard), 99 | ('commitTool', wx.EVT_TOOL, self.OnCommit), 100 | ('resetTool', wx.EVT_TOOL, self.OnReset), 101 | 102 | ('stageMenuItem', wx.EVT_MENU, self.OnStage), 103 | ('unstageMenuItem', wx.EVT_MENU, self.OnUnstage), 104 | ('discardMenuItem', wx.EVT_MENU, self.OnDiscard), 105 | ('commitMenuItem', wx.EVT_MENU, self.OnCommit), 106 | ('resetMenuItem', wx.EVT_MENU, self.OnReset), 107 | ]) 108 | 109 | def OnCreated(self): 110 | self.splitter.SetSashPosition(self.mainController.config.ReadInt('IndexSplitterPosition', 200)) 111 | self.splitter.SetMinimumPaneSize(120) 112 | 113 | def OnStage(self, e): 114 | if self.mainController.selectedTab != MainWindow.TAB_INDEX: 115 | return 116 | 117 | selection = self.unstagedList.GetSelections() 118 | 119 | for row in selection: 120 | filename, change = self.unstagedChanges[row] 121 | if change == FILE_DELETED: 122 | self.repo.run_cmd(['rm', '--cached', filename]) 123 | else: 124 | self.repo.run_cmd(['add', filename]) 125 | 126 | self.Refresh() 127 | 128 | if len(selection) > 0: 129 | row = selection[0] 130 | if self.unstagedList.GetItemCount() > row: 131 | self.unstagedList.SetItemState(row, wx.LIST_STATE_SELECTED, wx.LIST_STATE_SELECTED) 132 | 133 | def OnUnstage(self, e): 134 | if self.mainController.selectedTab != MainWindow.TAB_INDEX: 135 | return 136 | 137 | selection = self.stagedList.GetSelections() 138 | 139 | for row in self.stagedList.GetSelections(): 140 | filename = self.stagedChanges[row][0] 141 | if self.repo.head == 'HEAD': 142 | self.repo.run_cmd(['rm', '--cached', filename]) 143 | else: 144 | self.repo.run_cmd(['reset', 'HEAD', filename]) 145 | 146 | self.Refresh() 147 | 148 | if len(selection) > 0: 149 | row = selection[0] 150 | if self.stagedList.GetItemCount() > row: 151 | self.stagedList.SetItemState(row, wx.LIST_STATE_SELECTED, wx.LIST_STATE_SELECTED) 152 | 153 | def OnDiscard(self, e): 154 | if self.mainController.selectedTab != MainWindow.TAB_INDEX: 155 | return 156 | 157 | # Get selection 158 | rows = self.unstagedList.GetSelections() 159 | if len(rows) == 0: 160 | return 161 | 162 | # Confirm dialog 163 | msg = wx.MessageDialog( 164 | self.mainWindow, 165 | "The selected changes will be permanently lost. Do you really want to continue?", 166 | "Warning", 167 | wx.ICON_EXCLAMATION | wx.YES_NO | wx.YES_DEFAULT 168 | ) 169 | if msg.ShowModal() == wx.ID_YES: 170 | for row in rows: 171 | filename = self.unstagedChanges[row][0] 172 | if filename in self.untrackedFiles: 173 | try: os.unlink(os.path.join(self.repo.dir, filename)) 174 | except OSError: pass 175 | else: 176 | self.repo.run_cmd(['checkout', filename]) 177 | 178 | self.Refresh() 179 | 180 | def OnUnstagedListSelect(self, e): 181 | # Clear selection in stagedList 182 | for row in self.stagedList.GetSelections(): 183 | self.stagedList.SetItemState(row, 0, wx.LIST_STATE_SELECTED) 184 | 185 | # Show diffs 186 | selection = self.unstagedList.GetSelections() 187 | 188 | diff_text = '' 189 | for row in selection: 190 | filename = self.unstagedChanges[row][0] 191 | if filename in self.untrackedFiles: 192 | filename = os.path.join(self.repo.dir, filename) 193 | diff_text += diff_for_untracked_file(filename) 194 | else: 195 | diff_text += self.repo.run_cmd(['diff', self.unstagedChanges[row][0]]) 196 | 197 | self.diffViewer.SetDiffText(diff_text) 198 | 199 | def OnStagedListSelect(self, e): 200 | # Clear selection in unstagedList 201 | for row in self.unstagedList.GetSelections(): 202 | self.unstagedList.SetItemState(row, 0, wx.LIST_STATE_SELECTED) 203 | 204 | # Show diffs 205 | selection = self.stagedList.GetSelections() 206 | 207 | diff_text = '' 208 | for row in selection: 209 | diff_text += self.repo.run_cmd(['diff', '--cached', self.stagedChanges[row][0]]) 210 | 211 | self.diffViewer.SetDiffText(diff_text) 212 | 213 | def OnUnstagedRightClick(self, e): 214 | id = self.selectedUnstagedItem = e.GetIndex() 215 | filename, modification = self.unstagedChanges[id] 216 | submodule_names = [ r.name for r in self.repo.submodules ] 217 | 218 | if modification == FILE_UNMERGED and filename not in submodule_names: 219 | self.mainWindow.PopupMenu(self.unstagedMenu) 220 | 221 | def OnCommit(self, e): 222 | if len(self.stagedChanges) == 0 and not os.path.exists(os.path.join(self.repo.dir, '.git', 'MERGE_HEAD')): 223 | wx.MessageBox( 224 | "Stage some files on Changes tab before committing!", 225 | "Nothing to commit.", 226 | style=wx.ICON_EXCLAMATION | wx.OK 227 | ) 228 | return 229 | 230 | if len([c for f,c in self.unstagedChanges if c == FILE_UNMERGED]): 231 | wx.MessageBox( 232 | "You should fix conflicts before committing!", 233 | "Error", 234 | style=wx.ICON_EXCLAMATION | wx.OK 235 | ) 236 | return 237 | 238 | # Show commit wizard 239 | commit_wizard = CommitWizard(self.mainWindow, -1, self.repo) 240 | commit_wizard.RunWizard() 241 | self.mainController.SetRepo(self.repo) 242 | 243 | def OnReset(self, e): 244 | msg = wx.MessageDialog( 245 | self.mainWindow, 246 | "This operation will discard ALL (both staged and unstaged) changes. Do you really want to continue?", 247 | "Warning", 248 | wx.ICON_EXCLAMATION | wx.YES_NO | wx.YES_DEFAULT 249 | ) 250 | if msg.ShowModal() == wx.ID_YES: 251 | self.repo.run_cmd(['reset', '--hard']) 252 | self.repo.run_cmd(['clean', '-f']) 253 | 254 | self.Refresh() 255 | 256 | def OnMergeFile(self, e): 257 | self.repo.merge_file(self.unstagedChanges[self.selectedUnstagedItem][0]) 258 | 259 | def _simpleMerge(self, filename, msg, index): 260 | msg = wx.MessageDialog( 261 | self.mainWindow, 262 | msg, 263 | "Warning", 264 | wx.ICON_EXCLAMATION | wx.YES_NO | wx.YES_DEFAULT 265 | ) 266 | if msg.ShowModal() == wx.ID_YES: 267 | try: 268 | content = self.repo.run_cmd(['cat-file', 'blob', ':%d:%s' % (index, filename)], raise_error=True) 269 | f = open(os.path.join(self.repo.dir, filename), 'wb') 270 | f.write(content) 271 | f.close() 272 | self.repo.run_cmd(['add', filename], raise_error=True) 273 | except GitError, e: 274 | wx.MessageBox(safe_unicode(e), "Error", style=wx.OK|wx.ICON_ERROR) 275 | except OSError, e: 276 | wx.MessageBox(safe_unicode(e), "Error", style=wx.OK|wx.ICON_ERROR) 277 | 278 | self.Refresh() 279 | 280 | def OnTakeLocal(self, e): 281 | filename = self.unstagedChanges[self.selectedUnstagedItem][0] 282 | 283 | msg = "You are about to stage the HEAD version of file '%s' " \ 284 | "and discard any modifications from the merged commit.\n\n" \ 285 | "Do you want to continue?" % filename 286 | 287 | self._simpleMerge(filename, msg, 2) 288 | 289 | def OnTakeRemote(self, e): 290 | filename = self.unstagedChanges[self.selectedUnstagedItem][0] 291 | 292 | msg = "You are about to stage the MERGE_HEAD version of file '%s' " \ 293 | "and discard the version that is in the current HEAD.\n\n" \ 294 | "Do you want to continue?" % filename 295 | 296 | self._simpleMerge(filename, msg, 3) 297 | 298 | def SetRepo(self, repo): 299 | self.repo = repo 300 | unstagedDict, stagedDict = self.repo.get_status() 301 | 302 | # Unstaged changes 303 | unstagedFiles = unstagedDict.keys() 304 | unstagedFiles.sort() 305 | self.unstagedChanges = [ (f,unstagedDict[f]) for f in unstagedFiles ] 306 | 307 | self.unstagedList.DeleteAllItems() 308 | for c in self.unstagedChanges: 309 | pos = self.unstagedList.GetItemCount() 310 | self.unstagedList.InsertStringItem(pos, '%s (%s)' % (c[0], MOD_DESCS[c[1]])) 311 | 312 | # Unstaged changes 313 | stagedFiles = stagedDict.keys() 314 | stagedFiles.sort() 315 | self.stagedChanges = [ (f,stagedDict[f]) for f in stagedFiles ] 316 | 317 | self.stagedList.DeleteAllItems() 318 | for c in self.stagedChanges: 319 | pos = self.stagedList.GetItemCount() 320 | self.stagedList.InsertStringItem(pos, '%s (%s)' % (c[0], MOD_DESCS[c[1]])) 321 | 322 | # Untracked files 323 | self.untrackedFiles = [ f for f in unstagedDict if unstagedDict[f] == FILE_UNTRACKED ] 324 | 325 | def Refresh(self): 326 | self.SetRepo(self.repo) 327 | 328 | def SaveState(self): 329 | self.mainController.config.WriteInt('IndexSplitterPosition', self.splitter.GetSashPosition()) 330 | 331 | def _parse_diff_output(self, cmd): 332 | output = self.repo.run_cmd(cmd) 333 | result = [] 334 | 335 | items = output.split('\x00') 336 | for i in xrange(len(items)/2): 337 | mod, filename = items[2*i], items[2*i+1] 338 | old_mode, new_mode, old_sha1, new_sha1, mod_type = mod.split(' ') 339 | result.append((filename, mod_type[0])) 340 | 341 | return result 342 | 343 | class CommitWizard(Wizard.Wizard): 344 | def __init__(self, parent, id, repo): 345 | Wizard.Wizard.__init__(self, parent, id) 346 | self.repo = repo 347 | 348 | # --- Detached head warning page --- 349 | self.detachedWarningPage = self.CreateWarningPage( 350 | "Warning: committing to a detached HEAD", 351 | 352 | "Your HEAD is not connected with a local branch. If you commit and then " + 353 | "checkout to a different version later, your commit will be lost.\n\n" + 354 | "Do you still want to continue?", 355 | 356 | [Wizard.BTN_CANCEL, Wizard.BTN_CONTINUE] 357 | ) 358 | 359 | # --- Modified submodules warning page --- 360 | self.submoduleWarningPage = self.CreateWarningPage( 361 | "Warning: uncommitted changes in submodules", 362 | 363 | "There are uncommitted changes in one or more submodules.\n\n" + 364 | "If you want these changes to be saved in this version, " + 365 | "commit the submodules first, then stage the new submodule versions " + 366 | "to the main module.\n\n" + 367 | "Do you still want to continue?", 368 | 369 | [Wizard.BTN_CANCEL, Wizard.BTN_CONTINUE] 370 | ) 371 | 372 | # --- Commit page --- 373 | self.commitPage = self.CreatePage( 374 | "Commit staged changes", 375 | [Wizard.BTN_CANCEL, Wizard.BTN_FINISH] 376 | ) 377 | s = self.commitPage.sizer 378 | 379 | # Author 380 | s.Add(wx.StaticText(self.commitPage, -1, "Author:"), 0, wx.TOP, 5) 381 | 382 | authorSizer = wx.BoxSizer(wx.HORIZONTAL) 383 | s.Add(authorSizer, 0, wx.EXPAND) 384 | 385 | self.authorEntry = wx.TextCtrl(self.commitPage, -1, style=wx.TE_READONLY) 386 | authorSizer.Add(self.authorEntry, 1, wx.ALL, 5) 387 | 388 | self.changeAuthorBtn = wx.Button(self.commitPage, -1, 'Change') 389 | self.Bind(wx.EVT_BUTTON, self.OnAuthorChange, self.changeAuthorBtn) 390 | authorSizer.Add(self.changeAuthorBtn, 0, wx.ALL, 5) 391 | 392 | # Short message 393 | s.Add(wx.StaticText(self.commitPage, -1, "Commit description:"), 0, wx.TOP, 5) 394 | self.shortmsgEntry = wx.TextCtrl(self.commitPage, -1) 395 | s.Add(self.shortmsgEntry, 0, wx.EXPAND | wx.ALL, 5) 396 | 397 | # Details 398 | s.Add(wx.StaticText(self.commitPage, -1, "Commit details:"), 0, wx.TOP, 5) 399 | self.detailsEntry = wx.TextCtrl(self.commitPage, -1, style=wx.TE_MULTILINE) 400 | s.Add(self.detailsEntry, 1, wx.EXPAND | wx.ALL, 5) 401 | 402 | # Amend 403 | self.amendChk = wx.CheckBox(self.commitPage, -1, "Amend (add to previous commit)") 404 | s.Add(self.amendChk, 0, wx.EXPAND | wx.ALL, 5) 405 | self.Bind(wx.EVT_CHECKBOX, self.OnAmendChk, self.amendChk) 406 | 407 | # Get HEAD info for amending 408 | try: 409 | output = self.repo.run_cmd(['log', '-1', '--pretty=format:%an%x00%ae%x00%s%x00%b'], raise_error=True) 410 | self.amendAuthorName, self.amendAuthorEmail, self.amendShortMsg, self.amendDetails = output.split('\x00') 411 | except GitError: 412 | self.amendChk.Disable() 413 | 414 | def OnStart(self): 415 | # Check whether submodules have changes 416 | self.hasSubmoduleChanges = False 417 | for module in self.repo.submodules: 418 | unstagedChanges, stagedChanges = module.get_status() 419 | if unstagedChanges or stagedChanges: 420 | self.hasSubmoduleChanges = True 421 | break 422 | 423 | # Check whether HEAD is detached 424 | self.isDetachedHead = (self.repo.current_branch == None) 425 | 426 | # Get default commit message from MERGE_MSG 427 | mergemsg_file = os.path.join(self.repo.dir, '.git', 'MERGE_MSG') 428 | if os.path.exists(mergemsg_file): 429 | # Short msg 430 | f = open(mergemsg_file) 431 | self.currentShortMsg = safe_unicode(f.readline()) 432 | 433 | # Details 434 | self.currentDetails = u'' 435 | sep = f.readline() 436 | if sep.strip(): 437 | self.currentDetails += safe_unicode(sep) 438 | self.currentDetails += safe_unicode(f.read()) 439 | f.close() 440 | 441 | # Write into text fields 442 | self.shortmsgEntry.SetValue(self.currentShortMsg) 443 | self.detailsEntry.SetValue(self.currentDetails) 444 | 445 | # Get author info 446 | self.authorName = self.repo.run_cmd(['config', 'user.name']).strip() 447 | self.authorEmail = self.repo.run_cmd(['config', 'user.email']).strip() 448 | self.UpdateAuthorEntry() 449 | 450 | # Show first page 451 | if self.isDetachedHead: 452 | self.SetPage(self.detachedWarningPage) 453 | elif self.hasSubmoduleChanges: 454 | self.SetPage(self.submoduleWarningPage) 455 | else: 456 | self.SetPage(self.commitPage) 457 | 458 | def OnAuthorChange(self, e): 459 | # Show author dialog 460 | dialog = AuthorDialog(self, -1, self.authorName, self.authorEmail) 461 | 462 | if dialog.ShowModal(): 463 | self.authorName = dialog.authorName 464 | self.authorEmail = dialog.authorEmail 465 | 466 | # Save new author if necessary 467 | if dialog.saveMode == AUTHOR_PROJECT_DEFAULT: 468 | self.repo.run_cmd(['config', 'user.name', self.authorName]) 469 | self.repo.run_cmd(['config', 'user.email', self.authorEmail]) 470 | elif dialog.saveMode == AUTHOR_GLOBAL_DEFAULT: 471 | self.repo.run_cmd(['config', '--global', 'user.name', self.authorName]) 472 | self.repo.run_cmd(['config', '--global', 'user.email', self.authorEmail]) 473 | 474 | # Update author entry 475 | self.UpdateAuthorEntry() 476 | 477 | def UpdateAuthorEntry(self, name=None, email=None): 478 | if name == None: 479 | name = self.authorName 480 | if email == None: 481 | email = self.authorEmail 482 | 483 | self.authorEntry.SetValue(u"%s <%s>" % (safe_unicode(name), safe_unicode(email))) 484 | 485 | def OnAmendChk(self, e): 486 | is_amend = self.amendChk.GetValue() 487 | 488 | if is_amend: 489 | # Save current commit message 490 | self.currentShortMsg = self.shortmsgEntry.GetValue() 491 | self.currentDetails = self.detailsEntry.GetValue() 492 | 493 | # Replace commit message with the one in HEAD 494 | self.shortmsgEntry.SetValue(safe_unicode(self.amendShortMsg)) 495 | self.detailsEntry.SetValue(safe_unicode(self.amendDetails)) 496 | 497 | # Replace author, disable author change 498 | self.UpdateAuthorEntry(self.amendAuthorName, self.amendAuthorEmail) 499 | self.changeAuthorBtn.Disable() 500 | else: 501 | # Save modified amend message 502 | self.amendShortMsg = self.shortmsgEntry.GetValue() 503 | self.amendDetails = self.detailsEntry.GetValue() 504 | 505 | # Write back old commit message 506 | self.shortmsgEntry.SetValue(safe_unicode(self.currentShortMsg)) 507 | self.detailsEntry.SetValue(safe_unicode(self.currentDetails)) 508 | 509 | # Write back chosen author, enable author change 510 | self.UpdateAuthorEntry() 511 | self.changeAuthorBtn.Enable() 512 | 513 | def OnButtonClicked(self, button): 514 | if button == Wizard.BTN_CANCEL: 515 | self.EndWizard(0) 516 | 517 | if self.currentPage == self.detachedWarningPage: 518 | if self.hasSubmoduleChanges: 519 | self.SetPage(self.submoduleWarningPage) 520 | else: 521 | self.SetPage(self.commitPage) 522 | elif self.currentPage == self.submoduleWarningPage: 523 | self.SetPage(self.commitPage) 524 | 525 | # Commit page 526 | elif self.currentPage == self.commitPage: 527 | if button == Wizard.BTN_PREV: 528 | self.SetPage(self.submoduleWarningPage) 529 | elif button == Wizard.BTN_FINISH: 530 | if self.Validate(): 531 | # Commit changes 532 | short_msg = self.shortmsgEntry.GetValue() 533 | details = self.detailsEntry.GetValue() 534 | is_amend = self.amendChk.GetValue() 535 | 536 | if len(details.strip()): 537 | msg = "%s\n\n%s" % (short_msg, details) 538 | else: 539 | msg = short_msg 540 | 541 | try: 542 | self.repo.commit(self.authorName, self.authorEmail, msg, amend=is_amend) 543 | except GitError, msg: 544 | wx.MessageBox( 545 | safe_unicode(msg), 546 | "Error", 547 | style=wx.ICON_ERROR | wx.OK 548 | ) 549 | 550 | self.EndWizard(0) 551 | else: 552 | # Show alert 553 | if len(self.authorName) == 0 or len(self.authorEmail) == 0: 554 | errormsg = "Please set author name!" 555 | else: 556 | errormsg = "Please fill in commit description!" 557 | 558 | msg = wx.MessageDialog( 559 | self, 560 | errormsg, 561 | "Notice", 562 | wx.ICON_EXCLAMATION | wx.OK 563 | ) 564 | msg.ShowModal() 565 | 566 | def Validate(self): 567 | return len(self.authorName) != 0 and len(self.authorEmail) != 0 and \ 568 | len(self.shortmsgEntry.GetValue()) != 0 569 | 570 | AUTHOR_NOT_DEFAULT = 0 571 | AUTHOR_PROJECT_DEFAULT = 1 572 | AUTHOR_GLOBAL_DEFAULT = 2 573 | 574 | class AuthorDialog(wx.Dialog): 575 | def __init__(self, parent, id, default_name, default_email): 576 | wx.Dialog.__init__(self, parent, id, size=(350,280), title="Change author...") 577 | 578 | self.sizer = wx.BoxSizer(wx.VERTICAL) 579 | self.SetSizer(self.sizer) 580 | 581 | self.authorName = default_name 582 | self.authorEmail = default_email 583 | self.saveMode = AUTHOR_NOT_DEFAULT 584 | 585 | # Name 586 | self.sizer.Add(wx.StaticText(self, -1, "Name:"), 0, wx.ALL, 5) 587 | self.nameEntry = wx.TextCtrl(self, -1) 588 | self.nameEntry.SetValue(default_name) 589 | self.sizer.Add(self.nameEntry, 0, wx.EXPAND | wx.ALL, 5) 590 | 591 | # Email 592 | self.sizer.Add(wx.StaticText(self, -1, "E-mail:"), 0, wx.ALL, 5) 593 | self.emailEntry = wx.TextCtrl(self, -1) 594 | self.emailEntry.SetValue(default_email) 595 | self.sizer.Add(self.emailEntry, 0, wx.EXPAND | wx.ALL, 5) 596 | 597 | # Save mode 598 | self.saveModeBtns = wx.RadioBox(self, -1, "Save mode:", 599 | style=wx.RA_SPECIFY_ROWS, 600 | choices=["Use only for this commit", 601 | "Save as project default", 602 | "Save as global default"] 603 | ) 604 | self.sizer.Add(self.saveModeBtns, 1, wx.EXPAND | wx.ALL, 5) 605 | 606 | # Finish buttons 607 | self.buttonSizer = wx.BoxSizer(wx.HORIZONTAL) 608 | self.sizer.Add(self.buttonSizer, 0, wx.ALL | wx.ALIGN_RIGHT, 5) 609 | 610 | self.okBtn = wx.Button(self, -1, 'OK') 611 | self.buttonSizer.Add(self.okBtn, 1, wx.ALL, 5) 612 | self.Bind(wx.EVT_BUTTON, self.OnOk, self.okBtn) 613 | 614 | self.cancelBtn = wx.Button(self, -1, 'Cancel') 615 | self.buttonSizer.Add(self.cancelBtn, 1, wx.ALL, 5) 616 | self.Bind(wx.EVT_BUTTON, self.OnCancel, self.cancelBtn) 617 | 618 | def OnOk(self, e): 619 | name = self.nameEntry.GetValue().strip() 620 | email = self.emailEntry.GetValue().strip() 621 | 622 | if name and email: 623 | self.authorName = name 624 | self.authorEmail = email 625 | self.saveMode = self.saveModeBtns.GetSelection() 626 | self.EndModal(1) 627 | 628 | def OnCancel(self, e): 629 | self.EndModal(0) 630 | 631 | -------------------------------------------------------------------------------- /stupidgit_gui/MainWindow.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | from git import * 3 | from HistoryTab import HistoryTab 4 | from IndexTab import IndexTab 5 | from AboutDialog import ShowAboutDialog 6 | import wx 7 | from wx import xrc 8 | from wxutil import * 9 | 10 | ID_NEWWINDOW = 101 11 | ID_CLOSEWINDOW = 102 12 | 13 | TAB_HISTORY = 0 14 | TAB_INDEX = 1 15 | 16 | class MainWindow(object): 17 | def __init__(self, repo): 18 | # Load frame from XRC 19 | self.frame = LoadFrame(None, 'MainWindow') 20 | 21 | # Read default window size 22 | self.config = wx.Config('stupidgit') 23 | width = self.config.ReadInt('MainWindowWidth', 550) 24 | height = self.config.ReadInt('MainWindowHeight', 650) 25 | self.frame.SetSize((width, height)) 26 | 27 | # Create module choice 28 | toolbar = self.frame.GetToolBar() 29 | self.moduleChoice = wx.Choice(toolbar, -1) 30 | if sys.platform == 'darwin': 31 | # Don't ask me why, but that's how the control is positioned to middle... 32 | self.moduleChoice.SetSize((200,15)) 33 | else: 34 | self.moduleChoice.SetSize((200,-1)) 35 | 36 | self.moduleChoice.Bind(wx.EVT_CHOICE, self.OnModuleChosen) 37 | toolbar.InsertControl(0, self.moduleChoice) 38 | toolbar.Realize() 39 | 40 | # Setup events 41 | SetupEvents(self.frame, [ 42 | (None, wx.EVT_CLOSE, self.OnWindowClosed), 43 | 44 | ('tabs', wx.EVT_NOTEBOOK_PAGE_CHANGED, self.OnTabChanged), 45 | 46 | ('quitMenuItem', wx.EVT_MENU, self.OnExit), 47 | ('openMenuItem', wx.EVT_MENU, self.OnOpenRepository), 48 | ('newWindowMenuItem', wx.EVT_MENU, self.OnNewWindow), 49 | ('closeWindowMenuItem', wx.EVT_MENU, self.OnCloseWindow), 50 | ('aboutMenuItem', wx.EVT_MENU, self.OnAbout), 51 | ('refreshMenuItem', wx.EVT_MENU, self.OnRefresh), 52 | 53 | ('refreshTool', wx.EVT_TOOL, self.OnRefresh), 54 | ('refreshButton', wx.EVT_BUTTON, self.OnRefresh), 55 | ]) 56 | 57 | # Setup tabs 58 | self.historyTab = HistoryTab(self) 59 | self.indexTab = IndexTab(self) 60 | self.selectedTab = 0 61 | 62 | # Load repository 63 | self.SetMainRepo(repo) 64 | 65 | def Show(self, doShow=True): 66 | self.frame.Show(doShow) 67 | 68 | # Sash positions must be set after the window is really shown. 69 | # Otherwise the sash position settings will be silently ignored :-/ 70 | if doShow: 71 | self.OnWindowCreated(None) 72 | 73 | def OnNewWindow(self, e): 74 | win = MainWindow(None) 75 | win.Show(True) 76 | 77 | def OnWindowCreated(self, e): 78 | wx.TheApp.OnWindowCreated(self) 79 | self.indexTab.OnCreated() 80 | self.historyTab.OnCreated() 81 | 82 | def OnCloseWindow(self, e): 83 | self.frame.Close() 84 | 85 | def OnWindowClosed(self, e): 86 | # Save window geometry 87 | size = self.frame.GetSize() 88 | self.config.WriteInt('MainWindowWidth', size.GetWidth()) 89 | self.config.WriteInt('MainWindowHeight', size.GetHeight()) 90 | self.historyTab.SaveState() 91 | self.indexTab.SaveState() 92 | 93 | # Close window 94 | self.frame.Destroy() 95 | wx.TheApp.OnWindowClosed(self) 96 | 97 | def OnOpenRepository(self, e): 98 | repodir = wx.DirSelector("Open repository") 99 | if not repodir: return 100 | 101 | try: 102 | repo = Repository(repodir) 103 | 104 | if self.mainRepo: 105 | new_win = MainWindow(repo) 106 | new_win.Show(True) 107 | else: 108 | self.SetMainRepo(repo) 109 | except GitError, msg: 110 | wx.MessageBox(str(msg), 'Error', style=wx.OK|wx.ICON_ERROR) 111 | 112 | def OnTabChanged(self, e): 113 | self.selectedTab = e.GetSelection() 114 | 115 | def OnAbout(self, e): 116 | ShowAboutDialog() 117 | 118 | def OnExit(self, e): 119 | wx.TheApp.ExitApp() 120 | 121 | def SetMainRepo(self, repo): 122 | self.mainRepo = repo 123 | 124 | if repo: 125 | title = "stupidgit - %s" % os.path.basename(repo.dir) 126 | 127 | for module in self.mainRepo.all_modules: 128 | self.moduleChoice.Append(module.name) 129 | 130 | self.moduleChoice.Select(0) 131 | self.SetRepo(repo) 132 | 133 | else: 134 | title = "stupidgit" 135 | self.currentRepo = None 136 | 137 | self.frame.SetTitle(title) 138 | 139 | def SetRepo(self, repo): 140 | self.currentRepo = repo 141 | self.currentRepo.load_refs() 142 | self.historyTab.SetRepo(repo) 143 | self.indexTab.SetRepo(repo) 144 | 145 | def ReloadRepo(self): 146 | self.currentRepo.load_refs() 147 | self.SetRepo(self.currentRepo) 148 | 149 | # Load referenced version in submodules 150 | for submodule in self.currentRepo.submodules: 151 | submodule.load_refs() 152 | 153 | def OnModuleChosen(self, e): 154 | module_name = e.GetString() 155 | module = [m for m in self.mainRepo.all_modules if m.name == module_name] 156 | if module: 157 | self.SetRepo(module[0]) 158 | 159 | def OnRefresh(self, e): 160 | self.currentRepo.load_refs() 161 | self.SetRepo(self.currentRepo) 162 | 163 | -------------------------------------------------------------------------------- /stupidgit_gui/PasswordDialog.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | class PasswordDialog(wx.Dialog): 4 | def __init__(self, parent, id, title): 5 | wx.Dialog.__init__(self, parent, id) 6 | self.SetTitle('SSH authentication') 7 | 8 | if not title: 9 | title = 'Password:' 10 | self.password = None 11 | 12 | sizer = wx.BoxSizer(wx.VERTICAL) 13 | self.SetSizer(sizer) 14 | 15 | txt = wx.StaticText(self, -1, title) 16 | sizer.Add(txt, 1, wx.EXPAND | wx.ALL, 10) 17 | 18 | self.passwordEntry = wx.TextCtrl(self, -1, style=wx.TE_PASSWORD | wx.TE_PROCESS_ENTER) 19 | self.passwordEntry.Bind(wx.EVT_TEXT_ENTER, self.OnOk) 20 | sizer.Add(self.passwordEntry, 0, wx.EXPAND | wx.ALL, 10) 21 | 22 | btnSizer = wx.BoxSizer(wx.HORIZONTAL) 23 | sizer.Add(btnSizer, 0, wx.EXPAND | wx.ALL, 10) 24 | 25 | btnOk = wx.Button(self, -1, 'OK') 26 | btnOk.Bind(wx.EVT_BUTTON, self.OnOk) 27 | btnSizer.Add(btnOk, 0, wx.EXPAND | wx.RIGHT, 5) 28 | 29 | btnCancel = wx.Button(self, -1, 'Cancel') 30 | btnCancel.Bind(wx.EVT_BUTTON, self.OnCancel) 31 | btnSizer.Add(btnCancel, 0, wx.EXPAND | wx.LEFT, 5) 32 | 33 | self.Fit() 34 | 35 | def OnOk(self, e): 36 | self.password = self.passwordEntry.GetValue() 37 | self.EndModal(1) 38 | 39 | def OnCancel(self, e): 40 | self.EndModal(0) 41 | 42 | -------------------------------------------------------------------------------- /stupidgit_gui/PushDialogs.py: -------------------------------------------------------------------------------- 1 | import wx 2 | from wxutil import * 3 | from util import * 4 | from git import * 5 | 6 | class PushSetupDialog(object): 7 | def __init__(self, parent, id, repo): 8 | self.dialog = LoadDialog(parent, 'PushDialog') 9 | self.dialog.SetMinSize((400, -1)) 10 | self.repo = repo 11 | 12 | # Widgets 13 | self.remoteChooser = GetWidget(self.dialog, 'remoteChooser') 14 | self.branchChooser = GetWidget(self.dialog, 'branchChooser') 15 | self.branchEntry = GetWidget(self.dialog, 'branchEntry') 16 | self.warningLabel = GetWidget(self.dialog, 'warningLabel') 17 | self.detailsButton = GetWidget(self.dialog, 'detailsButton') 18 | self.forcePushCheckbox = GetWidget(self.dialog, 'forcePushCheckbox') 19 | 20 | # Events 21 | SetupEvents(self.dialog, [ 22 | ('remoteChooser', wx.EVT_CHOICE, self.OnRemoteChosen), 23 | ('branchChooser', wx.EVT_CHOICE, self.OnBranchChosen), 24 | ('branchEntry', wx.EVT_TEXT, self.OnBranchText), 25 | ('forcePushCheckbox', wx.EVT_CHECKBOX, self.OnForcePush) 26 | ]) 27 | 28 | # Setup remotes 29 | self.remoteChoices = [name for name,url in self.repo.remotes.iteritems()] 30 | self.remoteChoices.sort() 31 | 32 | self.remoteChooser = GetWidget(self.dialog, 'remoteChooser') 33 | for remote in self.remoteChoices: 34 | self.remoteChooser.Append(remote) 35 | self.remoteChooser.Select(0) 36 | self.OnRemoteChosen() 37 | 38 | # Setup initial settings 39 | self.forcePush = False 40 | self.HideWarning() 41 | 42 | def ShowModal(self): 43 | self.dialog.Fit() 44 | return self.dialog.ShowModal() 45 | 46 | def OnRemoteChosen(self, e=None): 47 | remoteIndex = self.remoteChooser.GetSelection() 48 | self.selectedRemote = self.remoteChoices[remoteIndex] 49 | 50 | # Update branches 51 | prefix = '%s/' % self.selectedRemote 52 | self.remoteBranches = [b[len(prefix):] for b in self.repo.remote_branches.keys() if b.startswith(prefix)] 53 | self.remoteBranches.sort() 54 | 55 | self.branchChooser.Clear() 56 | for branch in self.remoteBranches: 57 | self.branchChooser.Append(branch) 58 | self.branchChooser.Append('New branch...') 59 | self.branchChooser.Select(0) 60 | self.OnBranchChosen() 61 | 62 | def OnBranchChosen(self, e=None): 63 | branchIndex = self.branchChooser.GetSelection() 64 | if branchIndex == len(self.remoteBranches): 65 | self.branchEntry.Show() 66 | self.selectedBranch = self.branchEntry.GetValue() 67 | else: 68 | self.branchEntry.Hide() 69 | self.selectedBranch = self.remoteBranches[branchIndex] 70 | 71 | self.dialog.Layout() 72 | self.dialog.Fit() 73 | 74 | def OnBranchText(self, e): 75 | self.selectedBranch = self.branchEntry.GetValue() 76 | 77 | def OnForcePush(self, e): 78 | self.forcePush = self.forcePushCheckbox.GetValue() 79 | 80 | def HideWarning(self): 81 | self.warningLabel.Hide() 82 | self.detailsButton.Hide() 83 | 84 | class PushProgressDialog(object): 85 | def __init__(self, parent, id, repo, remote, commit, remoteBranch, forcePush): 86 | self.parent = parent 87 | self.repo = repo 88 | self.remote = remote 89 | self.commit = commit 90 | self.remoteBranch = remoteBranch 91 | self.forcePush = forcePush 92 | 93 | # Setup dialog 94 | self.dialog = LoadDialog(parent, 'PushProgressDialog') 95 | self.dialog.SetMinSize((350, -1)) 96 | self.dialog.SetTitle('Pushing to %s...' % remote) 97 | 98 | # Widgets 99 | self.progressLabel = GetWidget(self.dialog, 'progressLabel') 100 | self.progressBar = GetWidget(self.dialog, 'progressBar') 101 | SetupEvents(self.dialog, [ 102 | ('cancelButton', wx.EVT_BUTTON, self.OnCancel) 103 | ]) 104 | 105 | def ShowModal(self): 106 | self.progressLabel.SetLabel('Connecting to remote repository...') 107 | self.progressBar.Pulse() 108 | self.dialog.Fit() 109 | 110 | self.pushThread = self.repo.push_bg(self.remote, self.commit, self.remoteBranch, self.forcePush, self.ProgressCallback) 111 | return self.dialog.ShowModal() 112 | 113 | def ProgressCallback(self, event, param): 114 | if event == TRANSFER_COMPRESSING: 115 | wx.CallAfter(self.progressLabel.SetLabel, "Compressing objects...") 116 | wx.CallAfter(self.progressBar.SetValue, param) 117 | elif event == TRANSFER_WRITING: 118 | wx.CallAfter(self.progressLabel.SetLabel, "Writing objects...") 119 | wx.CallAfter(self.progressBar.SetValue, param) 120 | elif event == TRANSFER_ENDED: 121 | wx.CallAfter(self.OnPushEnded, param) 122 | 123 | def OnPushEnded(self, error): 124 | if error: 125 | wx.MessageBox(safe_unicode(error), 'Error', style=wx.OK|wx.ICON_ERROR) 126 | self.dialog.EndModal(0) 127 | else: 128 | self.dialog.EndModal(1) 129 | 130 | def OnCancel(self, e): 131 | self.pushThread.abort() 132 | self.dialog.EndModal(0) 133 | -------------------------------------------------------------------------------- /stupidgit_gui/SwitchWizard.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import wx 3 | import git 4 | import platformspec 5 | import os 6 | from Dialogs import CommitListDialog, UncommittedFilesDialog 7 | 8 | from util import * 9 | 10 | SWMODE_EXISTING_BRANCH = 'Checkout branch...' 11 | SWMODE_NEW_BRANCH = 'Checkout as new branch...' 12 | SWMODE_DETACHED_HEAD = 'Checkout as detached HEAD' 13 | SWMODE_MOVE_BRANCH = 'Move branch here...' 14 | 15 | WORKDIR_CHECKOUT = 0 16 | WORKDIR_KEEP = 1 17 | 18 | UNCOMMITTED_SAFE_MODE = 0 19 | UNCOMMITTED_MERGE = 1 20 | UNCOMMITTED_DISCARD = 2 21 | 22 | SUBMODULE_MOVE_BRANCH = 0 23 | SUBMODULE_DETACHED_HEAD = 1 24 | SUBMODULE_NEW_BRANCH = 2 25 | 26 | class SwitchWizard(wx.Dialog): 27 | def __init__(self, parent, id, repo, targetCommit): 28 | wx.Dialog.__init__(self, parent, id) 29 | 30 | self.repo = repo 31 | self.targetCommit = targetCommit 32 | 33 | # Basic layout 34 | self.SetTitle('Switch to version...') 35 | self.sizer = wx.BoxSizer(wx.VERTICAL) 36 | self.SetSizer(self.sizer) 37 | 38 | sizer = wx.BoxSizer(wx.VERTICAL) 39 | self.sizer.Add(sizer, 1, wx.ALL, 5) 40 | 41 | choiceTopPadding = 4 if sys.platform == 'darwin' else 0 42 | 43 | # Detect capabilities 44 | self.targetBranches = [ branch for (branch,sha1) in self.repo.branches.iteritems() if sha1 == targetCommit.sha1 ] 45 | self.targetBranches.sort() 46 | 47 | self.allBranches = [ branch for (branch,sha1) in self.repo.branches.iteritems() ] 48 | 49 | if self.targetBranches: 50 | self.switchModes = [SWMODE_EXISTING_BRANCH, SWMODE_NEW_BRANCH, SWMODE_DETACHED_HEAD, SWMODE_MOVE_BRANCH] 51 | branchChoices = self.targetBranches 52 | elif self.allBranches: 53 | self.switchModes = [SWMODE_NEW_BRANCH, SWMODE_DETACHED_HEAD, SWMODE_MOVE_BRANCH] 54 | branchChoices = self.allBranches 55 | else: 56 | self.switchModes = [SWMODE_NEW_BRANCH, SWMODE_DETACHED_HEAD] 57 | branchChoices = [] 58 | 59 | self.hasUncommittedChanges = (len(self.repo.get_unified_status()) > 0) 60 | 61 | self.hasSubmodules = (len(self.repo.submodules) > 0) 62 | 63 | # Default values 64 | self.switchMode = self.switchModes[0] 65 | self.workdirMode = WORKDIR_CHECKOUT 66 | self.uncommittedMode = UNCOMMITTED_SAFE_MODE 67 | self.submoduleSwitch = False 68 | self.submoduleMode = SUBMODULE_MOVE_BRANCH 69 | self.newBranchName = '' 70 | self.submoduleBranchName = '' 71 | if self.switchMode == SWMODE_EXISTING_BRANCH: 72 | self.targetBranch = self.targetBranches[0] 73 | else: 74 | self.targetBranch = '' 75 | self.error = None 76 | self.submoduleWarnings = {} 77 | 78 | # -------------------- Switch mode --------------------- 79 | # Switch mode 80 | self.swmodeSizer = wx.BoxSizer(wx.HORIZONTAL) 81 | sizer.Add(self.swmodeSizer, 0, wx.EXPAND | wx.ALL, 5) 82 | 83 | self.swmodeSizer.Add(wx.StaticText(self, -1, 'Switch mode:'), 0, wx.ALIGN_CENTRE_VERTICAL | wx.RIGHT, 5) 84 | self.swmodeChoices = wx.Choice(self, -1, choices=self.switchModes) 85 | self.swmodeSizer.Add(self.swmodeChoices, 0, wx.ALIGN_CENTRE_VERTICAL | wx.TOP | wx.RIGHT, choiceTopPadding) 86 | self.swmodeChoices.Select(0) 87 | self.Bind(wx.EVT_CHOICE, self.OnSwitchModeChosen, self.swmodeChoices) 88 | 89 | # Branch selector 90 | self.branchChoices = wx.Choice(self, -1, choices=branchChoices) 91 | self.swmodeSizer.Add(self.branchChoices, 1, wx.ALIGN_CENTRE_VERTICAL | wx.TOP | wx.RIGHT, choiceTopPadding) 92 | if branchChoices: 93 | self.branchChoices.Select(0) 94 | self.branchChoices.Bind(wx.EVT_CHOICE, self.OnBranchChosen) 95 | self.branchChoices.Show(self.switchModes[0] != SWMODE_NEW_BRANCH) 96 | 97 | # New branch text box 98 | self.newBranchTxt = wx.TextCtrl(self, -1) 99 | self.newBranchTxt.Bind(wx.EVT_TEXT, self.Validate) 100 | self.swmodeSizer.Add(self.newBranchTxt, 1, wx.ALIGN_CENTRE_VERTICAL | wx.LEFT | wx.RIGHT, 5) 101 | self.newBranchTxt.Show(self.switchModes[0] == SWMODE_NEW_BRANCH) 102 | 103 | # ------------------ Working directory ------------------ 104 | # Static box 105 | self.workdirBox = wx.StaticBox(self, -1, 'Working directory:') 106 | self.workdirSizer = wx.StaticBoxSizer(self.workdirBox, wx.VERTICAL) 107 | sizer.Add(self.workdirSizer, 0, wx.EXPAND | wx.ALL, 5) 108 | 109 | # Radio buttons 110 | btn = wx.RadioButton(self, -1, 'Switch file contents to new version', style=wx.RB_GROUP) 111 | btn.SetValue(True) 112 | btn.Bind(wx.EVT_RADIOBUTTON, lambda e:self.OnWorkdirMode(WORKDIR_CHECKOUT)) 113 | self.workdirSizer.Add(btn, 0, wx.ALL, 5) 114 | 115 | btn = wx.RadioButton(self, -1, 'Keep files unchanged') 116 | btn.Bind(wx.EVT_RADIOBUTTON, lambda e:self.OnWorkdirMode(WORKDIR_KEEP)) 117 | self.workdirSizer.Add(btn, 0, wx.ALL, 5) 118 | 119 | # ------------------ Uncommitted changes ----------------- 120 | if self.hasUncommittedChanges: 121 | self.uncommittedBox = wx.StaticBox(self, -1, 'Uncommitted changes:') 122 | self.uncommittedSizer = wx.StaticBoxSizer(self.uncommittedBox, wx.VERTICAL) 123 | sizer.Add(self.uncommittedSizer, 0, wx.EXPAND | wx.ALL, 5) 124 | 125 | # Radio buttons 126 | self.uncommittedButtons = [] 127 | 128 | btn = wx.RadioButton(self, -1, 'Switch only if these files need not to be modified', style=wx.RB_GROUP) 129 | btn.SetValue(True) 130 | btn.Bind(wx.EVT_RADIOBUTTON, lambda e:self.OnUncommittedMode(UNCOMMITTED_SAFE_MODE)) 131 | self.uncommittedButtons.append(btn) 132 | self.uncommittedSizer.Add(btn, 0, wx.ALL, 5) 133 | 134 | btn = wx.RadioButton(self, -1, 'Merge uncommitted changes into new version') 135 | btn.Bind(wx.EVT_RADIOBUTTON, lambda e:self.OnUncommittedMode(UNCOMMITTED_MERGE)) 136 | self.uncommittedButtons.append(btn) 137 | self.uncommittedSizer.Add(btn, 0, wx.ALL, 5) 138 | 139 | btn = wx.RadioButton(self, -1, 'Discard uncommitted changes') 140 | btn.Bind(wx.EVT_RADIOBUTTON, lambda e:self.OnUncommittedMode(UNCOMMITTED_DISCARD)) 141 | self.uncommittedButtons.append(btn) 142 | self.uncommittedSizer.Add(btn, 0, wx.ALL, 5) 143 | 144 | btn = wx.Button(self, -1, 'Review uncommitted changes') 145 | btn.Bind(wx.EVT_BUTTON, self.OnReviewUncommittedChanges) 146 | self.uncommittedSizer.Add(btn, 0, wx.ALL, 5) 147 | 148 | # ----------------------- Submodules ---------------------- 149 | if self.hasSubmodules: 150 | self.submoduleBox = wx.StaticBox(self, -1, 'Submodules:') 151 | self.submoduleSizer = wx.StaticBoxSizer(self.submoduleBox, wx.VERTICAL) 152 | sizer.Add(self.submoduleSizer, 0, wx.EXPAND | wx.ALL, 5) 153 | 154 | # Submodule checkbox 155 | self.submoduleChk = wx.CheckBox(self, -1, 'Switch submodules to referenced version') 156 | self.submoduleChk.SetValue(False) 157 | self.submoduleChk.Bind(wx.EVT_CHECKBOX, self.OnSubmoduleSwitch) 158 | self.submoduleSizer.Add(self.submoduleChk, 0, wx.ALL, 5) 159 | 160 | # Radio buttons 161 | self.submoduleModeButtons = [] 162 | 163 | btn = wx.RadioButton(self, -1, 'Move currently selected branches (only if no commits will be lost)', style=wx.RB_GROUP) 164 | btn.SetValue(1) 165 | btn.Enable(False) 166 | btn.Bind(wx.EVT_RADIOBUTTON, lambda e:self.OnSubmoduleMode(SUBMODULE_MOVE_BRANCH)) 167 | self.submoduleSizer.Add(btn, 0, wx.ALL, 5) 168 | self.submoduleModeButtons.append(btn) 169 | 170 | btn = wx.RadioButton(self, -1, 'Switch to detached HEAD') 171 | btn.Bind(wx.EVT_RADIOBUTTON, lambda e:self.OnSubmoduleMode(SUBMODULE_DETACHED_HEAD)) 172 | btn.Enable(False) 173 | self.submoduleSizer.Add(btn, 0, wx.ALL, 5) 174 | self.submoduleModeButtons.append(btn) 175 | 176 | s = wx.BoxSizer(wx.HORIZONTAL) 177 | self.submoduleSizer.Add(s, 0, wx.ALL, 5) 178 | 179 | btn = wx.RadioButton(self, -1, 'Switch to new branch:') 180 | btn.Bind(wx.EVT_RADIOBUTTON, lambda e:self.OnSubmoduleMode(SUBMODULE_NEW_BRANCH)) 181 | btn.Enable(False) 182 | s.Add(btn, 0) 183 | self.submoduleModeButtons.append(btn) 184 | 185 | # New branch text field 186 | self.submoduleBranchTxt = wx.TextCtrl(self, -1) 187 | self.submoduleBranchTxt.Bind(wx.EVT_TEXT, self.Validate) 188 | s.Add(self.submoduleBranchTxt, 0, wx.LEFT, 7) 189 | self.submoduleBranchTxt.Enable(False) 190 | 191 | # Status message 192 | self.statusSizer = wx.BoxSizer(wx.HORIZONTAL) 193 | sizer.Add(self.statusSizer, 0, wx.EXPAND | wx.TOP, 15) 194 | 195 | self.statusMsg = wx.StaticText(self, -1, '') 196 | self.statusSizer.Add(self.statusMsg, 1, wx.LEFT, 5) 197 | 198 | self.statusButton = wx.Button(self, -1, 'Details') 199 | self.statusButton.Bind(wx.EVT_BUTTON, self.OnDetailsButton) 200 | self.statusSizer.Add(self.statusButton, 0, wx.LEFT | wx.RIGHT, 5) 201 | 202 | # Finish buttons 203 | s = wx.BoxSizer(wx.HORIZONTAL) 204 | sizer.Add(s, 0, wx.TOP | wx.BOTTOM | wx.ALIGN_RIGHT, 15) 205 | 206 | self.okButton = wx.Button(self, -1, 'OK') 207 | self.okButton.Bind(wx.EVT_BUTTON, self.OnOkClicked) 208 | s.Add(self.okButton, 0, wx.LEFT | wx.RIGHT, 5) 209 | 210 | self.cancelButton = wx.Button(self, -1, 'Cancel') 211 | self.cancelButton.Bind(wx.EVT_BUTTON, self.OnCancelClicked) 212 | s.Add(self.cancelButton, 0, wx.LEFT | wx.RIGHT, 5) 213 | 214 | self.Validate() 215 | 216 | # Resize window 217 | self.Fit() 218 | self.Layout() 219 | w,h = self.GetSize() 220 | self.SetSize((max(450,w),h)) 221 | 222 | def Validate(self, e=None): 223 | isValid = True 224 | 225 | # If we are on a detached head, we may always lose commits 226 | self.lostCommits = [] 227 | if not self.repo.current_branch: 228 | self.lostCommits = self.repo.get_lost_commits('HEAD', self.targetCommit.sha1) 229 | 230 | # Validate branch name 231 | if self.switchMode == SWMODE_NEW_BRANCH: 232 | self.newBranchName = self.newBranchTxt.GetValue().strip() 233 | if not self.newBranchName: 234 | isValid = False 235 | self.statusMsg.SetLabel('Enter the name of the new branch!') 236 | elif self.newBranchName in self.repo.branches.keys(): 237 | isValid = False 238 | self.statusMsg.SetLabel("Branch '%s' already exists!" % self.newBranchName) 239 | 240 | # Check lost commits of moved branch 241 | elif self.switchMode == SWMODE_MOVE_BRANCH: 242 | lostCommits = self.repo.get_lost_commits('refs/heads/' + self.targetBranch, self.targetCommit.sha1) 243 | for c in lostCommits: 244 | if c not in self.lostCommits: 245 | self.lostCommits.append(c) 246 | 247 | # Check submodule branch name 248 | if isValid and self.submoduleMode == SUBMODULE_NEW_BRANCH: 249 | self.submoduleBranchName = self.submoduleBranchTxt.GetValue().strip() 250 | if not self.submoduleBranchName: 251 | isValid = False 252 | self.statusMsg.SetLabel('Enter the name of the new submodule branch!') 253 | 254 | # Enable / disable controls according to validity 255 | if isValid: 256 | self.okButton.Enable(1) 257 | 258 | # Show warning about lost commits 259 | if self.lostCommits: 260 | if len(self.lostCommits) == 1: 261 | msg = 'WARNING: You will permanently lose a commit!' 262 | else: 263 | msg = 'WARNING: You will permanently lose %d commits!' % len(self.lostCommits) 264 | self.statusMsg.SetLabel(msg) 265 | self.statusButton.Show(1) 266 | else: 267 | self.statusMsg.SetLabel('') 268 | self.statusButton.Show(0) 269 | else: 270 | self.okButton.Enable(0) 271 | self.statusButton.Show(0) 272 | 273 | # Refresh layout 274 | self.statusSizer.Layout() 275 | 276 | def OnSwitchModeChosen(self, e): 277 | self.switchMode = e.GetString() 278 | 279 | if self.switchMode == SWMODE_EXISTING_BRANCH: 280 | self.branchChoices.Show(1) 281 | self.newBranchTxt.Show(0) 282 | 283 | self.targetBranch = self.targetBranches[0] 284 | self.branchChoices.Clear() 285 | for branch in self.targetBranches: 286 | self.branchChoices.Append(branch) 287 | self.branchChoices.Select(0) 288 | 289 | elif self.switchMode == SWMODE_NEW_BRANCH: 290 | self.branchChoices.Show(0) 291 | self.newBranchTxt.Show(1) 292 | 293 | self.newBranchTxt.SetValue('') 294 | 295 | elif self.switchMode == SWMODE_DETACHED_HEAD: 296 | self.branchChoices.Show(0) 297 | self.newBranchTxt.Show(0) 298 | 299 | elif self.switchMode == SWMODE_MOVE_BRANCH: 300 | self.branchChoices.Show(1) 301 | self.newBranchTxt.Show(0) 302 | 303 | # Select current branch by default 304 | if self.repo.current_branch: 305 | branchIndex = self.allBranches.index(self.repo.current_branch) 306 | else: 307 | branchIndex = 0 308 | 309 | self.targetBranch = self.allBranches[branchIndex] 310 | self.branchChoices.Clear() 311 | for branch in self.allBranches: 312 | self.branchChoices.Append(branch) 313 | self.branchChoices.Select(branchIndex) 314 | 315 | self.swmodeSizer.RecalcSizes() 316 | self.Validate() 317 | 318 | def OnBranchChosen(self, e): 319 | if self.switchMode in [SWMODE_EXISTING_BRANCH, SWMODE_MOVE_BRANCH]: 320 | self.targetBranch = e.GetString() 321 | else: 322 | self.targetBranch = '' 323 | 324 | self.Validate() 325 | 326 | def OnWorkdirMode(self, workdirMode): 327 | self.workdirMode = workdirMode 328 | 329 | if self.hasUncommittedChanges: 330 | uncommittedModeEnabled = (workdirMode == WORKDIR_CHECKOUT) 331 | for btn in self.uncommittedButtons: 332 | btn.Enable(uncommittedModeEnabled) 333 | 334 | self.Validate() 335 | 336 | def OnUncommittedMode(self, uncommittedMode): 337 | self.uncommittedMode = uncommittedMode 338 | self.Validate() 339 | 340 | def OnReviewUncommittedChanges(self, e): 341 | dialog = UncommittedFilesDialog(self, -1, self.repo) 342 | dialog.SetTitle('Uncommitted changes') 343 | dialog.SetMessage('The following changes are not committed:') 344 | dialog.ShowModal() 345 | 346 | def OnDetailsButton(self, e): 347 | if self.repo.current_branch == None: 348 | if self.switchMode == SWMODE_MOVE_BRANCH: 349 | message = 'By moving a detached HEAD and/or branch \'%s\' to a different position ' % self.targetBranch 350 | else: 351 | message = 'By moving a detached HEAD to a different position ' 352 | else: 353 | message = 'By moving branch \'%s\' to a different position ' % self.targetBranch 354 | 355 | message += 'some of the commits will not be referenced by any ' + \ 356 | 'branch, tag or remote branch. They will disappear from the ' + \ 357 | 'history graph and will be permanently lost.\n\n' + \ 358 | 'These commits are:' 359 | 360 | dialog = CommitListDialog(self, -1, self.repo, self.lostCommits) 361 | dialog.SetTitle('Review commits to be lost') 362 | dialog.SetMessage(message) 363 | dialog.ShowModal() 364 | 365 | def OnSubmoduleSwitch(self, e): 366 | self.submoduleSwitch = self.submoduleChk.GetValue() 367 | 368 | for btn in self.submoduleModeButtons: 369 | btn.Enable(self.submoduleSwitch) 370 | 371 | if self.submoduleSwitch: 372 | self.submoduleBranchTxt.Enable(self.submoduleMode == SUBMODULE_NEW_BRANCH) 373 | else: 374 | self.submoduleBranchTxt.Enable(False) 375 | 376 | self.Validate() 377 | 378 | def OnSubmoduleMode(self, submoduleMode): 379 | self.submoduleMode = submoduleMode 380 | self.submoduleBranchTxt.Enable(submoduleMode == SUBMODULE_NEW_BRANCH) 381 | self.Validate() 382 | 383 | def OnOkClicked(self, e): 384 | # Update references 385 | self.repo.load_refs() 386 | 387 | try: 388 | # Switch to new version (as detached HEAD) 389 | if self.workdirMode == WORKDIR_KEEP: 390 | self.repo.run_cmd(['update-ref', '--no-deref', 'HEAD', self.targetCommit.sha1], raise_error=True) 391 | elif self.uncommittedMode == UNCOMMITTED_SAFE_MODE: 392 | self.repo.run_cmd(['checkout', self.targetCommit.sha1], raise_error=True) 393 | elif self.uncommittedMode == UNCOMMITTED_MERGE: 394 | self.repo.run_cmd(['checkout', '-m', self.targetCommit.sha1], raise_error=True) 395 | elif self.uncommittedMode == UNCOMMITTED_DISCARD: 396 | self.repo.run_cmd(['reset', '--hard'], raise_error=True) 397 | self.repo.run_cmd(['clean', '-f'], raise_error=True) 398 | 399 | # Checkout branch 400 | branch = None 401 | if self.switchMode in [SWMODE_EXISTING_BRANCH, SWMODE_MOVE_BRANCH]: 402 | branch = self.targetBranch 403 | elif self.switchMode == SWMODE_NEW_BRANCH: 404 | branch = self.newBranchName 405 | if branch: 406 | if self.switchMode != SWMODE_EXISTING_BRANCH: 407 | self.repo.run_cmd(['update-ref', 'refs/heads/%s' % branch, self.targetCommit.sha1], raise_error=True) 408 | self.repo.update_head('ref: refs/heads/%s' % branch) 409 | 410 | except git.GitError, e: 411 | self.error = str(e).partition('\n')[2].strip() 412 | if not self.error: 413 | self.error = str(e) 414 | self.EndModal(1) 415 | return 416 | 417 | # Update submodules 418 | if self.submoduleSwitch: 419 | self.repo.load_refs() 420 | 421 | for submodule in self.repo.submodules: 422 | submodule.load_refs() 423 | submodule.get_log(['--topo-order', '--all']) # Update commit pool 424 | 425 | # Check existence of referenced commit 426 | if submodule.main_ref not in git.commit_pool: 427 | self.submoduleWarnings[submodule.name] = 'Referenced version cannot be found' 428 | continue 429 | commit = git.commit_pool[submodule.main_ref] 430 | 431 | # Check lost commits 432 | lostCommits = submodule.get_lost_commits('HEAD', commit.sha1) 433 | if self.submoduleMode == SUBMODULE_MOVE_BRANCH and submodule.current_branch: 434 | lostCommits += submodule.get_lost_commits('refs/heads/%s' % submodule.current_branch, commit.sha1) 435 | if lostCommits: 436 | self.submoduleWarnings[submodule.name] = 'Switching to new version would result in lost commits' 437 | continue 438 | 439 | # Try to checkout (in safe mode) 440 | try: 441 | # Reset submodule so that it won't be unmerged 442 | self.repo.run_cmd(['reset', submodule.name]) 443 | 444 | if self.submoduleMode == SUBMODULE_DETACHED_HEAD: 445 | submodule.run_cmd(['checkout', commit.sha1], raise_error=True) 446 | elif self.submoduleMode == SUBMODULE_NEW_BRANCH: 447 | if self.submoduleBranchName in submodule.branches: 448 | self.submoduleWarnings[submodule.name] = "Branch '%s' already exists!" % self.submoduleBranchName 449 | continue 450 | submodule.run_cmd(['branch', self.submoduleBranchName, commit.sha1], raise_error=True) 451 | submodule.run_cmd(['checkout', self.submoduleBranchName], raise_error=True) 452 | elif self.submoduleMode == SUBMODULE_MOVE_BRANCH: 453 | submodule.run_cmd(['checkout', commit.sha1], raise_error=True) 454 | if submodule.current_branch: 455 | submodule.run_cmd(['update-ref', 'refs/heads/%s' % submodule.current_branch, commit.sha1], raise_error=True) 456 | submodule.run_cmd(['checkout', submodule.current_branch], raise_error=True) 457 | except git.GitError, e: 458 | error_line = str(e).partition('\n')[2].strip() 459 | if not error_line: 460 | error_line = e 461 | self.submoduleWarnings[submodule.name] = error_line 462 | 463 | self.EndModal(1) 464 | 465 | def OnCancelClicked(self, e): 466 | self.EndModal(0) 467 | 468 | def RunWizard(self): 469 | return self.ShowModal() 470 | 471 | -------------------------------------------------------------------------------- /stupidgit_gui/Wizard.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | BTN_NEXT = "Next >" 4 | BTN_PREV = "< Previous" 5 | BTN_CONTINUE = "Continue >" 6 | BTN_CANCEL = "Cancel" 7 | BTN_FINISH = "Finish" 8 | 9 | class Wizard(wx.Dialog): 10 | def __init__(self, parent, id): 11 | wx.Dialog.__init__(self, parent, id, size=wx.Size(600,400)) 12 | 13 | # Layout 14 | self.sizer = wx.BoxSizer(wx.VERTICAL) 15 | self.SetSizer(self.sizer) 16 | 17 | # Page container 18 | self.pageSizer = wx.BoxSizer(wx.VERTICAL) 19 | self.sizer.Add(self.pageSizer, 1, wx.EXPAND | wx.ALL, 5) 20 | self.currentPage = None 21 | 22 | # Button container 23 | self.buttonSizer = wx.BoxSizer(wx.HORIZONTAL) 24 | self.sizer.Add(self.buttonSizer, 0, wx.ALIGN_RIGHT | wx.ALL, 10) 25 | self.buttons = [] 26 | 27 | def SetPage(self, page): 28 | # Remove old page from layout 29 | if self.currentPage: 30 | self.pageSizer.Detach(self.currentPage) 31 | self.currentPage.Hide() 32 | 33 | # Add new page to layout 34 | self.currentPage = page 35 | self.currentPage.Show() 36 | self.pageSizer.Add(self.currentPage, 1, wx.EXPAND) 37 | self.currentPage.sizer.Layout() 38 | self.pageSizer.Layout() 39 | self.sizer.Layout() 40 | 41 | # Show buttons 42 | self.SetButtons(page.buttons) 43 | 44 | # Replace title 45 | self.SetTitle(page.caption) 46 | 47 | def SetButtons(self, buttonLabels): 48 | for button in self.buttons: 49 | self.buttonSizer.Detach(button) 50 | button.Destroy() 51 | 52 | self.buttons = [ wx.Button(self, -1, label) for label in buttonLabels ] 53 | for button in self.buttons: 54 | self.Bind(wx.EVT_BUTTON, self._onButton, button) 55 | self.buttonSizer.Add(button, 0, wx.LEFT, 5) 56 | 57 | self.sizer.Layout() 58 | 59 | def _onButton(self, e): 60 | self.OnButtonClicked(e.GetEventObject().GetLabel()) 61 | 62 | def RunWizard(self): 63 | self.OnStart() 64 | return self.ShowModal() 65 | 66 | def EndWizard(self, retval): 67 | self.EndModal(retval) 68 | 69 | # Abstract functions 70 | def OnStart(self): 71 | pass 72 | 73 | def OnButtonClicked(self, button): 74 | pass 75 | 76 | # Helper functions to create pages 77 | def CreatePage(self, caption, buttons=[]): 78 | page = wx.Panel(self, -1) 79 | page.caption = caption 80 | page.buttons = buttons 81 | page.sizer = wx.BoxSizer(wx.VERTICAL) 82 | page.SetSizer(page.sizer) 83 | page.Hide() 84 | 85 | return page 86 | 87 | def CreateWarningPage(self, caption, message, buttons=[]): 88 | page = self.CreatePage(caption, buttons) 89 | 90 | captionFont = wx.Font(16, wx.DEFAULT, wx.NORMAL, wx.BOLD) 91 | page.captionText = wx.StaticText(page, -1, caption) 92 | page.captionText.SetFont(captionFont) 93 | 94 | page.text = wx.StaticText(page, -1, message) 95 | 96 | page.sizer.Add(page.captionText, 0, wx.ALL, 10) 97 | page.sizer.Add(page.text, 1, wx.EXPAND | wx.ALL, 10) 98 | 99 | return page 100 | 101 | -------------------------------------------------------------------------------- /stupidgit_gui/__init__.py: -------------------------------------------------------------------------------- 1 | from git import * 2 | from MainWindow import * 3 | 4 | -------------------------------------------------------------------------------- /stupidgit_gui/git.py: -------------------------------------------------------------------------------- 1 | import os 2 | import os.path 3 | import sys 4 | import subprocess 5 | import re 6 | import tempfile 7 | import threading 8 | from util import * 9 | 10 | FILE_ADDED = 'A' 11 | FILE_MODIFIED = 'M' 12 | FILE_DELETED = 'D' 13 | FILE_COPIED = 'C' 14 | FILE_RENAMED = 'R' 15 | FILE_UNMERGED = 'U' 16 | FILE_TYPECHANGED = 'T' 17 | FILE_UNTRACKED = 'N' 18 | FILE_BROKEN = 'B' 19 | FILE_UNKNOWN = 'X' 20 | 21 | MERGE_TOOLS = { 22 | 'opendiff': ( 23 | ['/usr/bin/opendiff'], 24 | ['{LOCAL}', '{REMOTE}', '-merge', '{MERGED}'] 25 | ), 26 | 'diffmerge.app': ( 27 | ['/Applications/DiffMerge.app/Contents/MacOS/DiffMerge'], 28 | ['--nosplash', '-t1={FILENAME}.LOCAL', '-t2={FILENAME}.MERGED', '-t3={FILENAME}.REMOTE', '{LOCAL}', '{MERGED}', '{REMOTE}'] 29 | ), 30 | 'diffmerge.cmdline': ( 31 | ['{PATH}/diffmerge', '{PATH}/diffmerge.sh'], 32 | ['--nosplash', '-t1=LOCAL', '-t2=MERGED', '-t3=REMOTE', '{LOCAL}', '{MERGED}', '{REMOTE}'] 33 | ), 34 | 'meld': ( 35 | ['{PATH}/meld'], 36 | ['{LOCAL}', '{MERGED}', '{REMOTE}'] 37 | ), 38 | 'kdiff3': ( 39 | ['{PATH}/kdiff3'], 40 | ['{LOCAL}', '{MERGED}', '{REMOTE}', '-o', '{MERGED}', '--L1', '{FILENAME}.LOCAL', '--L2', '{FILENAME}.MERGED', '--L3', '{FILENAME}.REMOTE'] 41 | ), 42 | 'winmerge': ( 43 | [r'C:\Program Files\WinMerge\WinMergeU.exe'], 44 | ['{MERGED}'] # It does not support 3-way merge yet... 45 | ), 46 | 'tortoisemerge': ( 47 | [r'C:\Program Files\TortoiseGit\bin\TortoiseMerge.exe'], 48 | ['{MERGED}', '{LOCAL}', '{REMOTE}'] 49 | ), 50 | } 51 | 52 | _git = None 53 | commit_pool = {} 54 | 55 | class GitError(RuntimeError): pass 56 | 57 | def git_binary(): 58 | global _git 59 | 60 | if _git: 61 | return _git 62 | 63 | # Search for git binary 64 | if os.name == 'posix': 65 | locations = ['{PATH}/git', '/opt/local/bin/git', '/usr/local/git/bin'] 66 | elif sys.platform == 'win32': 67 | locations = (r'{PATH}\git.exe', r'C:\Program Files\Git\bin\git.exe') 68 | else: 69 | locations = [] 70 | 71 | for _git in find_binary(locations): 72 | return _git 73 | 74 | _git = None 75 | raise GitError, "git executable not found" 76 | 77 | _mergetool = None 78 | def detect_mergetool(): 79 | global _mergetool 80 | 81 | if _mergetool: 82 | return _mergetool 83 | 84 | # Select tools 85 | if sys.platform == 'darwin': 86 | # Mac OS X 87 | tools = ['diffmerge.app', 'diffmerge.cmdline', 'opendiff', 'meld'] 88 | elif os.name == 'posix': 89 | # Other Unix 90 | tools = ['diffmerge.cmdline', 'meld', 'kdiff3'] 91 | elif sys.platform == 'win32': 92 | # Windows 93 | tools = ['tortoisemerge', 'winmerge'] 94 | else: 95 | raise GitError, "Cannot detect any merge tool" 96 | 97 | # Detect binaries 98 | for tool in tools: 99 | locations, args = MERGE_TOOLS[tool] 100 | for location in find_binary(locations): 101 | _mergetool = (location, args) 102 | return _mergetool 103 | 104 | # Return error if no tool was found 105 | raise GitError, "Cannot detect any merge tool" 106 | 107 | def run_cmd(dir, args, with_retcode=False, with_stderr=False, raise_error=False, input=None, env={}, run_bg=False, setup_askpass=False): 108 | # Check args 109 | if type(args) in [str, unicode]: 110 | args = [args] 111 | args = [str(a) for a in args] 112 | 113 | # Check directory 114 | if not os.path.isdir(dir): 115 | raise GitError, 'Directory not exists: ' + dir 116 | 117 | try: 118 | os.chdir(dir) 119 | except OSError, msg: 120 | raise GitError, msg 121 | 122 | # Run command 123 | if type(args) != list: 124 | args = [args] 125 | 126 | # Setup environment 127 | git_env = dict(os.environ) 128 | if setup_askpass and 'SSH_ASKPASS' not in git_env: 129 | git_env['SSH_ASKPASS'] = '%s-askpass' % os.path.realpath(os.path.abspath(sys.argv[0])) 130 | 131 | git_env.update(env) 132 | 133 | preexec_fn = os.setsid if setup_askpass else None 134 | 135 | p = Popen([git_binary()] + args, stdout=subprocess.PIPE, 136 | stderr=subprocess.PIPE, stdin=subprocess.PIPE, 137 | env=git_env, shell=False, preexec_fn=preexec_fn) 138 | if run_bg: 139 | return p 140 | 141 | if input == None: 142 | stdout,stderr = p.communicate('') 143 | else: 144 | stdout,stderr = p.communicate(utf8_str(input)) 145 | 146 | # Return command output in a form given by arguments 147 | ret = [] 148 | 149 | if p.returncode != 0 and raise_error: 150 | raise GitError, 'git returned with the following error:\n%s' % stderr 151 | 152 | if with_retcode: 153 | ret.append(p.returncode) 154 | 155 | ret.append(stdout) 156 | 157 | if with_stderr: 158 | ret.append(stderr) 159 | 160 | if len(ret) == 1: 161 | return ret[0] 162 | else: 163 | return tuple(ret) 164 | 165 | class Repository(object): 166 | def __init__(self, repodir, name='Main module', parent=None): 167 | self.name = name 168 | self.parent = parent 169 | 170 | # Search for .git directory in repodir ancestors 171 | repodir = os.path.abspath(repodir) 172 | try: 173 | if parent: 174 | if not os.path.isdir(os.path.join(repodir, '.git')): 175 | raise GitError, "Not a git repository: %s" % repodir 176 | else: 177 | while not os.path.isdir(os.path.join(repodir, '.git')): 178 | new_repodir = os.path.abspath(os.path.join(repodir, '..')) 179 | if new_repodir == repodir or (parent and new_repodir == parent.dir): 180 | raise GitError, "Directory is not a git repository" 181 | else: 182 | repodir = new_repodir 183 | except OSError: 184 | raise GitError, "Directory is not a git repository or it is not readable" 185 | 186 | self.dir = repodir 187 | 188 | # Remotes 189 | self.config = ConfigFile(os.path.join(self.dir, '.git', 'config')) 190 | self.url = self.config.get_option('remote', 'origin', 'url') 191 | 192 | self.remotes = {} 193 | for remote, opts in self.config.sections_for_type('remote'): 194 | if 'url' in opts: 195 | self.remotes[remote] = opts['url'] 196 | 197 | # Run a git status to see whether this is really a git repository 198 | retcode,output = self.run_cmd(['status'], with_retcode=True) 199 | if retcode not in [0,1]: 200 | raise GitError, "Directory is not a git repository" 201 | 202 | # Load refs 203 | self.load_refs() 204 | 205 | # Get submodule info 206 | self.submodules = self.get_submodules() 207 | self.all_modules = [self] + self.submodules 208 | 209 | def load_refs(self): 210 | self.refs = {} 211 | self.branches = {} 212 | self.remote_branches = {} 213 | self.tags = {} 214 | 215 | # HEAD, current branch 216 | self.head = self.run_cmd(['rev-parse', 'HEAD']).strip() 217 | self.current_branch = None 218 | try: 219 | f = open(os.path.join(self.dir, '.git', 'HEAD')) 220 | head = f.read().strip() 221 | f.close() 222 | 223 | if head.startswith('ref: refs/heads/'): 224 | self.current_branch = head[16:] 225 | except OSError: 226 | pass 227 | 228 | # Main module references 229 | if self.parent: 230 | self.main_ref = self.parent.get_submodule_version(self.name, 'HEAD') 231 | if os.path.exists(os.path.join(self.parent.dir, '.git', 'MERGE_HEAD')): 232 | self.main_merge_ref = self.parent.get_submodule_version(self.name, 'MERGE_HEAD') 233 | else: 234 | self.main_merge_ref = None 235 | else: 236 | self.main_ref = None 237 | self.main_merge_ref = None 238 | 239 | # References 240 | for line in self.run_cmd(['show-ref']).split('\n'): 241 | commit_id, _, refname = line.partition(' ') 242 | self.refs[refname] = commit_id 243 | 244 | if refname.startswith('refs/heads/'): 245 | branchname = refname[11:] 246 | self.branches[branchname] = commit_id 247 | elif refname.startswith('refs/remotes/'): 248 | branchname = refname[13:] 249 | self.remote_branches[branchname] = commit_id 250 | elif refname.startswith('refs/tags/'): 251 | # Load the referenced commit for tags 252 | tagname = refname[10:] 253 | try: 254 | self.tags[tagname] = self.run_cmd(['rev-parse', '%s^{commit}' % refname], raise_error=True).strip() 255 | except GitError: 256 | pass 257 | 258 | # Inverse reference hashes 259 | self.refs_by_sha1 = invert_hash(self.refs) 260 | self.branches_by_sha1 = invert_hash(self.branches) 261 | self.remote_branches_by_sha1 = invert_hash(self.remote_branches) 262 | self.tags_by_sha1 = invert_hash(self.tags) 263 | 264 | def run_cmd(self, args, **opts): 265 | return run_cmd(self.dir, args, **opts) 266 | 267 | def get_submodules(self): 268 | # Check existence of .gitmodules 269 | gitmodules_path = os.path.join(self.dir, '.gitmodules') 270 | if not os.path.isfile(gitmodules_path): 271 | return [] 272 | 273 | # Parse .gitmodules file 274 | repos = [] 275 | submodule_config = ConfigFile(gitmodules_path) 276 | for name,opts in submodule_config.sections_for_type('submodule'): 277 | if 'path' in opts: 278 | repo_path = os.path.join(self.dir, opts['path']) 279 | repos.append(Repository(repo_path, name=opts['path'], parent=self)) 280 | 281 | return repos 282 | 283 | def get_submodule_version(self, submodule_name, main_version): 284 | dir = os.path.dirname(submodule_name) 285 | name = os.path.basename(submodule_name) 286 | output = self.run_cmd(['ls-tree', '-z', '%s:%s' % (main_version, dir)]) 287 | for line in output.split('\x00'): 288 | if not line.strip(): continue 289 | 290 | meta, filename = line.split('\t') 291 | if filename == name: 292 | mode, filetype, sha1 = meta.split(' ') 293 | if filetype == 'commit': 294 | return sha1 295 | 296 | return None 297 | 298 | def get_log(self, args=[]): 299 | log = self.run_cmd(['log', '-z', '--date=relative', '--pretty=format:%H%n%h%n%P%n%T%n%an%n%ae%n%ad%n%s%n%b']+args) 300 | 301 | if len(log) == 0: 302 | return [] 303 | 304 | commit_texts = log.split('\x00') 305 | commit_texts.reverse() 306 | 307 | commits = [] 308 | for text in commit_texts: 309 | c = Commit(self) 310 | c.parse_gitlog_output(text) 311 | commit_pool[c.sha1] = c 312 | commits.append(c) 313 | 314 | commits.reverse() 315 | return commits 316 | 317 | def commit(self, author_name, author_email, msg, amend=False): 318 | if amend: 319 | # Get details of current HEAD 320 | is_merge_resolve = False 321 | 322 | output = self.run_cmd(['log', '-1', '--pretty=format:%P%n%an%n%ae%n%aD']) 323 | if not output.strip(): 324 | raise GitError, "Cannot amend in an empty repository!" 325 | 326 | parents, author_name, author_email, author_date = output.split('\n') 327 | parents = parents.split(' ') 328 | else: 329 | author_date = None # Use current date 330 | 331 | # Get HEAD sha1 id 332 | if self.head == 'HEAD': 333 | parents = [] 334 | else: 335 | head = self.run_cmd(['rev-parse', 'HEAD']).strip() 336 | parents = [head] 337 | 338 | # Get merge head if exists 339 | is_merge_resolve = False 340 | try: 341 | merge_head_filename = os.path.join(self.dir, '.git', 'MERGE_HEAD') 342 | if os.path.isfile(merge_head_filename): 343 | f = open(merge_head_filename) 344 | p = f.read().strip() 345 | f.close() 346 | parents.append(p) 347 | is_merge_resolve = True 348 | except OSError: 349 | raise GitError, "Cannot open MERGE_HEAD file" 350 | 351 | # Write tree 352 | tree = self.run_cmd(['write-tree'], raise_error=True).strip() 353 | 354 | # Write commit 355 | parent_args = [] 356 | for parent in parents: 357 | parent_args += ['-p', parent] 358 | 359 | env = {} 360 | if author_name: env['GIT_AUTHOR_NAME'] = author_name 361 | if author_email: env['GIT_AUTHOR_EMAIL'] = author_email 362 | if author_date: env['GIT_AUTHOR_DATE'] = author_date 363 | 364 | commit = self.run_cmd( 365 | ['commit-tree', tree] + parent_args, 366 | raise_error=True, 367 | input=msg, 368 | env=env 369 | ).strip() 370 | 371 | # Update reference 372 | self.run_cmd(['update-ref', 'HEAD', commit], raise_error=True) 373 | 374 | # Remove MERGE_HEAD 375 | if is_merge_resolve: 376 | try: 377 | os.unlink(os.path.join(self.dir, '.git', 'MERGE_HEAD')) 378 | os.unlink(os.path.join(self.dir, '.git', 'MERGE_MODE')) 379 | os.unlink(os.path.join(self.dir, '.git', 'MERGE_MSG')) 380 | os.unlink(os.path.join(self.dir, '.git', 'ORIG_HEAD')) 381 | except OSError: 382 | pass 383 | 384 | def get_status(self): 385 | unstaged_changes = {} 386 | staged_changes = {} 387 | 388 | # Unstaged changes 389 | changes = self.run_cmd(['diff', '--name-status', '-z']).split('\x00') 390 | for i in xrange(len(changes)/2): 391 | status, filename = changes[2*i], changes[2*i+1] 392 | if filename not in unstaged_changes or status == FILE_UNMERGED: 393 | unstaged_changes[filename] = status 394 | 395 | # Untracked files 396 | for filename in self.run_cmd(['ls-files', '--others', '--exclude-standard', '-z']).split('\x00'): 397 | if filename and filename not in unstaged_changes: 398 | unstaged_changes[filename] = FILE_UNTRACKED 399 | 400 | # Staged changes 401 | if self.head == 'HEAD': 402 | # Initial commit 403 | for filename in self.run_cmd(['ls-files', '--cached', '-z']).split('\x00'): 404 | if filename: 405 | staged_changes[filename] = FILE_ADDED 406 | else: 407 | changes = self.run_cmd(['diff', '--cached', '--name-status', '-z']).split('\x00') 408 | for i in xrange(len(changes)/2): 409 | status, filename = changes[2*i], changes[2*i+1] 410 | if status != FILE_UNMERGED or filename not in unstaged_changes: 411 | staged_changes[filename] = status 412 | 413 | return unstaged_changes, staged_changes 414 | 415 | def get_unified_status(self): 416 | unified_changes = {} 417 | 418 | # Staged & unstaged changes 419 | changes = self.run_cmd(['diff', 'HEAD', '--name-status', '-z']).split('\x00') 420 | for i in xrange(len(changes)/2): 421 | status, filename = changes[2*i], changes[2*i+1] 422 | if filename not in unified_changes or status == FILE_UNMERGED: 423 | unified_changes[filename] = status 424 | 425 | # Untracked files 426 | for filename in self.run_cmd(['ls-files', '--others', '--exclude-standard', '-z']).split('\x00'): 427 | if filename and filename not in unified_changes: 428 | unified_changes[filename] = FILE_UNTRACKED 429 | 430 | return unified_changes 431 | 432 | def merge_file(self, filename): 433 | # Store file versions in temporary files 434 | fd, local_file = tempfile.mkstemp(prefix=os.path.basename(filename) + '.LOCAL.') 435 | os.write(fd, self.run_cmd(['show', ':2:%s' % filename], raise_error=True)) 436 | os.close(fd) 437 | 438 | fd, remote_file = tempfile.mkstemp(prefix=os.path.basename(filename) + '.REMOTE.') 439 | os.write(fd, self.run_cmd(['show', ':3:%s' % filename], raise_error=True)) 440 | os.close(fd) 441 | 442 | # Run mergetool 443 | mergetool, args = detect_mergetool() 444 | args = list(args) 445 | 446 | for i in xrange(len(args)): 447 | args[i] = args[i].replace('{FILENAME}', os.path.basename(filename)) 448 | args[i] = args[i].replace('{LOCAL}', local_file) 449 | args[i] = args[i].replace('{REMOTE}', remote_file) 450 | args[i] = args[i].replace('{MERGED}', os.path.join(self.dir, filename)) 451 | 452 | s = Popen([mergetool] + args, shell=False) 453 | 454 | def get_lost_commits(self, refname, moving_to=None): 455 | # Note: refname must be a full reference name (e.g. refs/heads/master) 456 | # or HEAD (if head is detached). 457 | # moving_to must be a SHA1 commit identifier 458 | if refname == 'HEAD': 459 | commit_id = self.head 460 | else: 461 | commit_id = self.refs[refname] 462 | commit = commit_pool[commit_id] 463 | 464 | # If commit is not moving, it won't be lost :) 465 | if commit_id == moving_to: 466 | return [] 467 | 468 | # If a commit has another reference, it won't be lost :) 469 | head_refnum = len(self.refs_by_sha1.get(commit_id, [])) 470 | if (refname == 'HEAD' and head_refnum > 0) or head_refnum > 1: 471 | return [] 472 | 473 | # If commit has descendants, it won't be lost: at least one of its 474 | # descendants has another reference 475 | if commit.children: 476 | return [] 477 | 478 | # If commit has parents, traverse the commit graph into this direction. 479 | # Mark every commit as lost commit until: 480 | # (1) the end of the graph is found 481 | # (2) a reference is found 482 | # (3) the moving_to destination is found 483 | # (4) a commit is found that has more than one children. 484 | # (it must have a descendant that has a reference) 485 | lost_commits = [] 486 | search_pos = [commit] 487 | 488 | while search_pos: 489 | next_search_pos = [] 490 | 491 | for c in search_pos: 492 | for p in c.parents: 493 | if p.sha1 not in self.refs_by_sha1 and p.sha1 != moving_to \ 494 | and len(p.children) == 1: 495 | next_search_pos.append(p) 496 | 497 | lost_commits += search_pos 498 | search_pos = next_search_pos 499 | 500 | return lost_commits 501 | 502 | def update_head(self, content): 503 | try: 504 | f = open(os.path.join(self.dir, '.git', 'HEAD'), 'w') 505 | f.write(content) 506 | f.close() 507 | except OSError: 508 | raise GitError, "Write error:\nCannot write into .git/HEAD" 509 | 510 | def fetch_bg(self, remote, callbackFunc, fetch_tags=False): 511 | url = self.remotes[remote] 512 | t = FetchThread(self, remote, callbackFunc, fetch_tags) 513 | t.start() 514 | 515 | return t 516 | 517 | def push_bg(self, remote, commit, remoteBranch, forcePush, callbackFunc): 518 | t = PushThread(self, remote, commit, remoteBranch, forcePush, callbackFunc) 519 | t.start() 520 | 521 | return t 522 | 523 | class Commit(object): 524 | def __init__(self, repo): 525 | self.repo = repo 526 | 527 | self.sha1 = None 528 | self.abbrev = None 529 | 530 | self.parents = None 531 | self.children = None 532 | self.tree = None 533 | 534 | self.author_name = None 535 | self.author_email = None 536 | self.author_date = None 537 | 538 | self.short_msg = None 539 | self.full_msg = None 540 | 541 | self.remote_branches = None 542 | self.branches = None 543 | self.tags = None 544 | 545 | def parse_gitlog_output(self, text): 546 | lines = text.split('\n') 547 | 548 | (self.sha1, self.abbrev, parents, self.tree, 549 | self.author_name, self.author_email, self.author_date, 550 | self.short_msg) = lines[0:8] 551 | 552 | if parents: 553 | parent_ids = parents.split(' ') 554 | self.parents = [commit_pool[p] for p in parent_ids] 555 | for parent in self.parents: 556 | parent.children.append(self) 557 | else: 558 | self.parents = [] 559 | 560 | self.children = [] 561 | 562 | self.full_msg = '\n'.join(lines[8:]) 563 | 564 | 565 | class ConfigFile(object): 566 | def __init__(self, filename): 567 | self.sections = [] 568 | 569 | # Patterns 570 | p_rootsect = re.compile(r'\[([^\]\s]+)\]') 571 | p_sect = re.compile(r'\[([^\]"\s]+)\s+"([^"]+)"\]') 572 | p_option = re.compile(r'(\w+)\s*=\s*(.*)') 573 | 574 | # Parse file 575 | section = None 576 | section_type = None 577 | options = {} 578 | 579 | f = open(filename) 580 | for line in f: 581 | line = line.strip() 582 | 583 | if len(line) == 0 or line.startswith('#'): 584 | continue 585 | 586 | # Parse sections 587 | m_rootsect = p_rootsect.match(line) 588 | m_sect = p_sect.match(line) 589 | 590 | if (m_rootsect or m_sect) and section: 591 | self.sections.append( (section_type, section, options) ) 592 | if m_rootsect: 593 | section_type = None 594 | section = m_rootsect.group(1) 595 | options = {} 596 | elif m_sect: 597 | section_type = m_sect.group(1) 598 | section = m_sect.group(2) 599 | options = {} 600 | 601 | # Parse options 602 | m_option = p_option.match(line) 603 | if section and m_option: 604 | options[m_option.group(1)] = m_option.group(2) 605 | 606 | if section: 607 | self.sections.append( (section_type, section, options) ) 608 | f.close() 609 | 610 | def has_section(self, sect_type, sect_name): 611 | m = [ s for s in self.sections if s[0]==sect_type and s[1] == sect_name ] 612 | return len(m) > 0 613 | 614 | def sections_for_type(self, sect_type): 615 | return [ (s[1],s[2]) for s in self.sections if s[0]==sect_type ] 616 | 617 | def options_for_section(self, sect_type, sect_name): 618 | m = [ s[2] for s in self.sections if s[0]==sect_type and s[1] == sect_name ] 619 | if m: 620 | return m[0] 621 | else: 622 | return None 623 | 624 | def get_option(self, sect_type, sect_name, option): 625 | opts = self.options_for_section(sect_type, sect_name) 626 | if opts: 627 | return opts.get(option) 628 | else: 629 | return None 630 | 631 | TRANSFER_COUNTING = 0 632 | TRANSFER_COMPRESSING = 1 633 | TRANSFER_RECEIVING = 2 634 | TRANSFER_WRITING = 3 635 | TRANSFER_RESOLVING = 4 636 | TRANSFER_ENDED = 5 637 | class ObjectTransferThread(threading.Thread): 638 | def __init__(self, repo, callback_func): 639 | threading.Thread.__init__(self) 640 | 641 | # Parameters 642 | self.repo = repo 643 | self.callback_func = callback_func 644 | 645 | # Regular expressions for progress indicator 646 | self.counting_expr = re.compile(r'.*Counting objects:\s*([0-9]+)') 647 | self.compressing_expr = re.compile(r'.*Compressing objects:\s*([0-9]+)%') 648 | self.receiving_expr = re.compile(r'.*Receiving objects:\s*([0-9]+)%') 649 | self.writing_expr = re.compile(r'.*Writing objects:\s*([0-9]+)%') 650 | self.resolving_expr = re.compile(r'.*Resolving deltas:\s*([0-9]+)%') 651 | 652 | self.progress_exprs = ( 653 | (self.counting_expr, TRANSFER_COUNTING), 654 | (self.compressing_expr, TRANSFER_COMPRESSING), 655 | (self.receiving_expr, TRANSFER_RECEIVING), 656 | (self.writing_expr, TRANSFER_WRITING), 657 | (self.resolving_expr, TRANSFER_RESOLVING) 658 | ) 659 | 660 | def run(self, cmd): 661 | # Initial state 662 | self.error_msg = 'Unknown error occured' 663 | self.aborted = False 664 | 665 | # Run git 666 | self.process = self.repo.run_cmd(cmd, run_bg=True, setup_askpass=True) 667 | self.process.stdin.close() 668 | 669 | # Read stdout from a different thread (select.select() does not work 670 | # properly on Windows) 671 | stdout_thread = threading.Thread(target=self.read_stdout, args=[self.process.stdout], kwargs={}) 672 | stdout_thread.start() 673 | 674 | # Read lines 675 | line = '' 676 | c = self.process.stderr.read(1) 677 | while c: 678 | if c in ['\n', '\r']: 679 | self.parse_line(line) 680 | line = '' 681 | else: 682 | line += c 683 | 684 | c = self.process.stderr.read(1) 685 | 686 | self.process.wait() 687 | stdout_thread.join() 688 | 689 | # Remaining line 690 | if line: 691 | self.parse_line(line) 692 | 693 | # Report end of operation 694 | if self.aborted: 695 | return 696 | elif self.process.returncode == 0: 697 | result = self.transfer_ended() 698 | self.callback_func(TRANSFER_ENDED, result) 699 | else: 700 | self.callback_func(TRANSFER_ENDED, self.error_msg) 701 | 702 | def parse_line(self, line): 703 | # Progress indicators 704 | for reg, event in self.progress_exprs: 705 | m = reg.match(line) 706 | if m: 707 | self.callback_func(event, int(m.group(1))) 708 | 709 | # Fatal error 710 | if line.startswith('fatal:'): 711 | self.error_msg = line 712 | 713 | def read_stdout(self, stdout): 714 | lines = stdout.read().split('\n') 715 | for line in lines: 716 | self.parse_line(line) 717 | 718 | def abort(self): 719 | self.aborted = True 720 | try: 721 | self.process.kill() 722 | except: 723 | pass 724 | 725 | class FetchThread(ObjectTransferThread): 726 | def __init__(self, repo, remote, callback_func, fetch_tags=False): 727 | ObjectTransferThread.__init__(self, repo, callback_func) 728 | 729 | # Parameters 730 | self.remote = remote 731 | self.fetch_tags = fetch_tags 732 | 733 | # Regular expressions for remote refs 734 | self.branches = {} 735 | self.tags = {} 736 | self.branch_expr = re.compile(r'([0-9a-f]{40}) refs\/heads\/([a-zA-Z0-9_.\-]+)') 737 | self.tag_expr = re.compile(r'([0-9a-f]{40}) refs\/tags\/([a-zA-Z0-9_.\-]+)') 738 | 739 | self.ref_exprs = ( 740 | (self.branch_expr, self.branches), 741 | (self.tag_expr, self.tags) 742 | ) 743 | 744 | def run(self): 745 | ObjectTransferThread.run(self, ['fetch-pack', '-v', '--all', self.repo.remotes[self.remote]]) 746 | 747 | def transfer_ended(self): 748 | # Update remote branches 749 | for branch, sha1 in self.branches.iteritems(): 750 | self.repo.run_cmd(['update-ref', 'refs/remotes/%s/%s' % (self.remote, branch), sha1]) 751 | 752 | # Update tags 753 | if self.fetch_tags: 754 | for tag, sha1 in self.tags.iteritems(): 755 | self.repo.run_cmd(['update-ref', 'refs/tags/%s' % tag, sha1]) 756 | 757 | return (self.branches, self.tags) 758 | 759 | def parse_line(self, line): 760 | ObjectTransferThread.parse_line(self, line) 761 | 762 | # Remote refs 763 | for reg, refs in self.ref_exprs: 764 | m = reg.match(line) 765 | if m: 766 | refs[m.group(2)] = m.group(1) 767 | 768 | class PushThread(ObjectTransferThread): 769 | def __init__(self, repo, remote, commit, remote_branch, force_push, callback_func): 770 | ObjectTransferThread.__init__(self, repo, callback_func) 771 | 772 | # Parameters 773 | self.remote = remote 774 | self.commit = commit 775 | self.remote_branch = remote_branch 776 | self.force_push = force_push 777 | 778 | def run(self): 779 | if self.force_push: 780 | push_cmd = ['push', '-f'] 781 | else: 782 | push_cmd = ['push'] 783 | 784 | cmd = push_cmd + [self.remote, '%s:refs/heads/%s' % (self.commit.sha1, self.remote_branch)] 785 | ObjectTransferThread.run(self, cmd) 786 | 787 | def parse_line(self, line): 788 | ObjectTransferThread.parse_line(self, line) 789 | 790 | if line.startswith(' ! [rejected]'): 791 | self.error_msg = 'The pushed commit is non-fast forward.' 792 | 793 | def transfer_ended(self): 794 | return None 795 | 796 | # Utility functions 797 | def diff_for_untracked_file(filename): 798 | # Start "diff" text 799 | diff_text = 'New file: %s\n' % filename 800 | 801 | # Detect whether file is binary 802 | if is_binary_file(filename): 803 | diff_text += "@@ File is binary.\n\n" 804 | else: 805 | # Text file => show lines 806 | newfile_text = '' 807 | try: 808 | f = open(filename, 'r') 809 | lines = f.readlines() 810 | f.close() 811 | 812 | newfile_text += '@@ -1,0 +1,%d @@\n' % len(lines) 813 | 814 | for line in lines: 815 | newfile_text += '+ ' + line 816 | 817 | diff_text += newfile_text 818 | except OSError: 819 | diff_text += '@@ Error: Cannot open file\n\n' 820 | 821 | return diff_text 822 | 823 | -------------------------------------------------------------------------------- /stupidgit_gui/platformspec.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import wx 4 | 5 | platform = None 6 | default_font = None 7 | 8 | # Init non-GUI specific parts 9 | def init(): 10 | global platform 11 | 12 | if not platform: 13 | # Determine platform name 14 | if sys.platform in ['win32', 'cygwin']: 15 | platform = 'win' 16 | elif sys.platform == 'darwin': 17 | platform = 'osx' 18 | elif os.name == 'posix': 19 | platform = 'unix' # I know, OSX is unix, too :) 20 | else: 21 | platform = 'other' 22 | 23 | # Init platform-specific values 24 | def init_wx(): 25 | global default_font 26 | 27 | # Fonts 28 | default_font = wx.SystemSettings_GetFont(wx.SYS_DEFAULT_GUI_FONT) 29 | 30 | # Font creator that solves the headache with pixel sizes 31 | # - in most cases... 32 | def Font(size, family=None, style=wx.FONTSTYLE_NORMAL, weight=wx.FONTWEIGHT_NORMAL): 33 | init_wx() 34 | 35 | if not family: 36 | family = default_font.GetFamily() 37 | 38 | font = wx.Font(size, family, style, weight) 39 | if platform == 'win': 40 | font.SetPixelSize((size*2,size)) 41 | 42 | return font 43 | 44 | # Initialize module 45 | init() 46 | 47 | -------------------------------------------------------------------------------- /stupidgit_gui/run.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 3 | 4 | import sys 5 | import os 6 | import os.path 7 | import wx 8 | 9 | from git import Repository 10 | from MainWindow import * 11 | from PasswordDialog import * 12 | from HiddenWindow import * 13 | 14 | class StupidGitApp(wx.PySimpleApp): 15 | def InitApp(self): 16 | self.SetAppName('StupidGit') 17 | wx.TheApp = self 18 | self.app_windows = [] 19 | if sys.platform == 'darwin': 20 | self.hiddenWindow = HiddenWindow() 21 | self.SetExitOnFrameDelete(False) 22 | wx.App_SetMacAboutMenuItemId(xrc.XRCID('aboutMenuItem')) 23 | wx.App_SetMacExitMenuItemId(xrc.XRCID('quitMenuItem')) 24 | 25 | def OpenRepo(self, repo=None): 26 | # Find the first empty window (if exists) 27 | win = None 28 | for app_window in self.app_windows: 29 | if not app_window.mainRepo: 30 | win = app_window 31 | break 32 | 33 | if win: 34 | # Open repository in existing empty window 35 | win.SetMainRepo(repo) 36 | else: 37 | # Create a new window 38 | win = MainWindow(repo) 39 | win.Show(True) 40 | 41 | def OnWindowCreated(self, win): 42 | self.app_windows.append(win) 43 | 44 | def OnWindowClosed(self, win): 45 | self.app_windows.remove(win) 46 | if len(self.app_windows) == 0 and sys.platform == 'darwin': 47 | self.hiddenWindow.ShowMenu() 48 | 49 | def ExitApp(self): 50 | while self.app_windows: 51 | self.app_windows[0].frame.Close(True) 52 | self.ExitMainLoop() 53 | 54 | def MacOpenFile(self, filename): 55 | try: 56 | repo = Repository(filename) 57 | self.OpenRepo(repo) 58 | except GitError: 59 | pass 60 | 61 | def main_normal(): 62 | # Parse arguments 63 | repodir = sys.argv[1] if len(sys.argv) > 1 else os.getcwd() 64 | 65 | # Show main window 66 | try: 67 | repo = Repository(repodir) 68 | except GitError: 69 | repo = None 70 | 71 | app = StupidGitApp() 72 | app.InitApp() 73 | app.OpenRepo(repo) 74 | app.MainLoop() 75 | 76 | def main_askpass(): 77 | app = wx.PySimpleApp() 78 | 79 | askpass = PasswordDialog(None, -1, ' '.join(sys.argv[1:])) 80 | askpass.ShowModal() 81 | 82 | if askpass.password: 83 | print askpass.password 84 | sys.exit(0) 85 | else: 86 | sys.exit(1) 87 | 88 | def main(): 89 | if 'askpass' in sys.argv[0]: 90 | main_askpass() 91 | else: 92 | main_normal() 93 | 94 | if __name__ == '__main__': 95 | main() 96 | 97 | -------------------------------------------------------------------------------- /stupidgit_gui/util.py: -------------------------------------------------------------------------------- 1 | import locale 2 | import sys 3 | import os 4 | import os.path 5 | import subprocess 6 | if sys.platform == 'win32': 7 | import ctypes 8 | 9 | def safe_unicode(s): 10 | '''Creates unicode object from string s. 11 | It tries to decode string as UTF-8, fallbacks to current locale 12 | or ISO-8859-1 if both decode attemps fail''' 13 | 14 | if type(s) == unicode: 15 | return s 16 | elif isinstance(s, Exception): 17 | s = str(s) 18 | 19 | try: 20 | return s.decode('UTF-8') 21 | except UnicodeDecodeError: 22 | pass 23 | 24 | try: 25 | lang,encoding = locale.getdefaultlocale() 26 | except ValueError: 27 | lang,encoding = 'C','UTF-8' 28 | 29 | if encoding != 'UTF-8': 30 | try: 31 | return s.decode(encoding) 32 | except UnicodeDecodeError: 33 | pass 34 | 35 | return s.decode('ISO-8859-1') 36 | 37 | def utf8_str(s): 38 | s = safe_unicode(s) 39 | return s.encode('UTF-8') 40 | 41 | def invert_hash(h): 42 | ih = {} 43 | 44 | for key,value in h.iteritems(): 45 | if value not in ih: 46 | ih[value] = [] 47 | ih[value].append(key) 48 | 49 | return ih 50 | 51 | def find_binary(locations): 52 | searchpath_sep = ';' if sys.platform == 'win32' else ':' 53 | searchpaths = os.environ['PATH'].split(searchpath_sep) 54 | 55 | for location in locations: 56 | if '{PATH}' in location: 57 | for searchpath in searchpaths: 58 | s = location.replace('{PATH}', searchpath) 59 | if os.path.isfile(s) and os.access(s, os.X_OK): 60 | yield s 61 | elif os.path.isfile(location) and os.access(location, os.X_OK): 62 | yield location 63 | 64 | def is_binary_file(file): 65 | # Returns True if the file cannot be decoded as UTF-8 66 | # and > 20% of the file is binary character 67 | 68 | # Read file 69 | try: 70 | f = open(file) 71 | buf = f.read() 72 | f.close() 73 | except OSError: 74 | return False 75 | 76 | # Decode as UTF-8 77 | try: 78 | ubuf = unicode(buf, 'utf-8') 79 | return False 80 | except UnicodeDecodeError: 81 | pass 82 | 83 | # Check number of binary characters 84 | treshold = len(buf) / 5 85 | binary_chars = 0 86 | for c in buf: 87 | oc = ord(c) 88 | if oc > 0x7f or (oc < 0x1f and oc != '\r' and oc != '\n'): 89 | binary_chars += 1 90 | if binary_chars > treshold: 91 | return True 92 | 93 | return False 94 | 95 | PROCESS_TERMINATE = 1 96 | def kill_subprocess(process): 97 | if sys.platform == 'win32': 98 | handle = ctypes.windll.kernel32.OpenProcess(PROCESS_TERMINATE, False, process.pid) 99 | ctypes.windll.kernel32.TerminateProcess(handle, -1) 100 | ctypes.windll.kernel32.CloseHandle(handle) 101 | else: 102 | os.kill(process.pid, 9) 103 | 104 | CREATE_NO_WINDOW = 0x08000000 105 | def Popen(cmd, **args): 106 | # Create a subprocess that does not open a new console window 107 | if sys.platform == 'win32': 108 | process = subprocess.Popen(cmd, creationflags = CREATE_NO_WINDOW, **args) 109 | else: 110 | process = subprocess.Popen(cmd, **args) 111 | 112 | # Emulate kill() for Python 2.5 113 | if 'kill' not in dir(process): 114 | process.kill = lambda: kill_subprocess(process) 115 | 116 | return process 117 | 118 | -------------------------------------------------------------------------------- /stupidgit_gui/wxutil.py: -------------------------------------------------------------------------------- 1 | import os 2 | import os.path 3 | import wx 4 | from wx import xrc 5 | 6 | _resource_dir = None 7 | def resource_dir(): 8 | global _resource_dir 9 | if 'STUPIDGIT_RESOURCES' in os.environ: 10 | _resource_dir = os.environ['STUPIDGIT_RESOURCES'] 11 | elif not _resource_dir: 12 | if os.path.commonprefix([__file__, '/usr/lib/pymodules']) == '/usr/lib/pymodules': 13 | _resource_dir = '/usr/share/stupidgit' 14 | else: 15 | _resource_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'resources')) 16 | 17 | return _resource_dir 18 | 19 | _xrc_resource = None 20 | def _xrc(): 21 | global _xrc_resource 22 | if not _xrc_resource: 23 | _xrc_resource = xrc.XmlResource(os.path.join(resource_dir(), 'stupidgit.xrc')) 24 | 25 | return _xrc_resource 26 | 27 | def LoadFrame(parent, frameName): 28 | return _xrc().LoadFrame(parent, frameName) 29 | 30 | def LoadDialog(parent, frameName): 31 | return _xrc().LoadDialog(parent, frameName) 32 | 33 | def SetupEvents(parent, eventHandlers): 34 | for name, event, handler in eventHandlers: 35 | if name: 36 | parent.Bind(event, handler, id=xrc.XRCID(name)) 37 | else: 38 | parent.Bind(event, handler) 39 | 40 | def GetWidget(parent, name): 41 | return xrc.XRCCTRL(parent, name) 42 | 43 | --------------------------------------------------------------------------------