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

msiemens / PyGitUp / 31097746117

06 Aug 2026 11:33AM UTC coverage: 87.549% (+0.2%) from 87.327%
31097746117

push

github

msiemens
fix: make worktree detection work on MinGW

16 of 22 new or added lines in 2 files covered. (72.73%)

1 existing line in 1 file now uncovered.

450 of 514 relevant lines covered (87.55%)

13.13 hits per line

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

84.06
/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, RebaseError
15✔
42

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

45
def normalize_path(path):
15✔
46
    if ON_WINDOWS and path and path[0] == '/':
15✔
47
        return execute(['cygpath', '-m', path])
×
48

49
    return path
15✔
50

51

52
def prepare_windows_log_hook(log_hook):
15✔
53
    """ Turn a log hook into the body of a batch file.
54

55
    Positional arguments become delayed-expansion reads of the GITUP_ARG*
56
    environment variables. cmd substitutes %1 and %VAR% into a line before
57
    parsing it, so a branch name containing '&' or '|' would be parsed as
58
    syntax rather than data; !VAR! is expanded after the line is parsed.
59
    """
60
    # Accept $1 and $2 as well, in case the user is used to Bash or sh
61
    log_hook = re.sub(r'\$(\d+)', r'%\1', log_hook)
15✔
62

63
    # Escape a lone percent sign, as in 'git log --pretty=format:"%Cred%h"'
64
    log_hook = re.sub(r'%(?!\d)', '%%', log_hook)
15✔
65

66
    # Keep literal exclamation marks literal now that delayed expansion is on
67
    log_hook = log_hook.replace('!', '^!')
15✔
68

69
    log_hook = re.sub(r'%(\d+)', r'!GITUP_ARG\1!', log_hook)
15✔
70

71
    # Starting a line with 'echo' would echo a semicolon instead of treating
72
    # it as a command separator
73
    log_hook = re.sub(r'; ?', r'\n', log_hook)
15✔
74

75
    return log_hook
15✔
76

77
###############################################################################
78
# Setup of 3rd party libs
79
###############################################################################
80

81
colorama.init(autoreset=True, convert=ON_WINDOWS)
15✔
82

83
###############################################################################
84
# Setup constants
85
###############################################################################
86

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

89

90
###############################################################################
91
# GitUp
92
###############################################################################
93

94
def get_git_dir():
15✔
95
    toplevel_dir = execute(['git', 'rev-parse', '--show-toplevel'])
15✔
96
    toplevel_dir = normalize_path(toplevel_dir)
15✔
97

98
    if toplevel_dir is not None \
15✔
99
            and os.path.isfile(os.path.join(toplevel_dir, '.git')):
100
        # Not a normal git repo. Check if it's a submodule, then use
101
        # toplevel_dir. Otherwise it's a worktree, thus use  common_dir.
102
        # NOTE: git worktree support only comes with git v2.5.0 or
103
        # later, on earlier versions toplevel_dir is the best we can do.
104

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

108
        if inside_worktree == 'true' or Git().version_info[:3] < (2, 5, 0):
15✔
109
            return toplevel_dir
15✔
110
        else:
111
            common_dir = execute(['git', 'rev-parse', '--git-common-dir'])
15✔
112
            return normalize_path(common_dir)
15✔
113

114
    return toplevel_dir
15✔
115

116

117
class GitUp:
15✔
118
    """ Conainter class for GitUp methods """
119

120
    default_settings = {
15✔
121
        'fetch.prune': True,
122
        'fetch.all': False,
123
        'rebase.show-hashes': False,
124
        'rebase.arguments': None,
125
        'rebase.auto': True,
126
        'rebase.log-hook': None,
127
        'updates.check': True,
128
        'push.auto': False,
129
        'push.tags': False,
130
        'push.all': False,
131
    }
132

133
    def __init__(self, testing=False, sparse=False):
15✔
134
        # Sparse init: config only
135
        if sparse:
15✔
136
            self.git = GitWrapper(None)
15✔
137

138
            # Load configuration
139
            self.settings = self.default_settings.copy()
15✔
140
            self.load_config()
15✔
141
            return
15✔
142

143
        # Testing: redirect stderr to stdout
144
        self.testing = testing
15✔
145
        if self.testing:
146
            self.stderr = sys.stdout  # Quiet testing
147
        else:  # pragma: no cover
148
            self.stderr = sys.stderr
149

150
        self.states = []
15✔
151
        self.should_fetch = True
15✔
152
        self.pushed = False
15✔
153

154
        # Check, if we're in a git repo
155
        try:
15✔
156
            repo_dir = get_git_dir()
15✔
157
        except (OSError, GitCommandNotFound) as e:
15✔
158
            if isinstance(e, GitCommandNotFound) or e.errno == errno.ENOENT:
15✔
159
                exc = GitError("The git executable could not be found")
15✔
160
                raise exc
15✔
161
            else:
162
                raise
×
163
        else:
164
            if repo_dir is None:
15✔
165
                exc = GitError("We don't seem to be in a git repository.")
15✔
166
                raise exc
15✔
167

168
            self.repo = Repo(repo_dir, odbt=GitCmdObjectDB)
15✔
169

170
        # Check for branch tracking information
171
        if not any(b.tracking_branch() for b in self.repo.branches):
15✔
172
            exc = GitError("Can\'t update your repo because it doesn\'t has "
15✔
173
                           "any branches with tracking information.")
174
            self.print_error(exc)
15✔
175

176
            raise exc
15✔
177

178
        self.git = GitWrapper(self.repo)
15✔
179

180
        # target_map: map local branch names to remote tracking branches
181
        #: :type: dict[str, git.refs.remote.RemoteReference]
182
        self.target_map = dict()
15✔
183

184
        for branch in self.repo.branches:
15✔
185
            target = branch.tracking_branch()
15✔
186

187
            if target:
15✔
188
                if target.name.startswith('./'):
15✔
189
                    # Tracking branch is in local repo
190
                    target.is_local = True
15✔
191
                else:
192
                    target.is_local = False
15✔
193

194
                self.target_map[branch.name] = target
15✔
195

196
        # branches: all local branches with tracking information
197
        #: :type: list[git.refs.head.Head]
198
        self.branches = [b for b in self.repo.branches if b.tracking_branch()]
15✔
199
        self.branches.sort(key=lambda br: br.name)
15✔
200

201
        # remotes: all remotes that are associated with local branches
202
        #: :type: list[git.refs.remote.RemoteReference]
203
        self.remotes = uniq(
15✔
204
            # name = '<remote>/<branch>' -> '<remote>'
205
            [r.name.split('/', 2)[0]
206
             for r in list(self.target_map.values())]
207
        )
208

209
        # change_count: Number of unstaged changes
210
        self.change_count = len(
15✔
211
            self.git.status(porcelain=True, untracked_files='no').split('\n')
212
        )
213

214
        # Build worktree map: branch name -> worktree path
215
        self.worktree_map, self.in_progress_branches = self._build_worktree_map()
15✔
216

217
        # Load configuration
218
        self.settings = self.default_settings.copy()
15✔
219
        self.load_config()
15✔
220

221
    def run(self):
15✔
222
        """ Run all the git-up stuff. """
223
        try:
15✔
224
            if self.should_fetch:
15✔
225
                self.fetch()
15✔
226

227
            self.rebase_all_branches()
15✔
228

229
            if self.settings['push.auto']:
15✔
230
                self.push()
15✔
231

232
        except GitError as error:
15✔
233
            self.print_error(error)
15✔
234

235
            # Used for test cases
236
            if self.testing:
237
                raise
238
            else:  # pragma: no cover
239
                sys.exit(1)
240
        except KeyboardInterrupt:
15✔
241
            sys.exit(130)
15✔
242

243
    def rebase_all_branches(self):
15✔
244
        """ Rebase all branches, if possible. """
245
        col_width = max(len(b.name) for b in self.branches) + 1
15✔
246
        if self.repo.head.is_detached:
15✔
247
            raise GitError("You're not currently on a branch. I'm exiting"
15✔
248
                           " in case you're in the middle of something.")
249
        original_branch = self.repo.active_branch
15✔
250

251
        with self.git.stasher() as stasher:
15✔
252
            for branch in self.branches:
15✔
253
                target = self.target_map[branch.name]
15✔
254

255
                # Print branch name
256
                if branch.name == original_branch.name:
15✔
257
                    attrs = ['bold']
15✔
258
                else:
259
                    attrs = []
15✔
260
                print(colored(branch.name.ljust(col_width), attrs=attrs),
15✔
261
                        end=' ')
262

263
                # Check, if target branch exists
264
                try:
15✔
265
                    if target.name.startswith('./'):
15✔
266
                        # Check, if local branch exists
267
                        self.git.rev_parse(target.name[2:])
15✔
268
                    else:
269
                        # Check, if remote branch exists
270
                        _ = target.commit
15✔
271

272
                except (ValueError, GitError):
15✔
273
                    # Remote branch doesn't exist!
274
                    print(colored('error: remote branch doesn\'t exist', 'red'))
15✔
275
                    self.states.append('remote branch doesn\'t exist')
15✔
276

277
                    continue
15✔
278

279
                # Skip branches whose worktree has an in-progress operation
280
                if branch.name in self.in_progress_branches:
15✔
281
                    print(colored('operation in progress', 'yellow'))
×
282
                    self.states.append('operation in progress')
×
283
                    continue
×
284

285
                # Get tracking branch
286
                if target.is_local:
15✔
287
                    target = find(self.repo.branches,
15✔
288
                                  lambda b: b.name == target.name[2:])
289

290
                # Check status and act appropriately
291
                if target.commit.hexsha == branch.commit.hexsha:
15✔
292
                    print(colored('up to date', 'green'))
15✔
293
                    self.states.append('up to date')
15✔
294

295
                    continue  # Do not do anything
15✔
296

297
                base = self.git.merge_base(branch.name, target.name)
15✔
298

299
                if base == target.commit.hexsha:
15✔
300
                    print(colored('ahead of upstream', 'cyan'))
15✔
301
                    self.states.append('ahead')
15✔
302

303
                    continue  # Do not do anything
15✔
304

305
                fast_fastforward = False
15✔
306
                if base == branch.commit.hexsha:
15✔
307
                    print(colored('fast-forwarding...', 'yellow'), end='')
15✔
308
                    self.states.append('fast-forwarding')
15✔
309
                    # Don't fast fast-forward the currently checked-out branch
310
                    fast_fastforward = (branch.name !=
15✔
311
                                        self.repo.active_branch.name)
312

313
                elif not self.settings['rebase.auto']:
15✔
314
                    print(colored('diverged', 'red'))
15✔
315
                    self.states.append('diverged')
15✔
316

317
                    continue  # Do not do anything
15✔
318
                else:
319
                    print(colored('rebasing', 'yellow'), end='')
15✔
320
                    self.states.append('rebasing')
15✔
321

322
                if self.settings['rebase.show-hashes']:
15✔
323
                    print(' {}..{}'.format(base[0:7],
×
324
                                           target.commit.hexsha[0:7]))
325
                else:
326
                    print()
15✔
327

328
                self.log(branch, target)
15✔
329
                worktree_path = self.worktree_map.get(branch.name)
15✔
330
                if worktree_path:
15✔
331
                    self._rebase_in_worktree(
15✔
332
                        branch, target, worktree_path, fast_fastforward
333
                    )
334
                elif fast_fastforward:
15✔
335
                    branch.commit = target.commit
15✔
336
                else:
337
                    stasher()
15✔
338
                    self.git.checkout(branch.name)
15✔
339
                    self.git.rebase(target)
15✔
340

341
            if (self.repo.head.is_detached  # Only on Travis CI,
15✔
342
                    # we get a detached head after doing our rebase *confused*.
343
                    # Running self.repo.active_branch would fail.
344
                    or not self.repo.active_branch.name == original_branch.name):
345
                print(colored(f'returning to {original_branch.name}',
15✔
346
                              'magenta'))
347
                original_branch.checkout()
15✔
348

349
    def _build_worktree_map(self):
15✔
350
        """
351
        Build a map of branch names to worktree paths.
352

353
        This allows us to detect branches that are checked out in
354
        separate worktrees, so we can rebase them in-place instead of
355
        failing on checkout.
356
        """
357
        worktree_map = {}
15✔
358
        in_progress_branches = set()
15✔
359
        try:
15✔
360
            output = self.git.worktree('list', '--porcelain')
15✔
361
        except GitError:
×
362
            return worktree_map, in_progress_branches
×
363

364
        # The branch checked out in the current worktree is handled via the
365
        # regular checkout path. Exclude it by name instead of comparing
366
        # paths: a branch can only be checked out in one worktree, and the
367
        # paths reported by git may not be resolvable by Python (MSYS2 git
368
        # reports POSIX-style paths).
369
        active_branch = None
15✔
370
        if not self.repo.head.is_detached:
15✔
371
            active_branch = self.repo.active_branch.name
15✔
372

373
        current_path = None
15✔
374
        for line in output.split('\n'):
15✔
375
            line = line.rstrip('\r')
15✔
376
            if line.startswith('worktree '):
15✔
377
                current_path = self._normalize_git_path(
15✔
378
                    line[len('worktree '):]
379
                )
380
            elif line.startswith('branch refs/heads/'):
15✔
381
                branch_name = line[len('branch refs/heads/'):]
15✔
382
                if current_path and branch_name != active_branch:
15✔
383
                    worktree_map[branch_name] = current_path
15✔
384
                    if self._worktree_has_in_progress_op(current_path):
15✔
385
                        in_progress_branches.add(branch_name)
×
386
            elif line == 'detached' and current_path:
15✔
387
                branch_name = self._get_rebase_branch(current_path)
15✔
388
                if branch_name and branch_name != active_branch:
15✔
NEW
389
                    worktree_map[branch_name] = current_path
×
NEW
390
                    in_progress_branches.add(branch_name)
×
391

392
        return worktree_map, in_progress_branches
15✔
393

394
    @staticmethod
15✔
395
    def _normalize_git_path(path):
15✔
396
        """
397
        Convert a POSIX-style path reported by MSYS2 git into a path
398
        usable by a native Windows Python.
399
        """
400
        if ON_WINDOWS and path.startswith('/'):
15✔
NEW
401
            try:
×
NEW
402
                path = subprocess.check_output(
×
403
                    ['cygpath', '-m', path], text=True
404
                ).strip()
NEW
405
            except (OSError, subprocess.CalledProcessError):
×
NEW
406
                pass
×
407
        return path
15✔
408

409
    def _get_worktree_meta_dir(self, worktree_path):
15✔
410
        """Return the git metadata directory for a worktree."""
411
        git_file = os.path.join(worktree_path, '.git')
15✔
412
        if not os.path.isfile(git_file):
15✔
413
            return None
15✔
414
        with open(git_file, 'r') as f:
15✔
415
            content = f.read().strip()
15✔
416
        if not content.startswith('gitdir: '):
15✔
417
            return None
×
418
        meta_dir = self._normalize_git_path(content[len('gitdir: '):])
15✔
419
        if not os.path.isabs(meta_dir):
15✔
420
            meta_dir = os.path.join(worktree_path, meta_dir)
×
421
        return os.path.realpath(meta_dir)
15✔
422

423
    def _worktree_has_in_progress_op(self, worktree_path):
15✔
424
        """Return True if the worktree has a cherry-pick, merge, or bisect in progress."""
425
        meta_dir = self._get_worktree_meta_dir(worktree_path)
15✔
426
        if not meta_dir:
15✔
UNCOV
427
            return False
×
428
        for marker in ('CHERRY_PICK_HEAD', 'MERGE_HEAD', 'BISECT_LOG'):
15✔
429
            if os.path.isfile(os.path.join(meta_dir, marker)):
15✔
430
                return True
×
431
        return False
15✔
432

433
    def _get_rebase_branch(self, worktree_path):
15✔
434
        """Return the branch name if a rebase is in progress in the worktree."""
435
        meta_dir = self._get_worktree_meta_dir(worktree_path)
15✔
436
        if not meta_dir:
15✔
437
            return None
15✔
438
        for subdir in ('rebase-merge', 'rebase-apply'):
×
439
            head_name_file = os.path.join(meta_dir, subdir, 'head-name')
×
440
            if os.path.isfile(head_name_file):
×
441
                with open(head_name_file, 'r') as f:
×
442
                    ref = f.read().strip()
×
443
                if ref.startswith('refs/heads/'):
×
444
                    return ref[len('refs/heads/'):]
×
445
        return None
×
446

447
    def _rebase_in_worktree(self, branch, target, worktree_path,
15✔
448
                            fast_forward):
449
        """
450
        Rebase or fast-forward a branch checked out in a worktree.
451

452
        Instead of checking out the branch (which would fail), we operate
453
        directly in the worktree directory where the branch is already
454
        checked out.
455
        """
456
        worktree_repo = Repo(worktree_path, odbt=GitCmdObjectDB)
15✔
457
        worktree_git = GitWrapper(worktree_repo)
15✔
458

459
        if fast_forward:
15✔
460
            worktree_git._run('merge', '--ff-only', target.name)
15✔
461
        else:
462
            with worktree_git.stasher() as stash:
15✔
463
                stash()
15✔
464
                try:
15✔
465
                    worktree_git.rebase(target)
15✔
466
                except RebaseError:
×
467
                    stash.suppress_pop = True
×
468
                    raise
×
469

470
    def fetch(self):
15✔
471
        """
472
        Fetch the recent refs from the remotes.
473

474
        Unless git-up.fetch.all is set to true, all remotes with
475
        locally existent branches will be fetched.
476
        """
477
        fetch_kwargs = {'multiple': True}
15✔
478
        fetch_args = []
15✔
479

480
        if self.is_prune():
15✔
481
            fetch_kwargs['prune'] = True
15✔
482

483
        if self.settings['fetch.all']:
15✔
484
            fetch_kwargs['all'] = True
15✔
485
        else:
486
            if '.' in self.remotes:
15✔
487
                self.remotes.remove('.')
15✔
488

489
                if not self.remotes:
15✔
490
                    # Only local target branches,
491
                    # `git fetch --multiple` will fail
492
                    return
15✔
493

494
            fetch_args.append(self.remotes)
15✔
495

496
        try:
15✔
497
            self.git.fetch(*fetch_args, **fetch_kwargs)
15✔
498
        except GitError as error:
15✔
499
            error.message = "`git fetch` failed"
15✔
500
            raise error
15✔
501

502
    def push(self):
15✔
503
        """
504
        Push the changes back to the remote(s) after fetching
505
        """
506
        print('pushing...')
15✔
507
        push_kwargs = {}
15✔
508
        push_args = []
15✔
509

510
        if self.settings['push.tags']:
15✔
511
            push_kwargs['push'] = True
×
512

513
        if self.settings['push.all']:
15✔
514
            push_kwargs['all'] = True
×
515
        else:
516
            if '.' in self.remotes:
15✔
517
                self.remotes.remove('.')
×
518

519
                if not self.remotes:
×
520
                    # Only local target branches,
521
                    # `git push` will fail
522
                    return
×
523

524
            push_args.append(self.remotes)
15✔
525

526
        try:
15✔
527
            self.git.push(*push_args, **push_kwargs)
15✔
528
            self.pushed = True
15✔
529
        except GitError as error:
×
530
            error.message = "`git push` failed"
×
531
            raise error
×
532

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

537
        if log_hook:
15✔
538
            if ON_WINDOWS:  # pragma: no cover
539
                # Running a string in CMD from Python is not that easy on
540
                # Windows. Running 'cmd /C log_hook' produces problems when
541
                # using multiple statements or things like 'echo'. Therefore,
542
                # we write the string to a bat file and execute it.
543

544
                # Write log_hook to an temporary file and get it's path
545
                with NamedTemporaryFile(
546
                        prefix='PyGitUp.', suffix='.bat', delete=False
547
                ) as bat_file:
548
                    # Don't echo all commands
549
                    bat_file.file.write(b'@echo off\n')
550
                    # Required by the !GITUP_ARG*! reads in the prepared hook
551
                    bat_file.file.write(b'setlocal enabledelayedexpansion\n')
552
                    # Run log_hook
553
                    bat_file.file.write(
554
                        prepare_windows_log_hook(log_hook).encode('utf-8')
555
                    )
556

557
                # Pass the branch and remote name through the environment
558
                # rather than as arguments, so they never reach a command line
559
                # cmd parses.
560
                env = os.environ.copy()
561
                env['GITUP_ARG1'] = branch.name
562
                env['GITUP_ARG2'] = remote.name
563

564
                try:
565
                    state = subprocess.call([bat_file.name], env=env)
566
                finally:
567
                    # Clean up file
568
                    os.remove(bat_file.name)
569
            else:  # pragma: no cover
570
                def _escape_positional(value):
571
                    # Neutralize command substitution/backticks in branch names
572
                    return value.replace('$', r'\$').replace('`', r'\`')
573

574
                # Run log_hook via 'shell -c'
575
                # Disable globbing and word-splitting to keep $1/$2 safe
576
                state = subprocess.call(
577
                    ['sh', '-c', 'set -f; IFS=; ' + log_hook,
578
                     'git-up', _escape_positional(branch.name),
579
                     _escape_positional(remote.name)]
580
                )
581

582
            if self.testing:
583
                assert state == 0, 'log_hook returned != 0'
584

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

588
        # Retrive and show local version info
589
        try:
15✔
590
            local_version_str = metadata.version('git-up')
15✔
591
        except (AttributeError, metadata.PackageNotFoundError):
×
592
            print(
×
593
                colored(
594
                    "Please install 'git-up' via pip in order to get version information.",
595
                    'yellow',
596
                )
597
            )
598
            return
×
599

600
        try:
15✔
601
            local_version = Version(local_version_str)
15✔
602
        except InvalidVersion:
×
603
            print('GitUp version is: ' + colored('v' + local_version_str, 'green'))
×
604
            return
×
605

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

608
        if not self.settings['updates.check']:
15✔
609
            return
×
610

611
        # Check for updates
612
        print('Checking for updates...', end='')
15✔
613

614
        try:
15✔
615
            # Get version information from the PyPI JSON API
616
            reader = codecs.getreader('utf-8')
15✔
617
            details = json.load(reader(urlopen(PYPI_URL)))
15✔
618
            online_version = details['info']['version']
15✔
619
        except (HTTPError, URLError, ValueError):
×
620
            recent = True  # To not disturb the user with HTTP/parsing errors
×
621
        else:
622
            try:
15✔
623
                recent = local_version >= Version(online_version)
15✔
624
            except InvalidVersion:
×
625
                recent = True
×
626

627
        if not recent:
15✔
628
            # noinspection PyUnboundLocalVariable
629
            print(
×
630
                '\rRecent version is: '
631
                + colored('v' + online_version, color='yellow', attrs=['bold'])
632
            )
633
            print('Run \'pip install -U git-up\' to get the update.')
×
634
        else:
635
            # Clear the update line
636
            sys.stdout.write('\r' + ' ' * 80 + '\n')
15✔
637

638
    ###########################################################################
639
    # Helpers
640
    ###########################################################################
641

642
    def load_config(self):
15✔
643
        """
644
        Load the configuration from git config.
645
        """
646
        for key in self.settings:
15✔
647
            value = self.config(key)
15✔
648
            # Parse true/false
649
            if value == '' or value is None:
15✔
650
                continue  # Not set by user, go on
15✔
651
            if value.lower() == 'true':
15✔
652
                value = True
15✔
653
            elif value.lower() == 'false':
15✔
654
                value = False
15✔
655
            elif value:
15✔
656
                pass  # A user-defined string, store the value later
15✔
657

658
            self.settings[key] = value
15✔
659

660
    def config(self, key):
15✔
661
        """ Get a git-up-specific config value. """
662
        return self.git.config(f'git-up.{key}')
15✔
663

664
    def is_prune(self):
15✔
665
        """
666
        Return True, if `git fetch --prune` is allowed.
667

668
        Because of possible incompatibilities, this requires special
669
        treatment.
670
        """
671
        required_version = "1.6.6"
15✔
672
        config_value = self.settings['fetch.prune']
15✔
673

674
        if self.git.is_version_min(required_version):
15✔
675
            return config_value is not False
15✔
676
        else:  # pragma: no cover
677
            if config_value == 'true':
678
                print(colored(
679
                    "Warning: fetch.prune is set to 'true' but your git"
680
                    "version doesn't seem to support it ({} < {})."
681
                    "Defaulting to 'false'.".format(self.git.version,
682
                                                    required_version),
683
                    'yellow'
684
                ))
685

686
    def print_error(self, error):
15✔
687
        """
688
        Print more information about an error.
689

690
        :type error: GitError
691
        """
692
        print(colored(error.message, 'red'), file=self.stderr)
15✔
693

694
        if error.stdout or error.stderr:
15✔
695
            print(file=self.stderr)
15✔
696
            print("Here's what git said:", file=self.stderr)
15✔
697
            print(file=self.stderr)
15✔
698

699
            if error.stdout:
15✔
700
                print(error.stdout, file=self.stderr)
15✔
701
            if error.stderr:
15✔
702
                print(error.stderr, file=self.stderr)
15✔
703

704
        if error.details:
15✔
705
            print(file=self.stderr)
×
706
            print("Here's what we know:", file=self.stderr)
×
707
            print(str(error.details), file=self.stderr)
×
708
            print(file=self.stderr)
×
709

710

711
###############################################################################
712

713

714
EPILOG = '''
15✔
715
For configuration options, please see
716
https://github.com/msiemens/PyGitUp#readme.
717

718
\b
719
Python port of https://github.com/aanand/git-up/
720
Project Author: Markus Siemens <markus@m-siemens.de>
721
Project URL: https://github.com/msiemens/PyGitUp
722
\b
723
'''
724

725

726
def run():  # pragma: no cover
727
    """
728
    A nicer `git pull`.
729
    """
730

731
    parser = argparse.ArgumentParser(description="A nicer `git pull`.", epilog=EPILOG)
732
    parser.add_argument('-V', '--version', action='store_true',
733
                        help='Show version (and if there is a newer version).')
734
    parser.add_argument('-q', '--quiet', action='store_true',
735
                        help='Be quiet, only print error messages.')
736
    parser.add_argument('--no-fetch', '--no-f', dest='fetch', action='store_false',
737
                        help='Don\'t try to fetch from origin.')
738
    parser.add_argument('-p', '--push', action='store_true',
739
                        help='Push the changes after pulling successfully.')
740

741
    args = parser.parse_args()
742

743
    if args.version:
744
        if NO_DISTRIBUTE:
745
            print(colored('Please install \'git-up\' via pip in order to '
746
                          'get version information.', 'yellow'))
747
        else:
748
            GitUp(sparse=True).version_info()
749
        return
750

751
    if args.quiet:
752
        sys.stdout = StringIO()
753

754
    try:
755
        gitup = GitUp()
756
        gitup.settings['push.auto'] = args.push
757
        gitup.should_fetch = args.fetch
758
    except GitError:
759
        sys.exit(1)  # Error in constructor
760
    else:
761
        gitup.run()
762

763

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

© 2026 Coveralls, Inc