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

msiemens / PyGitUp / 31095981247

06 Aug 2026 11:07AM UTC coverage: 87.327% (-4.0%) from 91.315%
31095981247

push

github

web-flow
feat: support rebasing branches checked out in worktrees (#145)

* feat: support rebasing branches checked out in worktrees

When a branch is checked out in a separate git worktree, `git checkout`
fails with 'already used by worktree'. Instead of checking out such
branches in the main repo, detect them via `git worktree list --porcelain`
and perform the rebase (or fast-forward merge) directly in the worktree
directory.

This handles:
- Fast-forward: runs `git merge --ff-only` in the worktree
- Rebase: stashes worktree changes if dirty, rebases, then unstashes

Fixes the error:
  fatal: '<branch>' is already used by worktree at '<path>'

Co-Authored-By: Oz <oz-agent@warp.dev>

* fix: skip worktree branches that are mid-rebase

When a linked worktree is in the middle of a rebase, git reports it as
'detached' in `git worktree list --porcelain` rather than pointing to
the branch ref. This caused `git checkout <branch>` to fail with exit
code 128 since git still considers the branch locked to that worktree.

Now detached worktrees are inspected for rebase state via
rebase-merge/head-name and rebase-apply/head-name, and any branch found
there is reported as 'rebase in progress' and skipped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: address reviewer feedback on worktree support

- Rename mid_rebase_branches -> in_progress_branches; skip worktrees
  mid-cherry-pick, -merge, and -bisect in addition to mid-rebase
- Extract _get_worktree_meta_dir() helper to eliminate duplicated .git
  pointer-file parsing
- Add _worktree_has_in_progress_op() to detect cherry-pick/merge/bisect
- Add suppress_pop to stasher() so the stash is not popped when a
  rebase fails with conflicts
- Simplify _rebase_in_worktree() to use GitWrapper.stasher() and
  GitWrapper.rebase() instead of duplicating that logic

---------

Co-authored-by: Oz <oz-agent@warp.dev>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Markus Siemens <mark... (continued)

55 of 82 new or added lines in 2 files covered. (67.07%)

441 of 505 relevant lines covered (87.33%)

13.1 hits per line

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

83.63
/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✔
NEW
281
                    print(colored('operation in progress', 'yellow'))
×
NEW
282
                    self.states.append('operation in progress')
×
NEW
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._run('worktree', 'list', '--porcelain')
15✔
NEW
361
        except GitError:
×
NEW
362
            return worktree_map, in_progress_branches
×
363

364
        current_path = None
15✔
365
        main_worktree = os.path.realpath(self.repo.working_dir)
15✔
366

367
        for line in output.split('\n'):
15✔
368
            line = line.rstrip('\r')
15✔
369
            if line.startswith('worktree '):
15✔
370
                current_path = line[len('worktree '):]
15✔
371
            elif line.startswith('branch refs/heads/'):
15✔
372
                branch_name = line[len('branch refs/heads/'):]
15✔
373
                if current_path and \
15✔
374
                        os.path.realpath(current_path) != main_worktree:
375
                    worktree_map[branch_name] = current_path
15✔
376
                    if self._worktree_has_in_progress_op(current_path):
15✔
NEW
377
                        in_progress_branches.add(branch_name)
×
378
            elif line == 'detached' and current_path:
15✔
379
                if os.path.realpath(current_path) != main_worktree:
15✔
NEW
380
                    branch_name = self._get_rebase_branch(current_path)
×
NEW
381
                    if branch_name:
×
NEW
382
                        worktree_map[branch_name] = current_path
×
NEW
383
                        in_progress_branches.add(branch_name)
×
384

385
        return worktree_map, in_progress_branches
15✔
386

387
    def _get_worktree_meta_dir(self, worktree_path):
15✔
388
        """Return the git metadata directory for a worktree."""
389
        git_file = os.path.join(worktree_path, '.git')
15✔
390
        if not os.path.isfile(git_file):
15✔
391
            return None
15✔
392
        with open(git_file, 'r') as f:
15✔
393
            content = f.read().strip()
15✔
394
        if not content.startswith('gitdir: '):
15✔
NEW
395
            return None
×
396
        meta_dir = content[len('gitdir: '):]
15✔
397
        if not os.path.isabs(meta_dir):
15✔
NEW
398
            meta_dir = os.path.join(worktree_path, meta_dir)
×
399
        return os.path.realpath(meta_dir)
15✔
400

401
    def _worktree_has_in_progress_op(self, worktree_path):
15✔
402
        """Return True if the worktree has a cherry-pick, merge, or bisect in progress."""
403
        meta_dir = self._get_worktree_meta_dir(worktree_path)
15✔
404
        if not meta_dir:
15✔
405
            return False
15✔
406
        for marker in ('CHERRY_PICK_HEAD', 'MERGE_HEAD', 'BISECT_LOG'):
15✔
407
            if os.path.isfile(os.path.join(meta_dir, marker)):
15✔
NEW
408
                return True
×
409
        return False
15✔
410

411
    def _get_rebase_branch(self, worktree_path):
15✔
412
        """Return the branch name if a rebase is in progress in the worktree."""
NEW
413
        meta_dir = self._get_worktree_meta_dir(worktree_path)
×
NEW
414
        if not meta_dir:
×
NEW
415
            return None
×
NEW
416
        for subdir in ('rebase-merge', 'rebase-apply'):
×
NEW
417
            head_name_file = os.path.join(meta_dir, subdir, 'head-name')
×
NEW
418
            if os.path.isfile(head_name_file):
×
NEW
419
                with open(head_name_file, 'r') as f:
×
NEW
420
                    ref = f.read().strip()
×
NEW
421
                if ref.startswith('refs/heads/'):
×
NEW
422
                    return ref[len('refs/heads/'):]
×
NEW
423
        return None
×
424

425
    def _rebase_in_worktree(self, branch, target, worktree_path,
15✔
426
                            fast_forward):
427
        """
428
        Rebase or fast-forward a branch checked out in a worktree.
429

430
        Instead of checking out the branch (which would fail), we operate
431
        directly in the worktree directory where the branch is already
432
        checked out.
433
        """
434
        worktree_repo = Repo(worktree_path, odbt=GitCmdObjectDB)
15✔
435
        worktree_git = GitWrapper(worktree_repo)
15✔
436

437
        if fast_forward:
15✔
438
            worktree_git._run('merge', '--ff-only', target.name)
15✔
439
        else:
440
            with worktree_git.stasher() as stash:
15✔
441
                stash()
15✔
442
                try:
15✔
443
                    worktree_git.rebase(target)
15✔
NEW
444
                except RebaseError:
×
NEW
445
                    stash.suppress_pop = True
×
NEW
446
                    raise
×
447

448
    def fetch(self):
15✔
449
        """
450
        Fetch the recent refs from the remotes.
451

452
        Unless git-up.fetch.all is set to true, all remotes with
453
        locally existent branches will be fetched.
454
        """
455
        fetch_kwargs = {'multiple': True}
15✔
456
        fetch_args = []
15✔
457

458
        if self.is_prune():
15✔
459
            fetch_kwargs['prune'] = True
15✔
460

461
        if self.settings['fetch.all']:
15✔
462
            fetch_kwargs['all'] = True
15✔
463
        else:
464
            if '.' in self.remotes:
15✔
465
                self.remotes.remove('.')
15✔
466

467
                if not self.remotes:
15✔
468
                    # Only local target branches,
469
                    # `git fetch --multiple` will fail
470
                    return
15✔
471

472
            fetch_args.append(self.remotes)
15✔
473

474
        try:
15✔
475
            self.git.fetch(*fetch_args, **fetch_kwargs)
15✔
476
        except GitError as error:
15✔
477
            error.message = "`git fetch` failed"
15✔
478
            raise error
15✔
479

480
    def push(self):
15✔
481
        """
482
        Push the changes back to the remote(s) after fetching
483
        """
484
        print('pushing...')
15✔
485
        push_kwargs = {}
15✔
486
        push_args = []
15✔
487

488
        if self.settings['push.tags']:
15✔
489
            push_kwargs['push'] = True
×
490

491
        if self.settings['push.all']:
15✔
492
            push_kwargs['all'] = True
×
493
        else:
494
            if '.' in self.remotes:
15✔
495
                self.remotes.remove('.')
×
496

497
                if not self.remotes:
×
498
                    # Only local target branches,
499
                    # `git push` will fail
500
                    return
×
501

502
            push_args.append(self.remotes)
15✔
503

504
        try:
15✔
505
            self.git.push(*push_args, **push_kwargs)
15✔
506
            self.pushed = True
15✔
507
        except GitError as error:
×
508
            error.message = "`git push` failed"
×
509
            raise error
×
510

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

515
        if log_hook:
15✔
516
            if ON_WINDOWS:  # pragma: no cover
517
                # Running a string in CMD from Python is not that easy on
518
                # Windows. Running 'cmd /C log_hook' produces problems when
519
                # using multiple statements or things like 'echo'. Therefore,
520
                # we write the string to a bat file and execute it.
521

522
                # Write log_hook to an temporary file and get it's path
523
                with NamedTemporaryFile(
524
                        prefix='PyGitUp.', suffix='.bat', delete=False
525
                ) as bat_file:
526
                    # Don't echo all commands
527
                    bat_file.file.write(b'@echo off\n')
528
                    # Required by the !GITUP_ARG*! reads in the prepared hook
529
                    bat_file.file.write(b'setlocal enabledelayedexpansion\n')
530
                    # Run log_hook
531
                    bat_file.file.write(
532
                        prepare_windows_log_hook(log_hook).encode('utf-8')
533
                    )
534

535
                # Pass the branch and remote name through the environment
536
                # rather than as arguments, so they never reach a command line
537
                # cmd parses.
538
                env = os.environ.copy()
539
                env['GITUP_ARG1'] = branch.name
540
                env['GITUP_ARG2'] = remote.name
541

542
                try:
543
                    state = subprocess.call([bat_file.name], env=env)
544
                finally:
545
                    # Clean up file
546
                    os.remove(bat_file.name)
547
            else:  # pragma: no cover
548
                def _escape_positional(value):
549
                    # Neutralize command substitution/backticks in branch names
550
                    return value.replace('$', r'\$').replace('`', r'\`')
551

552
                # Run log_hook via 'shell -c'
553
                # Disable globbing and word-splitting to keep $1/$2 safe
554
                state = subprocess.call(
555
                    ['sh', '-c', 'set -f; IFS=; ' + log_hook,
556
                     'git-up', _escape_positional(branch.name),
557
                     _escape_positional(remote.name)]
558
                )
559

560
            if self.testing:
561
                assert state == 0, 'log_hook returned != 0'
562

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

566
        # Retrive and show local version info
567
        try:
15✔
568
            local_version_str = metadata.version('git-up')
15✔
569
        except (AttributeError, metadata.PackageNotFoundError):
×
570
            print(
×
571
                colored(
572
                    "Please install 'git-up' via pip in order to get version information.",
573
                    'yellow',
574
                )
575
            )
576
            return
×
577

578
        try:
15✔
579
            local_version = Version(local_version_str)
15✔
580
        except InvalidVersion:
×
581
            print('GitUp version is: ' + colored('v' + local_version_str, 'green'))
×
582
            return
×
583

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

586
        if not self.settings['updates.check']:
15✔
587
            return
×
588

589
        # Check for updates
590
        print('Checking for updates...', end='')
15✔
591

592
        try:
15✔
593
            # Get version information from the PyPI JSON API
594
            reader = codecs.getreader('utf-8')
15✔
595
            details = json.load(reader(urlopen(PYPI_URL)))
15✔
596
            online_version = details['info']['version']
15✔
597
        except (HTTPError, URLError, ValueError):
×
598
            recent = True  # To not disturb the user with HTTP/parsing errors
×
599
        else:
600
            try:
15✔
601
                recent = local_version >= Version(online_version)
15✔
602
            except InvalidVersion:
×
603
                recent = True
×
604

605
        if not recent:
15✔
606
            # noinspection PyUnboundLocalVariable
607
            print(
×
608
                '\rRecent version is: '
609
                + colored('v' + online_version, color='yellow', attrs=['bold'])
610
            )
611
            print('Run \'pip install -U git-up\' to get the update.')
×
612
        else:
613
            # Clear the update line
614
            sys.stdout.write('\r' + ' ' * 80 + '\n')
15✔
615

616
    ###########################################################################
617
    # Helpers
618
    ###########################################################################
619

620
    def load_config(self):
15✔
621
        """
622
        Load the configuration from git config.
623
        """
624
        for key in self.settings:
15✔
625
            value = self.config(key)
15✔
626
            # Parse true/false
627
            if value == '' or value is None:
15✔
628
                continue  # Not set by user, go on
15✔
629
            if value.lower() == 'true':
15✔
630
                value = True
15✔
631
            elif value.lower() == 'false':
15✔
632
                value = False
15✔
633
            elif value:
15✔
634
                pass  # A user-defined string, store the value later
15✔
635

636
            self.settings[key] = value
15✔
637

638
    def config(self, key):
15✔
639
        """ Get a git-up-specific config value. """
640
        return self.git.config(f'git-up.{key}')
15✔
641

642
    def is_prune(self):
15✔
643
        """
644
        Return True, if `git fetch --prune` is allowed.
645

646
        Because of possible incompatibilities, this requires special
647
        treatment.
648
        """
649
        required_version = "1.6.6"
15✔
650
        config_value = self.settings['fetch.prune']
15✔
651

652
        if self.git.is_version_min(required_version):
15✔
653
            return config_value is not False
15✔
654
        else:  # pragma: no cover
655
            if config_value == 'true':
656
                print(colored(
657
                    "Warning: fetch.prune is set to 'true' but your git"
658
                    "version doesn't seem to support it ({} < {})."
659
                    "Defaulting to 'false'.".format(self.git.version,
660
                                                    required_version),
661
                    'yellow'
662
                ))
663

664
    def print_error(self, error):
15✔
665
        """
666
        Print more information about an error.
667

668
        :type error: GitError
669
        """
670
        print(colored(error.message, 'red'), file=self.stderr)
15✔
671

672
        if error.stdout or error.stderr:
15✔
673
            print(file=self.stderr)
15✔
674
            print("Here's what git said:", file=self.stderr)
15✔
675
            print(file=self.stderr)
15✔
676

677
            if error.stdout:
15✔
678
                print(error.stdout, file=self.stderr)
13✔
679
            if error.stderr:
15✔
680
                print(error.stderr, file=self.stderr)
15✔
681

682
        if error.details:
15✔
683
            print(file=self.stderr)
×
684
            print("Here's what we know:", file=self.stderr)
×
685
            print(str(error.details), file=self.stderr)
×
686
            print(file=self.stderr)
×
687

688

689
###############################################################################
690

691

692
EPILOG = '''
15✔
693
For configuration options, please see
694
https://github.com/msiemens/PyGitUp#readme.
695

696
\b
697
Python port of https://github.com/aanand/git-up/
698
Project Author: Markus Siemens <markus@m-siemens.de>
699
Project URL: https://github.com/msiemens/PyGitUp
700
\b
701
'''
702

703

704
def run():  # pragma: no cover
705
    """
706
    A nicer `git pull`.
707
    """
708

709
    parser = argparse.ArgumentParser(description="A nicer `git pull`.", epilog=EPILOG)
710
    parser.add_argument('-V', '--version', action='store_true',
711
                        help='Show version (and if there is a newer version).')
712
    parser.add_argument('-q', '--quiet', action='store_true',
713
                        help='Be quiet, only print error messages.')
714
    parser.add_argument('--no-fetch', '--no-f', dest='fetch', action='store_false',
715
                        help='Don\'t try to fetch from origin.')
716
    parser.add_argument('-p', '--push', action='store_true',
717
                        help='Push the changes after pulling successfully.')
718

719
    args = parser.parse_args()
720

721
    if args.version:
722
        if NO_DISTRIBUTE:
723
            print(colored('Please install \'git-up\' via pip in order to '
724
                          'get version information.', 'yellow'))
725
        else:
726
            GitUp(sparse=True).version_info()
727
        return
728

729
    if args.quiet:
730
        sys.stdout = StringIO()
731

732
    try:
733
        gitup = GitUp()
734
        gitup.settings['push.auto'] = args.push
735
        gitup.should_fetch = args.fetch
736
    except GitError:
737
        sys.exit(1)  # Error in constructor
738
    else:
739
        gitup.run()
740

741

742
if __name__ == '__main__':  # pragma: no cover
743
    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