dag: add word wrapping to the diff widget
[git-cola.git] / cola / gitcmds.py
blob3daecc170ce001d987afaa9ac56110327a4375e8
1 """Git commands and queries for Git"""
2 from __future__ import absolute_import, division, print_function, unicode_literals
3 import json
4 import os
5 import re
6 from io import StringIO
8 from . import core
9 from . import utils
10 from . import version
11 from .git import STDOUT
12 from .git import EMPTY_TREE_OID
13 from .git import OID_LENGTH
14 from .i18n import N_
15 from .interaction import Interaction
18 def add(context, items, u=False):
19 """Run "git add" while preventing argument overflow"""
20 git_add = context.git.add
21 return utils.slice_fn(
22 items, lambda paths: git_add(
23 '--', force=True, verbose=True, u=u, *paths
28 def apply_diff(context, filename):
29 """Use "git apply" to apply the patch in `filename` to the staging area"""
30 git = context.git
31 return git.apply(filename, index=True, cached=True)
34 def apply_diff_to_worktree(context, filename):
35 """Use "git apply" to apply the patch in `filename` to the worktree"""
36 git = context.git
37 return git.apply(filename)
40 def get_branch(context, branch):
41 """Get the current branch"""
42 if branch is None:
43 branch = current_branch(context)
44 return branch
47 def upstream_remote(context, branch=None):
48 """Return the remote associated with the specified branch"""
49 config = context.cfg
50 branch = get_branch(context, branch)
51 return config.get('branch.%s.remote' % branch)
54 def remote_url(context, remote, push=False):
55 """Return the URL for the specified remote"""
56 config = context.cfg
57 url = config.get('remote.%s.url' % remote, '')
58 if push:
59 url = config.get('remote.%s.pushurl' % remote, url)
60 return url
63 def diff_index_filenames(context, ref):
64 """
65 Return a diff of filenames that have been modified relative to the index
66 """
67 git = context.git
68 out = git.diff_index(ref, name_only=True, z=True, _readonly=True)[STDOUT]
69 return _parse_diff_filenames(out)
72 def diff_filenames(context, *args):
73 """Return a list of filenames that have been modified"""
74 git = context.git
75 out = git.diff_tree(
76 name_only=True, no_commit_id=True, r=True, z=True, _readonly=True, *args
77 )[STDOUT]
78 return _parse_diff_filenames(out)
81 def listdir(context, dirname, ref='HEAD'):
82 """Get the contents of a directory according to Git
84 Query Git for the content of a directory, taking ignored
85 files into account.
87 """
88 dirs = []
89 files = []
91 # first, parse git ls-tree to get the tracked files
92 # in a list of (type, path) tuples
93 entries = ls_tree(context, dirname, ref=ref)
94 for entry in entries:
95 if entry[0][0] == 't': # tree
96 dirs.append(entry[1])
97 else:
98 files.append(entry[1])
100 # gather untracked files
101 untracked = untracked_files(context, paths=[dirname], directory=True)
102 for path in untracked:
103 if path.endswith('/'):
104 dirs.append(path[:-1])
105 else:
106 files.append(path)
108 dirs.sort()
109 files.sort()
111 return (dirs, files)
114 def diff(context, args):
115 """Return a list of filenames for the given diff arguments
117 :param args: list of arguments to pass to "git diff --name-only"
120 git = context.git
121 out = git.diff(name_only=True, z=True, _readonly=True, *args)[STDOUT]
122 return _parse_diff_filenames(out)
125 def _parse_diff_filenames(out):
126 if out:
127 return out[:-1].split('\0')
128 return []
131 def tracked_files(context, *args):
132 """Return the names of all files in the repository"""
133 git = context.git
134 out = git.ls_files('--', *args, z=True, _readonly=True)[STDOUT]
135 if out:
136 return sorted(out[:-1].split('\0'))
137 return []
140 def all_files(context, *args):
141 """Returns a sorted list of all files, including untracked files."""
142 git = context.git
143 ls_files = git.ls_files(
144 '--',
145 *args,
146 z=True,
147 cached=True,
148 others=True,
149 exclude_standard=True,
150 _readonly=True
151 )[STDOUT]
152 return sorted([f for f in ls_files.split('\0') if f])
155 class CurrentBranchCache(object):
156 """Cache for current_branch()"""
158 key = None
159 value = None
162 def reset():
163 """Reset cached value in this module (eg. the cached current branch)"""
164 CurrentBranchCache.key = None
167 def current_branch(context):
168 """Return the current branch"""
169 git = context.git
170 head = git.git_path('HEAD')
171 try:
172 key = core.stat(head).st_mtime
173 if CurrentBranchCache.key == key:
174 return CurrentBranchCache.value
175 except OSError:
176 # OSError means we can't use the stat cache
177 key = 0
179 status, data, _ = git.rev_parse('HEAD', symbolic_full_name=True, _readonly=True)
180 if status != 0:
181 # git init -- read .git/HEAD. We could do this unconditionally...
182 data = _read_git_head(context, head)
184 for refs_prefix in ('refs/heads/', 'refs/remotes/', 'refs/tags/'):
185 if data.startswith(refs_prefix):
186 value = data[len(refs_prefix):]
187 CurrentBranchCache.key = key
188 CurrentBranchCache.value = value
189 return value
190 # Detached head
191 return data
194 def _read_git_head(context, head, default='main'):
195 """Pure-python .git/HEAD reader"""
196 # Common .git/HEAD "ref: refs/heads/main" files
197 git = context.git
198 islink = core.islink(head)
199 if core.isfile(head) and not islink:
200 data = core.read(head).rstrip()
201 ref_prefix = 'ref: '
202 if data.startswith(ref_prefix):
203 return data[len(ref_prefix):]
204 # Detached head
205 return data
206 # Legacy .git/HEAD symlinks
207 if islink:
208 refs_heads = core.realpath(git.git_path('refs', 'heads'))
209 path = core.abspath(head).replace('\\', '/')
210 if path.startswith(refs_heads + '/'):
211 return path[len(refs_heads) + 1:]
213 return default
216 def branch_list(context, remote=False):
218 Return a list of local or remote branches
220 This explicitly removes HEAD from the list of remote branches.
223 if remote:
224 return for_each_ref_basename(context, 'refs/remotes')
225 return for_each_ref_basename(context, 'refs/heads')
228 def _version_sort(context, key='version:refname'):
229 if version.check_git(context, 'version-sort'):
230 sort = key
231 else:
232 sort = False
233 return sort
236 def for_each_ref_basename(context, refs):
237 """Return refs starting with 'refs'."""
238 git = context.git
239 sort = _version_sort(context)
240 _, out, _ = git.for_each_ref(refs, format='%(refname)', sort=sort, _readonly=True)
241 output = out.splitlines()
242 non_heads = [x for x in output if not x.endswith('/HEAD')]
243 offset = len(refs) + 1
244 return [x[offset:] for x in non_heads]
247 def _prefix_and_size(prefix, values):
248 """Return a tuple of (prefix, len(prefix) + 1, y) for <prefix>/ stripping"""
249 return (prefix, len(prefix) + 1, values)
252 def all_refs(context, split=False, sort_key='version:refname'):
253 """Return a tuple of (local branches, remote branches, tags)."""
254 git = context.git
255 local_branches = []
256 remote_branches = []
257 tags = []
258 query = (
259 _prefix_and_size('refs/tags', tags),
260 _prefix_and_size('refs/heads', local_branches),
261 _prefix_and_size('refs/remotes', remote_branches),
263 sort = _version_sort(context, key=sort_key)
264 _, out, _ = git.for_each_ref(format='%(refname)', sort=sort, _readonly=True)
265 for ref in out.splitlines():
266 for prefix, prefix_len, dst in query:
267 if ref.startswith(prefix) and not ref.endswith('/HEAD'):
268 dst.append(ref[prefix_len:])
269 continue
270 tags.reverse()
271 if split:
272 return local_branches, remote_branches, tags
273 return local_branches + remote_branches + tags
276 def tracked_branch(context, branch=None):
277 """Return the remote branch associated with 'branch'."""
278 if branch is None:
279 branch = current_branch(context)
280 if branch is None:
281 return None
282 config = context.cfg
283 remote = config.get('branch.%s.remote' % branch)
284 if not remote:
285 return None
286 merge_ref = config.get('branch.%s.merge' % branch)
287 if not merge_ref:
288 return None
289 refs_heads = 'refs/heads/'
290 if merge_ref.startswith(refs_heads):
291 return remote + '/' + merge_ref[len(refs_heads) :]
292 return None
295 def parse_remote_branch(branch):
296 """Split a remote branch apart into (remote, name) components"""
297 rgx = re.compile(r'^(?P<remote>[^/]+)/(?P<branch>.+)$')
298 match = rgx.match(branch)
299 remote = ''
300 branch = ''
301 if match:
302 remote = match.group('remote')
303 branch = match.group('branch')
304 return (remote, branch)
307 def untracked_files(context, paths=None, **kwargs):
308 """Returns a sorted list of untracked files."""
309 git = context.git
310 if paths is None:
311 paths = []
312 args = ['--'] + paths
313 out = git.ls_files(
314 z=True, others=True, exclude_standard=True, _readonly=True, *args, **kwargs
315 )[STDOUT]
316 if out:
317 return out[:-1].split('\0')
318 return []
321 def tag_list(context):
322 """Return a list of tags."""
323 result = for_each_ref_basename(context, 'refs/tags')
324 result.reverse()
325 return result
328 def log(git, *args, **kwargs):
329 return git.log(
330 no_color=True,
331 no_abbrev_commit=True,
332 no_ext_diff=True,
333 _readonly=True,
334 *args,
335 **kwargs
336 )[STDOUT]
339 def commit_diff(context, oid):
340 git = context.git
341 return log(git, '-1', oid, '--') + '\n\n' + oid_diff(context, oid)
344 _diff_overrides = {}
347 def update_diff_overrides(space_at_eol, space_change, all_space, function_context):
348 _diff_overrides['ignore_space_at_eol'] = space_at_eol
349 _diff_overrides['ignore_space_change'] = space_change
350 _diff_overrides['ignore_all_space'] = all_space
351 _diff_overrides['function_context'] = function_context
354 def common_diff_opts(context):
355 config = context.cfg
356 # Default to --patience when diff.algorithm is unset
357 patience = not config.get('diff.algorithm', default='')
358 submodule = version.check_git(context, 'diff-submodule')
359 opts = {
360 'patience': patience,
361 'submodule': submodule,
362 'no_color': True,
363 'no_ext_diff': True,
364 'unified': config.get('gui.diffcontext', default=3),
365 '_raw': True,
367 opts.update(_diff_overrides)
368 return opts
371 def _add_filename(args, filename):
372 if filename:
373 args.extend(['--', filename])
376 def oid_diff(context, oid, filename=None):
377 """Return the diff for an oid"""
378 # Naively "$oid^!" is what we'd like to use but that doesn't
379 # give the correct result for merges--the diff is reversed.
380 # Be explicit and compare oid against its first parent.
381 git = context.git
382 args = [oid + '~', oid]
383 opts = common_diff_opts(context)
384 _add_filename(args, filename)
385 status, out, _ = git.diff(*args, **opts)
386 if status != 0:
387 # We probably don't have "$oid~" because this is the root commit.
388 # "git show" is clever enough to handle the root commit.
389 args = [oid + '^!']
390 _add_filename(args, filename)
391 _, out, _ = git.show(pretty='format:', _readonly=True, *args, **opts)
392 out = out.lstrip()
393 return out
396 def diff_info(context, oid, filename=None):
397 git = context.git
398 decoded = log(git, '-1', oid, '--', pretty='format:%b').strip()
399 if decoded:
400 decoded += '\n\n'
401 return decoded + oid_diff(context, oid, filename=filename)
404 # pylint: disable=too-many-arguments
405 def diff_helper(
406 context,
407 commit=None,
408 ref=None,
409 endref=None,
410 filename=None,
411 cached=True,
412 deleted=False,
413 head=None,
414 amending=False,
415 with_diff_header=False,
416 suppress_header=True,
417 reverse=False,
418 untracked=False,
420 "Invokes git diff on a filepath."
421 git = context.git
422 cfg = context.cfg
423 if commit:
424 ref, endref = commit + '^', commit
425 argv = []
426 if ref and endref:
427 argv.append('%s..%s' % (ref, endref))
428 elif ref:
429 argv.extend(utils.shell_split(ref.strip()))
430 elif head and amending and cached:
431 argv.append(head)
433 encoding = None
434 if untracked:
435 argv.append('--no-index')
436 argv.append(os.devnull)
437 argv.append(filename)
438 elif filename:
439 argv.append('--')
440 if isinstance(filename, (list, tuple)):
441 argv.extend(filename)
442 else:
443 argv.append(filename)
444 encoding = cfg.file_encoding(filename)
446 status, out, _ = git.diff(
447 R=reverse,
448 M=True,
449 cached=cached,
450 _encoding=encoding,
451 *argv,
452 **common_diff_opts(context)
455 success = status == 0
457 # Diff will return 1 when comparing untracked file and it has change,
458 # therefore we will check for diff header from output to differentiate
459 # from actual error such as file not found.
460 if untracked and status == 1:
461 try:
462 _, second, _ = out.split('\n', 2)
463 except ValueError:
464 second = ''
465 success = second.startswith('new file mode ')
467 if not success:
468 # git init
469 if with_diff_header:
470 return ('', '')
471 return ''
473 result = extract_diff_header(deleted, with_diff_header, suppress_header, out)
474 return core.UStr(result, out.encoding)
477 def extract_diff_header(deleted, with_diff_header, suppress_header, diffoutput):
478 """Split a diff into a header section and payload section"""
480 if diffoutput.startswith('Submodule'):
481 if with_diff_header:
482 return ('', diffoutput)
483 return diffoutput
485 start = False
486 del_tag = 'deleted file mode '
488 output = StringIO()
489 headers = StringIO()
491 for line in diffoutput.split('\n'):
492 if not start and line[:2] == '@@' and '@@' in line[2:]:
493 start = True
494 if start or (deleted and del_tag in line):
495 output.write(line + '\n')
496 else:
497 if with_diff_header:
498 headers.write(line + '\n')
499 elif not suppress_header:
500 output.write(line + '\n')
502 output_text = output.getvalue()
503 output.close()
505 headers_text = headers.getvalue()
506 headers.close()
508 if with_diff_header:
509 return (headers_text, output_text)
510 return output_text
513 def format_patchsets(context, to_export, revs, output='patches'):
515 Group contiguous revision selection into patchsets
517 Exists to handle multi-selection.
518 Multiple disparate ranges in the revision selection
519 are grouped into continuous lists.
523 outs = []
524 errs = []
526 cur_rev = to_export[0]
527 cur_rev_idx = revs.index(cur_rev)
529 patches_to_export = [[cur_rev]]
530 patchset_idx = 0
532 # Group the patches into continuous sets
533 for rev in to_export[1:]:
534 # Limit the search to the current neighborhood for efficiency
535 try:
536 rev_idx = revs[cur_rev_idx:].index(rev)
537 rev_idx += cur_rev_idx
538 except ValueError:
539 rev_idx = revs.index(rev)
541 if rev_idx == cur_rev_idx + 1:
542 patches_to_export[patchset_idx].append(rev)
543 cur_rev_idx += 1
544 else:
545 patches_to_export.append([rev])
546 cur_rev_idx = rev_idx
547 patchset_idx += 1
549 # Export each patchsets
550 status = 0
551 for patchset in patches_to_export:
552 stat, out, err = export_patchset(
553 context,
554 patchset[0],
555 patchset[-1],
556 output=output,
557 n=len(patchset) > 1,
558 thread=True,
559 patch_with_stat=True,
561 outs.append(out)
562 if err:
563 errs.append(err)
564 status = max(stat, status)
565 return (status, '\n'.join(outs), '\n'.join(errs))
568 def export_patchset(context, start, end, output='patches', **kwargs):
569 """Export patches from start^ to end."""
570 git = context.git
571 return git.format_patch('-o', output, start + '^..' + end, **kwargs)
574 def reset_paths(context, head, items):
575 """Run "git reset" while preventing argument overflow"""
576 items = list(set(items))
577 fn = context.git.reset
578 status, out, err = utils.slice_fn(items, lambda paths: fn(head, '--', *paths))
579 return (status, out, err)
582 def unstage_paths(context, args, head='HEAD'):
583 """Unstage paths while accounting for git init"""
584 status, out, err = reset_paths(context, head, args)
585 if status == 128:
586 # handle git init: we have to use 'git rm --cached'
587 # detect this condition by checking if the file is still staged
588 return untrack_paths(context, args)
589 return (status, out, err)
592 def untrack_paths(context, args):
593 if not args:
594 return (-1, N_('Nothing to do'), '')
595 git = context.git
596 return git.update_index('--', force_remove=True, *set(args))
599 def worktree_state(
600 context, head='HEAD', update_index=False, display_untracked=True, paths=None
602 """Return a dict of files in various states of being
604 :rtype: dict, keys are staged, unstaged, untracked, unmerged,
605 changed_upstream, and submodule.
608 git = context.git
609 if update_index:
610 git.update_index(refresh=True)
612 staged, unmerged, staged_deleted, staged_submods = diff_index(
613 context, head, paths=paths
615 modified, unstaged_deleted, modified_submods = diff_worktree(context, paths)
616 if display_untracked:
617 untracked = untracked_files(context, paths=paths)
618 else:
619 untracked = []
621 # Remove unmerged paths from the modified list
622 if unmerged:
623 unmerged_set = set(unmerged)
624 modified = [path for path in modified if path not in unmerged_set]
626 # Look for upstream modified files if this is a tracking branch
627 upstream_changed = diff_upstream(context, head)
629 # Keep stuff sorted
630 staged.sort()
631 modified.sort()
632 unmerged.sort()
633 untracked.sort()
634 upstream_changed.sort()
636 return {
637 'staged': staged,
638 'modified': modified,
639 'unmerged': unmerged,
640 'untracked': untracked,
641 'upstream_changed': upstream_changed,
642 'staged_deleted': staged_deleted,
643 'unstaged_deleted': unstaged_deleted,
644 'submodules': staged_submods | modified_submods,
648 def _parse_raw_diff(out):
649 while out:
650 info, path, out = out.split('\0', 2)
651 status = info[-1]
652 is_submodule = '160000' in info[1:14]
653 yield (path, status, is_submodule)
656 def diff_index(context, head, cached=True, paths=None):
657 git = context.git
658 staged = []
659 unmerged = []
660 deleted = set()
661 submodules = set()
663 if paths is None:
664 paths = []
665 args = [head, '--'] + paths
666 status, out, _ = git.diff_index(cached=cached, z=True, _readonly=True, *args)
667 if status != 0:
668 # handle git init
669 args[0] = EMPTY_TREE_OID
670 status, out, _ = git.diff_index(cached=cached, z=True, _readonly=True, *args)
672 for path, status, is_submodule in _parse_raw_diff(out):
673 if is_submodule:
674 submodules.add(path)
675 if status in 'DAMT':
676 staged.append(path)
677 if status == 'D':
678 deleted.add(path)
679 elif status == 'U':
680 unmerged.append(path)
682 return staged, unmerged, deleted, submodules
685 def diff_worktree(context, paths=None):
686 git = context.git
687 ignore_submodules_value = context.cfg.get('diff.ignoresubmodules', 'none')
688 ignore_submodules = ignore_submodules_value in {'all', 'dirty', 'untracked'}
689 modified = []
690 deleted = set()
691 submodules = set()
693 if paths is None:
694 paths = []
695 args = ['--'] + paths
696 status, out, _ = git.diff_files(z=True, _readonly=True, *args)
697 for path, status, is_submodule in _parse_raw_diff(out):
698 if is_submodule:
699 submodules.add(path)
700 if ignore_submodules:
701 continue
702 if status in 'DAMT':
703 modified.append(path)
704 if status == 'D':
705 deleted.add(path)
707 return modified, deleted, submodules
710 def diff_upstream(context, head):
711 """Given `ref`, return $(git merge-base ref HEAD)..ref."""
712 tracked = tracked_branch(context)
713 if not tracked:
714 return []
715 base = merge_base(context, head, tracked)
716 return diff_filenames(context, base, tracked)
719 def list_submodule(context):
720 """Return submodules in the format(state, sha1, path, describe)"""
721 git = context.git
722 status, data, _ = git.submodule('status')
723 ret = []
724 if status == 0 and data:
725 data = data.splitlines()
726 # see git submodule status
727 # TODO better separation
728 for line in data:
729 state = line[0].strip()
730 sha1 = line[1 : OID_LENGTH + 1]
731 left_bracket = line.find('(', OID_LENGTH + 3)
732 if left_bracket == -1:
733 left_bracket = len(line) + 1
734 path = line[OID_LENGTH + 2 : left_bracket - 1]
735 describe = line[left_bracket + 1 : -1]
736 ret.append((state, sha1, path, describe))
737 return ret
740 def merge_base(context, head, ref):
741 """Return the merge-base of head and ref"""
742 git = context.git
743 return git.merge_base(head, ref, _readonly=True)[STDOUT]
746 def merge_base_parent(context, branch):
747 tracked = tracked_branch(context, branch=branch)
748 if tracked:
749 return tracked
750 return 'HEAD'
753 # TODO Unused?
754 def parse_ls_tree(context, rev):
755 """Return a list of (mode, type, oid, path) tuples."""
756 output = []
757 git = context.git
758 lines = git.ls_tree(rev, r=True, _readonly=True)[STDOUT].splitlines()
759 regex = re.compile(r'^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
760 for line in lines:
761 match = regex.match(line)
762 if match:
763 mode = match.group(1)
764 objtype = match.group(2)
765 oid = match.group(3)
766 filename = match.group(4)
767 output.append(
769 mode,
770 objtype,
771 oid,
772 filename,
775 return output
778 # TODO unused?
779 def ls_tree(context, path, ref='HEAD'):
780 """Return a parsed git ls-tree result for a single directory"""
781 git = context.git
782 result = []
783 status, out, _ = git.ls_tree(
784 ref, '--', path, z=True, full_tree=True, _readonly=True
786 if status == 0 and out:
787 path_offset = 6 + 1 + 4 + 1 + OID_LENGTH + 1
788 for line in out[:-1].split('\0'):
789 # 1 1 1
790 # .....6 ...4 ......................................40
791 # 040000 tree c127cde9a0c644a3a8fef449a244f47d5272dfa6 relative
792 # 100644 blob 139e42bf4acaa4927ec9be1ec55a252b97d3f1e2 relative/path
793 # 0..... 7... 12...................................... 53
794 # path_offset = 6 + 1 + 4 + 1 + OID_LENGTH(40) + 1
795 objtype = line[7:11]
796 relpath = line[path_offset:]
797 result.append((objtype, relpath))
799 return result
802 # A regex for matching the output of git(log|rev-list) --pretty=oneline
803 REV_LIST_REGEX = re.compile(r'^([0-9a-f]{40}) (.*)$')
806 def parse_rev_list(raw_revs):
807 """Parse `git log --pretty=online` output into (oid, summary) pairs."""
808 revs = []
809 for line in raw_revs.splitlines():
810 match = REV_LIST_REGEX.match(line)
811 if match:
812 rev_id = match.group(1)
813 summary = match.group(2)
814 revs.append(
816 rev_id,
817 summary,
820 return revs
823 # pylint: disable=redefined-builtin
824 def log_helper(context, all=False, extra_args=None):
825 """Return parallel arrays containing oids and summaries."""
826 revs = []
827 summaries = []
828 args = []
829 if extra_args:
830 args = extra_args
831 git = context.git
832 output = log(git, pretty='oneline', all=all, *args)
833 for line in output.splitlines():
834 match = REV_LIST_REGEX.match(line)
835 if match:
836 revs.append(match.group(1))
837 summaries.append(match.group(2))
838 return (revs, summaries)
841 def rev_list_range(context, start, end):
842 """Return (oid, summary) pairs between start and end."""
843 git = context.git
844 revrange = '%s..%s' % (start, end)
845 out = git.rev_list(revrange, pretty='oneline', _readonly=True)[STDOUT]
846 return parse_rev_list(out)
849 def commit_message_path(context):
850 """Return the path to .git/GIT_COLA_MSG"""
851 git = context.git
852 path = git.git_path('GIT_COLA_MSG')
853 if core.exists(path):
854 return path
855 return None
858 def merge_message_path(context):
859 """Return the path to .git/MERGE_MSG or .git/SQUASH_MSG."""
860 git = context.git
861 for basename in ('MERGE_MSG', 'SQUASH_MSG'):
862 path = git.git_path(basename)
863 if core.exists(path):
864 return path
865 return None
868 def prepare_commit_message_hook(context):
869 """Run the cola.preparecommitmessagehook to prepare the commit message"""
870 config = context.cfg
871 default_hook = config.hooks_path('cola-prepare-commit-msg')
872 return config.get('cola.preparecommitmessagehook', default=default_hook)
875 def cherry_pick(context, revs):
876 """Cherry-picks each revision into the current branch.
878 Returns (0, out, err) where stdout and stderr across all "git cherry-pick"
879 invocations are combined into single values when all cherry-picks succeed.
881 Returns a combined (status, out, err) of the first failing "git cherry-pick"
882 in the event of a non-zero exit status.
884 if not revs:
885 return []
886 outs = []
887 errs = []
888 status = 0
889 for rev in revs:
890 status, out, err = context.git.cherry_pick(rev)
891 if status != 0:
892 details = N_(
893 'Hint: The "Actions > Abort Cherry-Pick" menu action can be used to '
894 'cancel the current cherry-pick.'
896 output = '# git cherry-pick %s\n# %s\n\n%s' % (rev, details, out)
897 return (status, output, err)
898 outs.append(out)
899 errs.append(err)
900 return (0, '\n'.join(outs), '\n'.join(errs))
903 def abort_cherry_pick(context):
904 """Abort a cherry-pick."""
905 # Reset the worktree
906 git = context.git
907 status, out, err = git.cherry_pick(abort=True)
908 return status, out, err
911 def abort_merge(context):
912 """Abort a merge"""
913 # Reset the worktree
914 git = context.git
915 status, out, err = git.merge(abort=True)
916 return status, out, err
919 def strip_remote(remotes, remote_branch):
920 """Get branch names with the "<remote>/" prefix removed"""
921 for remote in remotes:
922 prefix = remote + '/'
923 if remote_branch.startswith(prefix):
924 return remote_branch[len(prefix):]
925 return remote_branch.split('/', 1)[-1]
928 def parse_refs(context, argv):
929 """Parse command-line arguments into object IDs"""
930 git = context.git
931 status, out, _ = git.rev_parse(_readonly=True, *argv)
932 if status == 0:
933 oids = [oid for oid in out.splitlines() if oid]
934 else:
935 oids = argv
936 return oids
939 def prev_commitmsg(context, *args):
940 """Queries git for the latest commit message."""
941 git = context.git
942 return git.log(
943 '-1', no_color=True, pretty='format:%s%n%n%b', _readonly=True, *args
944 )[STDOUT]
947 def rev_parse(context, name):
948 """Call git rev-parse and return the output"""
949 git = context.git
950 status, out, _ = git.rev_parse(name, _readonly=True)
951 if status == 0:
952 result = out.strip()
953 else:
954 result = name
955 return result
958 def write_blob(context, oid, filename):
959 """Write a blob to a temporary file and return the path
961 Modern versions of Git allow invoking filters. Older versions
962 get the object content as-is.
965 if version.check_git(context, 'cat-file-filters-path'):
966 return cat_file_to_path(context, filename, oid)
967 return cat_file_blob(context, filename, oid)
970 def cat_file_blob(context, filename, oid):
971 """Write a blob from git to the specified filename"""
972 return cat_file(context, filename, 'blob', oid)
975 def cat_file_to_path(context, filename, oid):
976 """Extract a file from an commit ref and a write it to the specified filename"""
977 return cat_file(context, filename, oid, path=filename, filters=True)
980 def cat_file(context, filename, *args, **kwargs):
981 """Redirect git cat-file output to a path"""
982 result = None
983 git = context.git
984 # Use the original filename in the suffix so that the generated filename
985 # has the correct extension, and so that it resembles the original name.
986 basename = os.path.basename(filename)
987 suffix = '-' + basename # ensures the correct filename extension
988 path = utils.tmp_filename('blob', suffix=suffix)
989 with open(path, 'wb') as tmp_file:
990 status, out, err = git.cat_file(
991 _raw=True, _readonly=True, _stdout=tmp_file, *args, **kwargs
993 Interaction.command(N_('Error'), 'git cat-file', status, out, err)
994 if status == 0:
995 result = path
996 if not result:
997 core.unlink(path)
998 return result
1001 def write_blob_path(context, head, oid, filename):
1002 """Use write_blob() when modern git is available"""
1003 if version.check_git(context, 'cat-file-filters-path'):
1004 return write_blob(context, oid, filename)
1005 return cat_file_blob(context, filename, head + ':' + filename)
1008 def annex_path(context, head, filename):
1009 """Return the git-annex path for a filename at the specified commit"""
1010 git = context.git
1011 path = None
1012 annex_info = {}
1014 # unfortunately there's no way to filter this down to a single path
1015 # so we just have to scan all reported paths
1016 status, out, _ = git.annex('findref', '--json', head, _readonly=True)
1017 if status == 0:
1018 for line in out.splitlines():
1019 info = json.loads(line)
1020 try:
1021 annex_file = info['file']
1022 except (ValueError, KeyError):
1023 continue
1024 # we only care about this file so we can skip the rest
1025 if annex_file == filename:
1026 annex_info = info
1027 break
1028 key = annex_info.get('key', '')
1029 if key:
1030 status, out, _ = git.annex('contentlocation', key, _readonly=True)
1031 if status == 0 and os.path.exists(out):
1032 path = out
1034 return path
1037 def is_binary(context, filename):
1038 """A heustic to determine whether `filename` contains (non-text) binary content"""
1039 cfg_is_binary = context.cfg.is_binary(filename)
1040 if cfg_is_binary is not None:
1041 return cfg_is_binary
1042 # This is the same heuristic as xdiff-interface.c:buffer_is_binary().
1043 size = 8000
1044 try:
1045 result = core.read(filename, size=size, encoding='bytes')
1046 except (IOError, OSError):
1047 result = b''
1049 return b'\0' in result
1052 def is_valid_ref(context, ref):
1053 """Is the provided Git ref a valid refname?"""
1054 status, _, _ = context.git.rev_parse(ref, quiet=True, verify=True, _readonly=True)
1055 return status == 0