• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

msiemens / PyGitUp / 20542144739

27 Dec 2025 05:29PM UTC coverage: 91.084% (+2.7%) from 88.366%
20542144739

push

github

msiemens
chore: update README with new image and install instructions

378 of 415 relevant lines covered (91.08%)

13.66 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.66
/PyGitUp/gitup.py
1
from git import Git
15✔
2
from git import GitCommandNotFound
15✔
3

4
__all__ = ['GitUp']
15✔
5

6
###############################################################################
7
# IMPORTS and LIBRARIES SETUP
8
###############################################################################
9

10
# Python libs
11
import argparse
15✔
12
import codecs
15✔
13
import errno
15✔
14
import sys
15✔
15
import os
15✔
16
import re
15✔
17
import json
15✔
18
import subprocess
15✔
19
from io import StringIO
15✔
20
from tempfile import NamedTemporaryFile
15✔
21
from urllib.error import HTTPError, URLError
15✔
22
from urllib.request import urlopen
15✔
23

24
# 3rd party libs
25
try:
15✔
26
    from importlib import metadata
15✔
27
except ImportError:  # pragma: no cover
28
    metadata = None
29
    NO_DISTRIBUTE = True
30
else:  # pragma: no cover
31
    NO_DISTRIBUTE = False
32

33
from packaging.version import InvalidVersion, Version
15✔
34

35
import colorama
15✔
36
from git import Repo, GitCmdObjectDB
15✔
37
from termcolor import colored
15✔
38

39
# PyGitUp libs
40
from PyGitUp.utils import execute, uniq, find
15✔
41
from PyGitUp.git_wrapper import GitWrapper, GitError
15✔
42

43
ON_WINDOWS = sys.platform == 'win32'
15✔
44

45
###############################################################################
46
# Setup of 3rd party libs
47
###############################################################################
48

49
colorama.init(autoreset=True, convert=ON_WINDOWS)
15✔
50

51
###############################################################################
52
# Setup constants
53
###############################################################################
54

55
PYPI_URL = 'https://pypi.python.org/pypi/git-up/json'
15✔
56

57

58
###############################################################################
59
# GitUp
60
###############################################################################
61

62
def get_git_dir():
15✔
63
    toplevel_dir = execute(['git', 'rev-parse', '--show-toplevel'])
15✔
64
    if ON_WINDOWS and toplevel_dir and toplevel_dir[0] == '/':
15✔
65
        toplevel_dir = execute(['cygpath', '-m', toplevel_dir])
×
66

67
    if toplevel_dir is not None \
15✔
68
            and os.path.isfile(os.path.join(toplevel_dir, '.git')):
69
        # Not a normal git repo. Check if it's a submodule, then use
70
        # toplevel_dir. Otherwise it's a worktree, thus use  common_dir.
71
        # NOTE: git worktree support only comes with git v2.5.0 or
72
        # later, on earlier versions toplevel_dir is the best we can do.
73

74
        cmd = ['git', 'rev-parse', '--is-inside-work-tree']
15✔
75
        inside_worktree = execute(cmd, cwd=os.path.join(toplevel_dir, '..'))
15✔
76

77
        if inside_worktree == 'true' or Git().version_info[:3] < (2, 5, 0):
15✔
78
            return toplevel_dir
15✔
79
        else:
80
            return execute(['git', 'rev-parse', '--git-common-dir'])
15✔
81

82
    return toplevel_dir
15✔
83

84

85
class GitUp:
15✔
86
    """ Conainter class for GitUp methods """
87

88
    default_settings = {
15✔
89
        'fetch.prune': True,
90
        'fetch.all': False,
91
        'rebase.show-hashes': False,
92
        'rebase.arguments': None,
93
        'rebase.auto': True,
94
        'rebase.log-hook': None,
95
        'updates.check': True,
96
        'push.auto': False,
97
        'push.tags': False,
98
        'push.all': False,
99
    }
100

101
    def __init__(self, testing=False, sparse=False):
15✔
102
        # Sparse init: config only
103
        if sparse:
15✔
104
            self.git = GitWrapper(None)
15✔
105

106
            # Load configuration
107
            self.settings = self.default_settings.copy()
15✔
108
            self.load_config()
15✔
109
            return
15✔
110

111
        # Testing: redirect stderr to stdout
112
        self.testing = testing
15✔
113
        if self.testing:
114
            self.stderr = sys.stdout  # Quiet testing
115
        else:  # pragma: no cover
116
            self.stderr = sys.stderr
117

118
        self.states = []
15✔
119
        self.should_fetch = True
15✔
120
        self.pushed = False
15✔
121

122
        # Check, if we're in a git repo
123
        try:
15✔
124
            repo_dir = get_git_dir()
15✔
125
        except (OSError, GitCommandNotFound) as e:
15✔
126
            if isinstance(e, GitCommandNotFound) or e.errno == errno.ENOENT:
15✔
127
                exc = GitError("The git executable could not be found")
15✔
128
                raise exc
15✔
129
            else:
130
                raise
×
131
        else:
132
            if repo_dir is None:
15✔
133
                exc = GitError("We don't seem to be in a git repository.")
15✔
134
                raise exc
15✔
135

136
            self.repo = Repo(repo_dir, odbt=GitCmdObjectDB)
15✔
137

138
        # Check for branch tracking information
139
        if not any(b.tracking_branch() for b in self.repo.branches):
15✔
140
            exc = GitError("Can\'t update your repo because it doesn\'t has "
15✔
141
                           "any branches with tracking information.")
142
            self.print_error(exc)
15✔
143

144
            raise exc
15✔
145

146
        self.git = GitWrapper(self.repo)
15✔
147

148
        # target_map: map local branch names to remote tracking branches
149
        #: :type: dict[str, git.refs.remote.RemoteReference]
150
        self.target_map = dict()
15✔
151

152
        for branch in self.repo.branches:
15✔
153
            target = branch.tracking_branch()
15✔
154

155
            if target:
15✔
156
                if target.name.startswith('./'):
15✔
157
                    # Tracking branch is in local repo
158
                    target.is_local = True
15✔
159
                else:
160
                    target.is_local = False
15✔
161

162
                self.target_map[branch.name] = target
15✔
163

164
        # branches: all local branches with tracking information
165
        #: :type: list[git.refs.head.Head]
166
        self.branches = [b for b in self.repo.branches if b.tracking_branch()]
15✔
167
        self.branches.sort(key=lambda br: br.name)
15✔
168

169
        # remotes: all remotes that are associated with local branches
170
        #: :type: list[git.refs.remote.RemoteReference]
171
        self.remotes = uniq(
15✔
172
            # name = '<remote>/<branch>' -> '<remote>'
173
            [r.name.split('/', 2)[0]
174
             for r in list(self.target_map.values())]
175
        )
176

177
        # change_count: Number of unstaged changes
178
        self.change_count = len(
15✔
179
            self.git.status(porcelain=True, untracked_files='no').split('\n')
180
        )
181

182
        # Load configuration
183
        self.settings = self.default_settings.copy()
15✔
184
        self.load_config()
15✔
185

186
    def run(self):
15✔
187
        """ Run all the git-up stuff. """
188
        try:
15✔
189
            if self.should_fetch:
15✔
190
                self.fetch()
15✔
191

192
            self.rebase_all_branches()
15✔
193

194
            if self.settings['push.auto']:
15✔
195
                self.push()
15✔
196

197
        except GitError as error:
15✔
198
            self.print_error(error)
15✔
199

200
            # Used for test cases
201
            if self.testing:
202
                raise
203
            else:  # pragma: no cover
204
                sys.exit(1)
205
        except KeyboardInterrupt:
15✔
206
            sys.exit(130)
15✔
207

208
    def rebase_all_branches(self):
15✔
209
        """ Rebase all branches, if possible. """
210
        col_width = max(len(b.name) for b in self.branches) + 1
15✔
211
        if self.repo.head.is_detached:
15✔
212
            raise GitError("You're not currently on a branch. I'm exiting"
15✔
213
                           " in case you're in the middle of something.")
214
        original_branch = self.repo.active_branch
15✔
215

216
        with self.git.stasher() as stasher:
15✔
217
            for branch in self.branches:
15✔
218
                target = self.target_map[branch.name]
15✔
219

220
                # Print branch name
221
                if branch.name == original_branch.name:
15✔
222
                    attrs = ['bold']
15✔
223
                else:
224
                    attrs = []
15✔
225
                print(colored(branch.name.ljust(col_width), attrs=attrs),
15✔
226
                        end=' ')
227

228
                # Check, if target branch exists
229
                try:
15✔
230
                    if target.name.startswith('./'):
15✔
231
                        # Check, if local branch exists
232
                        self.git.rev_parse(target.name[2:])
15✔
233
                    else:
234
                        # Check, if remote branch exists
235
                        _ = target.commit
15✔
236

237
                except (ValueError, GitError):
15✔
238
                    # Remote branch doesn't exist!
239
                    print(colored('error: remote branch doesn\'t exist', 'red'))
15✔
240
                    self.states.append('remote branch doesn\'t exist')
15✔
241

242
                    continue
15✔
243

244
                # Get tracking branch
245
                if target.is_local:
15✔
246
                    target = find(self.repo.branches,
15✔
247
                                  lambda b: b.name == target.name[2:])
248

249
                # Check status and act appropriately
250
                if target.commit.hexsha == branch.commit.hexsha:
15✔
251
                    print(colored('up to date', 'green'))
15✔
252
                    self.states.append('up to date')
15✔
253

254
                    continue  # Do not do anything
15✔
255

256
                base = self.git.merge_base(branch.name, target.name)
15✔
257

258
                if base == target.commit.hexsha:
15✔
259
                    print(colored('ahead of upstream', 'cyan'))
15✔
260
                    self.states.append('ahead')
15✔
261

262
                    continue  # Do not do anything
15✔
263

264
                fast_fastforward = False
15✔
265
                if base == branch.commit.hexsha:
15✔
266
                    print(colored('fast-forwarding...', 'yellow'), end='')
15✔
267
                    self.states.append('fast-forwarding')
15✔
268
                    # Don't fast fast-forward the currently checked-out branch
269
                    fast_fastforward = (branch.name !=
15✔
270
                                        self.repo.active_branch.name)
271

272
                elif not self.settings['rebase.auto']:
15✔
273
                    print(colored('diverged', 'red'))
15✔
274
                    self.states.append('diverged')
15✔
275

276
                    continue  # Do not do anything
15✔
277
                else:
278
                    print(colored('rebasing', 'yellow'), end='')
15✔
279
                    self.states.append('rebasing')
15✔
280

281
                if self.settings['rebase.show-hashes']:
15✔
282
                    print(' {}..{}'.format(base[0:7],
×
283
                                           target.commit.hexsha[0:7]))
284
                else:
285
                    print()
15✔
286

287
                self.log(branch, target)
15✔
288
                if fast_fastforward:
15✔
289
                    branch.commit = target.commit
15✔
290
                else:
291
                    stasher()
15✔
292
                    self.git.checkout(branch.name)
15✔
293
                    self.git.rebase(target)
15✔
294

295
            if (self.repo.head.is_detached  # Only on Travis CI,
15✔
296
                    # we get a detached head after doing our rebase *confused*.
297
                    # Running self.repo.active_branch would fail.
298
                    or not self.repo.active_branch.name == original_branch.name):
299
                print(colored(f'returning to {original_branch.name}',
15✔
300
                              'magenta'))
301
                original_branch.checkout()
15✔
302

303
    def fetch(self):
15✔
304
        """
305
        Fetch the recent refs from the remotes.
306

307
        Unless git-up.fetch.all is set to true, all remotes with
308
        locally existent branches will be fetched.
309
        """
310
        fetch_kwargs = {'multiple': True}
15✔
311
        fetch_args = []
15✔
312

313
        if self.is_prune():
15✔
314
            fetch_kwargs['prune'] = True
15✔
315

316
        if self.settings['fetch.all']:
15✔
317
            fetch_kwargs['all'] = True
15✔
318
        else:
319
            if '.' in self.remotes:
15✔
320
                self.remotes.remove('.')
15✔
321

322
                if not self.remotes:
15✔
323
                    # Only local target branches,
324
                    # `git fetch --multiple` will fail
325
                    return
15✔
326

327
            fetch_args.append(self.remotes)
15✔
328

329
        try:
15✔
330
            self.git.fetch(*fetch_args, **fetch_kwargs)
15✔
331
        except GitError as error:
15✔
332
            error.message = "`git fetch` failed"
15✔
333
            raise error
15✔
334

335
    def push(self):
15✔
336
        """
337
        Push the changes back to the remote(s) after fetching
338
        """
339
        print('pushing...')
15✔
340
        push_kwargs = {}
15✔
341
        push_args = []
15✔
342

343
        if self.settings['push.tags']:
15✔
344
            push_kwargs['push'] = True
×
345

346
        if self.settings['push.all']:
15✔
347
            push_kwargs['all'] = True
×
348
        else:
349
            if '.' in self.remotes:
15✔
350
                self.remotes.remove('.')
×
351

352
                if not self.remotes:
×
353
                    # Only local target branches,
354
                    # `git push` will fail
355
                    return
×
356

357
            push_args.append(self.remotes)
15✔
358

359
        try:
15✔
360
            self.git.push(*push_args, **push_kwargs)
15✔
361
            self.pushed = True
15✔
362
        except GitError as error:
×
363
            error.message = "`git push` failed"
×
364
            raise error
×
365

366
    def log(self, branch, remote):
15✔
367
        """ Call a log-command, if set by git-up.fetch.all. """
368
        log_hook = self.settings['rebase.log-hook']
15✔
369

370
        if log_hook:
15✔
371
            if ON_WINDOWS:  # pragma: no cover
372
                # Running a string in CMD from Python is not that easy on
373
                # Windows. Running 'cmd /C log_hook' produces problems when
374
                # using multiple statements or things like 'echo'. Therefore,
375
                # we write the string to a bat file and execute it.
376

377
                # In addition, we replace occurrences of $1 with %1 and so forth
378
                # in case the user is used to Bash or sh.
379
                # If there are occurrences of %something, we'll replace it with
380
                # %%something. This is the case when running something like
381
                # 'git log --pretty=format:"%Cred%h..."'.
382
                # Also, we replace a semicolon with a newline, because if you
383
                # start with 'echo' on Windows, it will simply echo the
384
                # semicolon and the commands behind instead of echoing and then
385
                # running other commands
386

387
                # Prepare log_hook
388
                log_hook = re.sub(r'\$(\d+)', r'%\1', log_hook)
389
                log_hook = re.sub(r'%(?!\d)', '%%', log_hook)
390
                log_hook = re.sub(r'; ?', r'\n', log_hook)
391

392
                # Write log_hook to an temporary file and get it's path
393
                with NamedTemporaryFile(
394
                        prefix='PyGitUp.', suffix='.bat', delete=False
395
                ) as bat_file:
396
                    # Don't echo all commands
397
                    bat_file.file.write(b'@echo off\n')
398
                    # Run log_hook
399
                    bat_file.file.write(log_hook.encode('utf-8'))
400

401
                # Run bat_file
402
                state = subprocess.call(
403
                    [bat_file.name, branch.name, remote.name]
404
                )
405

406
                # Clean up file
407
                os.remove(bat_file.name)
408
            else:  # pragma: no cover
409
                # Run log_hook via 'shell -c'
410
                state = subprocess.call(
411
                    [log_hook, 'git-up', branch.name, remote.name],
412
                    shell=True
413
                )
414

415
            if self.testing:
416
                assert state == 0, 'log_hook returned != 0'
417

418
    def version_info(self):
15✔
419
        """ Tell, what version we're running at and if it's up to date. """
420

421
        # Retrive and show local version info
422
        try:
15✔
423
            local_version_str = metadata.version('git-up')
15✔
424
        except (AttributeError, metadata.PackageNotFoundError):
×
425
            print(
×
426
                colored(
427
                    "Please install 'git-up' via pip in order to get version information.",
428
                    'yellow',
429
                )
430
            )
431
            return
×
432

433
        try:
15✔
434
            local_version = Version(local_version_str)
15✔
435
        except InvalidVersion:
×
436
            print('GitUp version is: ' + colored('v' + local_version_str, 'green'))
×
437
            return
×
438

439
        print('GitUp version is: ' + colored('v' + local_version_str, 'green'))
15✔
440

441
        if not self.settings['updates.check']:
15✔
442
            return
×
443

444
        # Check for updates
445
        print('Checking for updates...', end='')
15✔
446

447
        try:
15✔
448
            # Get version information from the PyPI JSON API
449
            reader = codecs.getreader('utf-8')
15✔
450
            details = json.load(reader(urlopen(PYPI_URL)))
15✔
451
            online_version = details['info']['version']
15✔
452
        except (HTTPError, URLError, ValueError):
×
453
            recent = True  # To not disturb the user with HTTP/parsing errors
×
454
        else:
455
            try:
15✔
456
                recent = local_version >= Version(online_version)
15✔
457
            except InvalidVersion:
×
458
                recent = True
×
459

460
        if not recent:
15✔
461
            # noinspection PyUnboundLocalVariable
462
            print(
×
463
                '\rRecent version is: '
464
                + colored('v' + online_version, color='yellow', attrs=['bold'])
465
            )
466
            print('Run \'pip install -U git-up\' to get the update.')
×
467
        else:
468
            # Clear the update line
469
            sys.stdout.write('\r' + ' ' * 80 + '\n')
15✔
470

471
    ###########################################################################
472
    # Helpers
473
    ###########################################################################
474

475
    def load_config(self):
15✔
476
        """
477
        Load the configuration from git config.
478
        """
479
        for key in self.settings:
15✔
480
            value = self.config(key)
15✔
481
            # Parse true/false
482
            if value == '' or value is None:
15✔
483
                continue  # Not set by user, go on
15✔
484
            if value.lower() == 'true':
15✔
485
                value = True
15✔
486
            elif value.lower() == 'false':
15✔
487
                value = False
15✔
488
            elif value:
15✔
489
                pass  # A user-defined string, store the value later
15✔
490

491
            self.settings[key] = value
15✔
492

493
    def config(self, key):
15✔
494
        """ Get a git-up-specific config value. """
495
        return self.git.config(f'git-up.{key}')
15✔
496

497
    def is_prune(self):
15✔
498
        """
499
        Return True, if `git fetch --prune` is allowed.
500

501
        Because of possible incompatibilities, this requires special
502
        treatment.
503
        """
504
        required_version = "1.6.6"
15✔
505
        config_value = self.settings['fetch.prune']
15✔
506

507
        if self.git.is_version_min(required_version):
15✔
508
            return config_value is not False
15✔
509
        else:  # pragma: no cover
510
            if config_value == 'true':
511
                print(colored(
512
                    "Warning: fetch.prune is set to 'true' but your git"
513
                    "version doesn't seem to support it ({} < {})."
514
                    "Defaulting to 'false'.".format(self.git.version,
515
                                                    required_version),
516
                    'yellow'
517
                ))
518

519
    def print_error(self, error):
15✔
520
        """
521
        Print more information about an error.
522

523
        :type error: GitError
524
        """
525
        print(colored(error.message, 'red'), file=self.stderr)
15✔
526

527
        if error.stdout or error.stderr:
15✔
528
            print(file=self.stderr)
15✔
529
            print("Here's what git said:", file=self.stderr)
15✔
530
            print(file=self.stderr)
15✔
531

532
            if error.stdout:
15✔
533
                print(error.stdout, file=self.stderr)
15✔
534
            if error.stderr:
15✔
535
                print(error.stderr, file=self.stderr)
15✔
536

537
        if error.details:
15✔
538
            print(file=self.stderr)
×
539
            print("Here's what we know:", file=self.stderr)
×
540
            print(str(error.details), file=self.stderr)
×
541
            print(file=self.stderr)
×
542

543

544
###############################################################################
545

546

547
EPILOG = '''
15✔
548
For configuration options, please see
549
https://github.com/msiemens/PyGitUp#readme.
550

551
\b
552
Python port of https://github.com/aanand/git-up/
553
Project Author: Markus Siemens <markus@m-siemens.de>
554
Project URL: https://github.com/msiemens/PyGitUp
555
\b
556
'''
557

558

559
def run():  # pragma: no cover
560
    """
561
    A nicer `git pull`.
562
    """
563

564
    parser = argparse.ArgumentParser(description="A nicer `git pull`.", epilog=EPILOG)
565
    parser.add_argument('-V', '--version', action='store_true',
566
                        help='Show version (and if there is a newer version).')
567
    parser.add_argument('-q', '--quiet', action='store_true',
568
                        help='Be quiet, only print error messages.')
569
    parser.add_argument('--no-fetch', '--no-f', dest='fetch', action='store_false',
570
                        help='Don\'t try to fetch from origin.')
571
    parser.add_argument('-p', '--push', action='store_true',
572
                        help='Push the changes after pulling successfully.')
573

574
    args = parser.parse_args()
575

576
    if args.version:
577
        if NO_DISTRIBUTE:
578
            print(colored('Please install \'git-up\' via pip in order to '
579
                          'get version information.', 'yellow'))
580
        else:
581
            GitUp(sparse=True).version_info()
582
        return
583

584
    if args.quiet:
585
        sys.stdout = StringIO()
586

587
    try:
588
        gitup = GitUp()
589
        gitup.settings['push.auto'] = args.push
590
        gitup.should_fetch = args.fetch
591
    except GitError:
592
        sys.exit(1)  # Error in constructor
593
    else:
594
        gitup.run()
595

596

597
if __name__ == '__main__':  # pragma: no cover
598
    run()
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc