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

msiemens / PyGitUp / 11213045688

05 Oct 2024 07:59PM UTC coverage: 93.284%. Remained the same
11213045688

push

github

msiemens
chore: release v2.3.0

375 of 402 relevant lines covered (93.28%)

13.79 hits per line

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

92.31
/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
    # noinspection PyUnresolvedReferences
27
    import pkg_resources as pkg
15✔
28
except ImportError:  # pragma: no cover
29
    NO_DISTRIBUTE = True
30
else:  # pragma: no cover
31
    NO_DISTRIBUTE = False
32

33
import colorama
15✔
34
from git import Repo, GitCmdObjectDB
15✔
35
from termcolor import colored
15✔
36

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

41
ON_WINDOWS = sys.platform == 'win32'
15✔
42

43
###############################################################################
44
# Setup of 3rd party libs
45
###############################################################################
46

47
colorama.init(autoreset=True, convert=ON_WINDOWS)
15✔
48

49
###############################################################################
50
# Setup constants
51
###############################################################################
52

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

55

56
###############################################################################
57
# GitUp
58
###############################################################################
59

60
def get_git_dir():
15✔
61
    toplevel_dir = execute(['git', 'rev-parse', '--show-toplevel'])
15✔
62

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

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

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

78
    return toplevel_dir
15✔
79

80

81
class GitUp:
15✔
82
    """ Conainter class for GitUp methods """
83

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

97
    def __init__(self, testing=False, sparse=False):
15✔
98
        # Sparse init: config only
99
        if sparse:
15✔
100
            self.git = GitWrapper(None)
12✔
101

102
            # Load configuration
103
            self.settings = self.default_settings.copy()
12✔
104
            self.load_config()
12✔
105
            return
12✔
106

107
        # Testing: redirect stderr to stdout
108
        self.testing = testing
15✔
109
        if self.testing:
110
            self.stderr = sys.stdout  # Quiet testing
111
        else:  # pragma: no cover
112
            self.stderr = sys.stderr
113

114
        self.states = []
15✔
115
        self.should_fetch = True
15✔
116
        self.pushed = False
15✔
117

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

132
            self.repo = Repo(repo_dir, odbt=GitCmdObjectDB)
15✔
133

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

140
            raise exc
15✔
141

142
        self.git = GitWrapper(self.repo)
15✔
143

144
        # target_map: map local branch names to remote tracking branches
145
        #: :type: dict[str, git.refs.remote.RemoteReference]
146
        self.target_map = dict()
15✔
147

148
        for branch in self.repo.branches:
15✔
149
            target = branch.tracking_branch()
15✔
150

151
            if target:
15✔
152
                if target.name.startswith('./'):
15✔
153
                    # Tracking branch is in local repo
154
                    target.is_local = True
15✔
155
                else:
156
                    target.is_local = False
15✔
157

158
                self.target_map[branch.name] = target
15✔
159

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

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

173
        # change_count: Number of unstaged changes
174
        self.change_count = len(
15✔
175
            self.git.status(porcelain=True, untracked_files='no').split('\n')
176
        )
177

178
        # Load configuration
179
        self.settings = self.default_settings.copy()
15✔
180
        self.load_config()
15✔
181

182
    def run(self):
15✔
183
        """ Run all the git-up stuff. """
184
        try:
15✔
185
            if self.should_fetch:
15✔
186
                self.fetch()
15✔
187

188
            self.rebase_all_branches()
15✔
189

190
            if self.settings['push.auto']:
15✔
191
                self.push()
15✔
192

193
        except GitError as error:
15✔
194
            self.print_error(error)
15✔
195

196
            # Used for test cases
197
            if self.testing:
198
                raise
199
            else:  # pragma: no cover
200
                sys.exit(1)
201
        except KeyboardInterrupt:
15✔
202
            sys.exit(130)
15✔
203

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

212
        with self.git.stasher() as stasher:
15✔
213
            for branch in self.branches:
15✔
214
                target = self.target_map[branch.name]
15✔
215

216
                # Print branch name
217
                if branch.name == original_branch.name:
15✔
218
                    attrs = ['bold']
15✔
219
                else:
220
                    attrs = []
15✔
221
                print(colored(branch.name.ljust(col_width), attrs=attrs),
15✔
222
                        end=' ')
223

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

233
                except (ValueError, GitError):
15✔
234
                    # Remote branch doesn't exist!
235
                    print(colored('error: remote branch doesn\'t exist', 'red'))
15✔
236
                    self.states.append('remote branch doesn\'t exist')
15✔
237

238
                    continue
15✔
239

240
                # Get tracking branch
241
                if target.is_local:
15✔
242
                    target = find(self.repo.branches,
15✔
243
                                  lambda b: b.name == target.name[2:])
244

245
                # Check status and act appropriately
246
                if target.commit.hexsha == branch.commit.hexsha:
15✔
247
                    print(colored('up to date', 'green'))
15✔
248
                    self.states.append('up to date')
15✔
249

250
                    continue  # Do not do anything
15✔
251

252
                base = self.git.merge_base(branch.name, target.name)
15✔
253

254
                if base == target.commit.hexsha:
15✔
255
                    print(colored('ahead of upstream', 'cyan'))
15✔
256
                    self.states.append('ahead')
15✔
257

258
                    continue  # Do not do anything
15✔
259

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

268
                elif not self.settings['rebase.auto']:
15✔
269
                    print(colored('diverged', 'red'))
15✔
270
                    self.states.append('diverged')
15✔
271

272
                    continue  # Do not do anything
15✔
273
                else:
274
                    print(colored('rebasing', 'yellow'), end='')
15✔
275
                    self.states.append('rebasing')
15✔
276

277
                if self.settings['rebase.show-hashes']:
15✔
278
                    print(' {}..{}'.format(base[0:7],
×
279
                                           target.commit.hexsha[0:7]))
280
                else:
281
                    print()
15✔
282

283
                self.log(branch, target)
15✔
284
                if fast_fastforward:
15✔
285
                    branch.commit = target.commit
15✔
286
                else:
287
                    stasher()
15✔
288
                    self.git.checkout(branch.name)
15✔
289
                    self.git.rebase(target)
15✔
290

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

299
    def fetch(self):
15✔
300
        """
301
        Fetch the recent refs from the remotes.
302

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

309
        if self.is_prune():
15✔
310
            fetch_kwargs['prune'] = True
15✔
311

312
        if self.settings['fetch.all']:
15✔
313
            fetch_kwargs['all'] = True
15✔
314
        else:
315
            if '.' in self.remotes:
15✔
316
                self.remotes.remove('.')
15✔
317

318
                if not self.remotes:
15✔
319
                    # Only local target branches,
320
                    # `git fetch --multiple` will fail
321
                    return
15✔
322

323
            fetch_args.append(self.remotes)
15✔
324

325
        try:
15✔
326
            self.git.fetch(*fetch_args, **fetch_kwargs)
15✔
327
        except GitError as error:
15✔
328
            error.message = "`git fetch` failed"
15✔
329
            raise error
15✔
330

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

339
        if self.settings['push.tags']:
15✔
340
            push_kwargs['push'] = True
×
341

342
        if self.settings['push.all']:
15✔
343
            push_kwargs['all'] = True
×
344
        else:
345
            if '.' in self.remotes:
15✔
346
                self.remotes.remove('.')
×
347

348
                if not self.remotes:
×
349
                    # Only local target branches,
350
                    # `git push` will fail
351
                    return
×
352

353
            push_args.append(self.remotes)
15✔
354

355
        try:
15✔
356
            self.git.push(*push_args, **push_kwargs)
15✔
357
            self.pushed = True
15✔
358
        except GitError as error:
×
359
            error.message = "`git push` failed"
×
360
            raise error
×
361

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

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

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

383
                # Prepare log_hook
384
                log_hook = re.sub(r'\$(\d+)', r'%\1', log_hook)
385
                log_hook = re.sub(r'%(?!\d)', '%%', log_hook)
386
                log_hook = re.sub(r'; ?', r'\n', log_hook)
387

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

397
                # Run bat_file
398
                state = subprocess.call(
399
                    [bat_file.name, branch.name, remote.name]
400
                )
401

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

411
            if self.testing:
412
                assert state == 0, 'log_hook returned != 0'
413

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

417
        # Retrive and show local version info
418
        package = pkg.get_distribution('git-up')
12✔
419
        local_version_str = package.version
12✔
420
        local_version = package.parsed_version
12✔
421

422
        print('GitUp version is: ' + colored('v' + local_version_str, 'green'))
12✔
423

424
        if not self.settings['updates.check']:
12✔
425
            return
×
426

427
        # Check for updates
428
        print('Checking for updates...', end='')
12✔
429

430
        try:
12✔
431
            # Get version information from the PyPI JSON API
432
            reader = codecs.getreader('utf-8')
12✔
433
            details = json.load(reader(urlopen(PYPI_URL)))
12✔
434
            online_version = details['info']['version']
12✔
435
        except (HTTPError, URLError, ValueError):
×
436
            recent = True  # To not disturb the user with HTTP/parsing errors
×
437
        else:
438
            recent = local_version >= pkg.parse_version(online_version)
12✔
439

440
        if not recent:
12✔
441
            # noinspection PyUnboundLocalVariable
442
            print(
×
443
                '\rRecent version is: '
444
                + colored('v' + online_version, color='yellow', attrs=['bold'])
445
            )
446
            print('Run \'pip install -U git-up\' to get the update.')
×
447
        else:
448
            # Clear the update line
449
            sys.stdout.write('\r' + ' ' * 80 + '\n')
12✔
450

451
    ###########################################################################
452
    # Helpers
453
    ###########################################################################
454

455
    def load_config(self):
15✔
456
        """
457
        Load the configuration from git config.
458
        """
459
        for key in self.settings:
15✔
460
            value = self.config(key)
15✔
461
            # Parse true/false
462
            if value == '' or value is None:
15✔
463
                continue  # Not set by user, go on
15✔
464
            if value.lower() == 'true':
15✔
465
                value = True
15✔
466
            elif value.lower() == 'false':
15✔
467
                value = False
15✔
468
            elif value:
15✔
469
                pass  # A user-defined string, store the value later
9✔
470

471
            self.settings[key] = value
15✔
472

473
    def config(self, key):
15✔
474
        """ Get a git-up-specific config value. """
475
        return self.git.config(f'git-up.{key}')
15✔
476

477
    def is_prune(self):
15✔
478
        """
479
        Return True, if `git fetch --prune` is allowed.
480

481
        Because of possible incompatibilities, this requires special
482
        treatment.
483
        """
484
        required_version = "1.6.6"
15✔
485
        config_value = self.settings['fetch.prune']
15✔
486

487
        if self.git.is_version_min(required_version):
15✔
488
            return config_value is not False
15✔
489
        else:  # pragma: no cover
490
            if config_value == 'true':
491
                print(colored(
492
                    "Warning: fetch.prune is set to 'true' but your git"
493
                    "version doesn't seem to support it ({} < {})."
494
                    "Defaulting to 'false'.".format(self.git.version,
495
                                                    required_version),
496
                    'yellow'
497
                ))
498

499
    def print_error(self, error):
15✔
500
        """
501
        Print more information about an error.
502

503
        :type error: GitError
504
        """
505
        print(colored(error.message, 'red'), file=self.stderr)
15✔
506

507
        if error.stdout or error.stderr:
15✔
508
            print(file=self.stderr)
15✔
509
            print("Here's what git said:", file=self.stderr)
15✔
510
            print(file=self.stderr)
15✔
511

512
            if error.stdout:
15✔
513
                print(error.stdout, file=self.stderr)
12✔
514
            if error.stderr:
15✔
515
                print(error.stderr, file=self.stderr)
15✔
516

517
        if error.details:
15✔
518
            print(file=self.stderr)
×
519
            print("Here's what we know:", file=self.stderr)
×
520
            print(str(error.details), file=self.stderr)
×
521
            print(file=self.stderr)
×
522

523

524
###############################################################################
525

526

527
EPILOG = '''
15✔
528
For configuration options, please see
529
https://github.com/msiemens/PyGitUp#readme.
530

531
\b
532
Python port of https://github.com/aanand/git-up/
533
Project Author: Markus Siemens <markus@m-siemens.de>
534
Project URL: https://github.com/msiemens/PyGitUp
535
\b
536
'''
537

538

539
def run():  # pragma: no cover
540
    """
541
    A nicer `git pull`.
542
    """
543

544
    parser = argparse.ArgumentParser(description="A nicer `git pull`.", epilog=EPILOG)
545
    parser.add_argument('-V', '--version', action='store_true',
546
                        help='Show version (and if there is a newer version).')
547
    parser.add_argument('-q', '--quiet', action='store_true',
548
                        help='Be quiet, only print error messages.')
549
    parser.add_argument('--no-fetch', '--no-f', dest='fetch', action='store_false',
550
                        help='Don\'t try to fetch from origin.')
551
    parser.add_argument('-p', '--push', action='store_true',
552
                        help='Push the changes after pulling successfully.')
553

554
    args = parser.parse_args()
555

556
    if args.version:
557
        if NO_DISTRIBUTE:
558
            print(colored('Please install \'git-up\' via pip in order to '
559
                          'get version information.', 'yellow'))
560
        else:
561
            GitUp(sparse=True).version_info()
562
        return
563

564
    if args.quiet:
565
        sys.stdout = StringIO()
566

567
    try:
568
        gitup = GitUp()
569
        gitup.settings['push.auto'] = args.push
570
        gitup.should_fetch = args.fetch
571
    except GitError:
572
        sys.exit(1)  # Error in constructor
573
    else:
574
        gitup.run()
575

576

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

© 2025 Coveralls, Inc