sequenceeditor: code style
[git-cola.git] / cola / gitcmds.py
blob4797b6b675d9e1b90fa962f05911093f659c339e
1 """Git commands and queries for Git"""
2 import json
3 import os
4 import re
5 from io import StringIO
7 from . import core
8 from . import utils
9 from . import version
10 from .git import STDOUT
11 from .git import EMPTY_TREE_OID
12 from .git import OID_LENGTH
13 from .i18n import N_
14 from .interaction import Interaction
15 from .models import prefs
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_func(
22 items, lambda paths: git_add('--', force=True, verbose=True, u=u, *paths)
26 def apply_diff(context, filename):
27 """Use "git apply" to apply the patch in `filename` to the staging area"""
28 git = context.git
29 return git.apply(filename, index=True, cached=True)
32 def apply_diff_to_worktree(context, filename):
33 """Use "git apply" to apply the patch in `filename` to the worktree"""
34 git = context.git
35 return git.apply(filename)
38 def get_branch(context, branch):
39 """Get the current branch"""
40 if branch is None:
41 branch = current_branch(context)
42 return branch
45 def upstream_remote(context, branch=None):
46 """Return the remote associated with the specified branch"""
47 config = context.cfg
48 branch = get_branch(context, branch)
49 return config.get('branch.%s.remote' % branch)
52 def remote_url(context, remote, push=False):
53 """Return the URL for the specified remote"""
54 config = context.cfg
55 url = config.get('remote.%s.url' % remote, '')
56 if push:
57 url = config.get('remote.%s.pushurl' % remote, url)
58 return url
61 def diff_index_filenames(context, ref):
62 """
63 Return a diff of filenames that have been modified relative to the index
64 """
65 git = context.git
66 out = git.diff_index(ref, name_only=True, z=True, _readonly=True)[STDOUT]
67 return _parse_diff_filenames(out)
70 def diff_filenames(context, *args):
71 """Return a list of filenames that have been modified"""
72 out = diff_tree(context, *args)[STDOUT]
73 return _parse_diff_filenames(out)
76 def changed_files(context, oid):
77 """Return the list of filenames that changed in a given commit oid"""
78 status, out, _ = diff_tree(context, oid + '~', oid)
79 if status != 0:
80 # git init
81 status, out, _ = diff_tree(context, EMPTY_TREE_OID, oid)
82 if status == 0:
83 result = _parse_diff_filenames(out)
84 else:
85 result = []
86 return result
89 def diff_tree(context, *args):
90 """Return a list of filenames that have been modified"""
91 git = context.git
92 return git_diff_tree(git, *args)
95 def git_diff_tree(git, *args):
96 return git.diff_tree(
97 name_only=True, no_commit_id=True, r=True, z=True, _readonly=True, *args
101 def listdir(context, dirname, ref='HEAD'):
102 """Get the contents of a directory according to Git
104 Query Git for the content of a directory, taking ignored
105 files into account.
108 dirs = []
109 files = []
111 # first, parse git ls-tree to get the tracked files
112 # in a list of (type, path) tuples
113 entries = ls_tree(context, dirname, ref=ref)
114 for entry in entries:
115 if entry[0][0] == 't': # tree
116 dirs.append(entry[1])
117 else:
118 files.append(entry[1])
120 # gather untracked files
121 untracked = untracked_files(context, paths=[dirname], directory=True)
122 for path in untracked:
123 if path.endswith('/'):
124 dirs.append(path[:-1])
125 else:
126 files.append(path)
128 dirs.sort()
129 files.sort()
131 return (dirs, files)
134 def diff(context, args):
135 """Return a list of filenames for the given diff arguments
137 :param args: list of arguments to pass to "git diff --name-only"
140 git = context.git
141 out = git.diff(name_only=True, z=True, _readonly=True, *args)[STDOUT]
142 return _parse_diff_filenames(out)
145 def _parse_diff_filenames(out):
146 if out:
147 return out[:-1].split('\0')
148 return []
151 def tracked_files(context, *args):
152 """Return the names of all files in the repository"""
153 git = context.git
154 out = git.ls_files('--', *args, z=True, _readonly=True)[STDOUT]
155 if out:
156 return sorted(out[:-1].split('\0'))
157 return []
160 def all_files(context, *args):
161 """Returns a sorted list of all files, including untracked files."""
162 git = context.git
163 ls_files = git.ls_files(
164 '--',
165 *args,
166 z=True,
167 cached=True,
168 others=True,
169 exclude_standard=True,
170 _readonly=True,
171 )[STDOUT]
172 return sorted([f for f in ls_files.split('\0') if f])
175 class CurrentBranchCache:
176 """Cache for current_branch()"""
178 key = None
179 value = None
182 def reset():
183 """Reset cached value in this module (e.g. the cached current branch)"""
184 CurrentBranchCache.key = None
187 def current_branch(context):
188 """Return the current branch"""
189 git = context.git
190 head = git.git_path('HEAD')
191 try:
192 key = core.stat(head).st_mtime
193 if CurrentBranchCache.key == key:
194 return CurrentBranchCache.value
195 except OSError:
196 # OSError means we can't use the stat cache
197 key = 0
199 status, data, _ = git.rev_parse('HEAD', symbolic_full_name=True, _readonly=True)
200 if status != 0:
201 # git init -- read .git/HEAD. We could do this unconditionally...
202 data = _read_git_head(context, head)
204 for refs_prefix in ('refs/heads/', 'refs/remotes/', 'refs/tags/'):
205 if data.startswith(refs_prefix):
206 value = data[len(refs_prefix) :]
207 CurrentBranchCache.key = key
208 CurrentBranchCache.value = value
209 return value
210 # Detached head
211 return data
214 def _read_git_head(context, head, default='main'):
215 """Pure-python .git/HEAD reader"""
216 # Common .git/HEAD "ref: refs/heads/main" files
217 git = context.git
218 islink = core.islink(head)
219 if core.isfile(head) and not islink:
220 data = core.read(head).rstrip()
221 ref_prefix = 'ref: '
222 if data.startswith(ref_prefix):
223 return data[len(ref_prefix) :]
224 # Detached head
225 return data
226 # Legacy .git/HEAD symlinks
227 if islink:
228 refs_heads = core.realpath(git.git_path('refs', 'heads'))
229 path = core.abspath(head).replace('\\', '/')
230 if path.startswith(refs_heads + '/'):
231 return path[len(refs_heads) + 1 :]
233 return default
236 def branch_list(context, remote=False):
238 Return a list of local or remote branches
240 This explicitly removes HEAD from the list of remote branches.
243 if remote:
244 return for_each_ref_basename(context, 'refs/remotes')
245 return for_each_ref_basename(context, 'refs/heads')
248 def _version_sort(context, key='version:refname'):
249 if version.check_git(context, 'version-sort'):
250 sort = key
251 else:
252 sort = False
253 return sort
256 def for_each_ref_basename(context, refs):
257 """Return refs starting with 'refs'."""
258 git = context.git
259 sort = _version_sort(context)
260 _, out, _ = git.for_each_ref(refs, format='%(refname)', sort=sort, _readonly=True)
261 output = out.splitlines()
262 non_heads = [x for x in output if not x.endswith('/HEAD')]
263 offset = len(refs) + 1
264 return [x[offset:] for x in non_heads]
267 def _prefix_and_size(prefix, values):
268 """Return a tuple of (prefix, len(prefix) + 1, y) for <prefix>/ stripping"""
269 return (prefix, len(prefix) + 1, values)
272 def all_refs(context, split=False, sort_key='version:refname'):
273 """Return a tuple of (local branches, remote branches, tags)."""
274 git = context.git
275 local_branches = []
276 remote_branches = []
277 tags = []
278 query = (
279 _prefix_and_size('refs/tags', tags),
280 _prefix_and_size('refs/heads', local_branches),
281 _prefix_and_size('refs/remotes', remote_branches),
283 sort = _version_sort(context, key=sort_key)
284 _, out, _ = git.for_each_ref(format='%(refname)', sort=sort, _readonly=True)
285 for ref in out.splitlines():
286 for prefix, prefix_len, dst in query:
287 if ref.startswith(prefix) and not ref.endswith('/HEAD'):
288 dst.append(ref[prefix_len:])
289 continue
290 tags.reverse()
291 if split:
292 return local_branches, remote_branches, tags
293 return local_branches + remote_branches + tags
296 def tracked_branch(context, branch=None):
297 """Return the remote branch associated with 'branch'."""
298 if branch is None:
299 branch = current_branch(context)
300 if branch is None:
301 return None
302 config = context.cfg
303 remote = config.get('branch.%s.remote' % branch)
304 if not remote:
305 return None
306 merge_ref = config.get('branch.%s.merge' % branch)
307 if not merge_ref:
308 return None
309 refs_heads = 'refs/heads/'
310 if merge_ref.startswith(refs_heads):
311 return remote + '/' + merge_ref[len(refs_heads) :]
312 return None
315 def parse_remote_branch(branch):
316 """Split a remote branch apart into (remote, name) components"""
317 rgx = re.compile(r'^(?P<remote>[^/]+)/(?P<branch>.+)$')
318 match = rgx.match(branch)
319 remote = ''
320 branch = ''
321 if match:
322 remote = match.group('remote')
323 branch = match.group('branch')
324 return (remote, branch)
327 def untracked_files(context, paths=None, **kwargs):
328 """Returns a sorted list of untracked files."""
329 git = context.git
330 if paths is None:
331 paths = []
332 args = ['--'] + paths
333 out = git.ls_files(
334 z=True, others=True, exclude_standard=True, _readonly=True, *args, **kwargs
335 )[STDOUT]
336 if out:
337 return out[:-1].split('\0')
338 return []
341 def tag_list(context):
342 """Return a list of tags."""
343 result = for_each_ref_basename(context, 'refs/tags')
344 result.reverse()
345 return result
348 def log(git, *args, **kwargs):
349 return git.log(
350 no_color=True,
351 no_abbrev_commit=True,
352 no_ext_diff=True,
353 _readonly=True,
354 *args,
355 **kwargs,
356 )[STDOUT]
359 def commit_diff(context, oid):
360 git = context.git
361 return log(git, '-1', oid, '--') + '\n\n' + oid_diff(context, oid)
364 _diff_overrides = {}
367 def update_diff_overrides(space_at_eol, space_change, all_space, function_context):
368 _diff_overrides['ignore_space_at_eol'] = space_at_eol
369 _diff_overrides['ignore_space_change'] = space_change
370 _diff_overrides['ignore_all_space'] = all_space
371 _diff_overrides['function_context'] = function_context
374 def common_diff_opts(context):
375 config = context.cfg
376 # Default to --patience when diff.algorithm is unset
377 patience = not config.get('diff.algorithm', default='')
378 submodule = version.check_git(context, 'diff-submodule')
379 opts = {
380 'patience': patience,
381 'submodule': submodule,
382 'no_color': True,
383 'no_ext_diff': True,
384 'unified': config.get('gui.diffcontext', default=3),
385 '_raw': True,
386 '_readonly': True,
388 opts.update(_diff_overrides)
389 return opts
392 def _add_filename(args, filename):
393 if filename:
394 args.extend(['--', filename])
397 def oid_diff(context, oid, filename=None):
398 """Return the diff for an oid"""
399 # Naively "$oid^!" is what we'd like to use but that doesn't
400 # give the correct result for merges--the diff is reversed.
401 # Be explicit and compare oid against its first parent.
402 return oid_diff_range(context, oid + '~', oid, filename=filename)
405 def oid_diff_range(context, start, end, filename=None):
406 """Return the diff for a commit range"""
407 args = [start, end]
408 git = context.git
409 opts = common_diff_opts(context)
410 _add_filename(args, filename)
411 status, out, _ = git.diff(*args, **opts)
412 if status != 0:
413 # We probably don't have "$oid~" because this is the root commit.
414 # "git show" is clever enough to handle the root commit.
415 args = [end + '^!']
416 _add_filename(args, filename)
417 _, out, _ = git.show(pretty='format:', *args, **opts)
418 out = out.lstrip()
419 return out
422 def diff_info(context, oid, filename=None):
423 """Return the diff for the specified oid"""
424 return diff_range(context, oid + '~', oid, filename=filename)
427 def diff_range(context, start, end, filename=None):
428 """Return the diff for the specified commit range"""
429 git = context.git
430 decoded = log(git, '-1', end, '--', pretty='format:%b').strip()
431 if decoded:
432 decoded += '\n\n'
433 return decoded + oid_diff_range(context, start, end, filename=filename)
436 # pylint: disable=too-many-arguments
437 def diff_helper(
438 context,
439 commit=None,
440 ref=None,
441 endref=None,
442 filename=None,
443 cached=True,
444 deleted=False,
445 head=None,
446 amending=False,
447 with_diff_header=False,
448 suppress_header=True,
449 reverse=False,
450 untracked=False,
452 """Invoke git diff on a path"""
453 git = context.git
454 cfg = context.cfg
455 if commit:
456 ref, endref = commit + '^', commit
457 argv = []
458 if ref and endref:
459 argv.append(f'{ref}..{endref}')
460 elif ref:
461 argv.extend(utils.shell_split(ref.strip()))
462 elif head and amending and cached:
463 argv.append(head)
465 encoding = None
466 if untracked:
467 argv.append('--no-index')
468 argv.append(os.devnull)
469 argv.append(filename)
470 elif filename:
471 argv.append('--')
472 if isinstance(filename, (list, tuple)):
473 argv.extend(filename)
474 else:
475 argv.append(filename)
476 encoding = cfg.file_encoding(filename)
478 status, out, _ = git.diff(
479 R=reverse,
480 M=True,
481 cached=cached,
482 _encoding=encoding,
483 *argv,
484 **common_diff_opts(context),
487 success = status == 0
489 # Diff will return 1 when comparing untracked file and it has change,
490 # therefore we will check for diff header from output to differentiate
491 # from actual error such as file not found.
492 if untracked and status == 1:
493 try:
494 _, second, _ = out.split('\n', 2)
495 except ValueError:
496 second = ''
497 success = second.startswith('new file mode ')
499 if not success:
500 # git init
501 if with_diff_header:
502 return ('', '')
503 return ''
505 result = extract_diff_header(deleted, with_diff_header, suppress_header, out)
506 return core.UStr(result, out.encoding)
509 def extract_diff_header(deleted, with_diff_header, suppress_header, diffoutput):
510 """Split a diff into a header section and payload section"""
512 if diffoutput.startswith('Submodule'):
513 if with_diff_header:
514 return ('', diffoutput)
515 return diffoutput
517 start = False
518 del_tag = 'deleted file mode '
520 output = StringIO()
521 headers = StringIO()
523 for line in diffoutput.split('\n'):
524 if not start and line[:2] == '@@' and '@@' in line[2:]:
525 start = True
526 if start or (deleted and del_tag in line):
527 output.write(line + '\n')
528 else:
529 if with_diff_header:
530 headers.write(line + '\n')
531 elif not suppress_header:
532 output.write(line + '\n')
534 output_text = output.getvalue()
535 output.close()
537 headers_text = headers.getvalue()
538 headers.close()
540 if with_diff_header:
541 return (headers_text, output_text)
542 return output_text
545 def format_patchsets(context, to_export, revs, output='patches'):
547 Group contiguous revision selection into patch sets
549 Exists to handle multi-selection.
550 Multiple disparate ranges in the revision selection
551 are grouped into continuous lists.
555 outs = []
556 errs = []
558 cur_rev = to_export[0]
559 cur_rev_idx = revs.index(cur_rev)
561 patches_to_export = [[cur_rev]]
562 patchset_idx = 0
564 # Group the patches into continuous sets
565 for rev in to_export[1:]:
566 # Limit the search to the current neighborhood for efficiency
567 try:
568 rev_idx = revs[cur_rev_idx:].index(rev)
569 rev_idx += cur_rev_idx
570 except ValueError:
571 rev_idx = revs.index(rev)
573 if rev_idx == cur_rev_idx + 1:
574 patches_to_export[patchset_idx].append(rev)
575 cur_rev_idx += 1
576 else:
577 patches_to_export.append([rev])
578 cur_rev_idx = rev_idx
579 patchset_idx += 1
581 # Export each patch set
582 status = 0
583 for patchset in patches_to_export:
584 stat, out, err = export_patchset(
585 context,
586 patchset[0],
587 patchset[-1],
588 output=output,
589 n=len(patchset) > 1,
590 thread=True,
591 patch_with_stat=True,
593 outs.append(out)
594 if err:
595 errs.append(err)
596 status = max(stat, status)
597 return (status, '\n'.join(outs), '\n'.join(errs))
600 def export_patchset(context, start, end, output='patches', **kwargs):
601 """Export patches from start^ to end."""
602 git = context.git
603 return git.format_patch('-o', output, start + '^..' + end, **kwargs)
606 def reset_paths(context, head, items):
607 """Run "git reset" while preventing argument overflow"""
608 items = list(set(items))
609 func = context.git.reset
610 status, out, err = utils.slice_func(items, lambda paths: func(head, '--', *paths))
611 return (status, out, err)
614 def unstage_paths(context, args, head='HEAD'):
615 """Unstage paths while accounting for git init"""
616 status, out, err = reset_paths(context, head, args)
617 if status == 128:
618 # handle git init: we have to use 'git rm --cached'
619 # detect this condition by checking if the file is still staged
620 return untrack_paths(context, args)
621 return (status, out, err)
624 def untrack_paths(context, args):
625 if not args:
626 return (-1, N_('Nothing to do'), '')
627 git = context.git
628 return git.update_index('--', force_remove=True, *set(args))
631 def worktree_state(
632 context, head='HEAD', update_index=False, display_untracked=True, paths=None
634 """Return a dict of files in various states of being
636 :rtype: dict, keys are staged, unstaged, untracked, unmerged,
637 changed_upstream, and submodule.
639 git = context.git
640 if update_index:
641 git.update_index(refresh=True)
643 staged, unmerged, staged_deleted, staged_submods = diff_index(
644 context, head, paths=paths
646 modified, unstaged_deleted, modified_submods = diff_worktree(context, paths)
647 if display_untracked:
648 untracked = untracked_files(context, paths=paths)
649 else:
650 untracked = []
652 # Remove unmerged paths from the modified list
653 if unmerged:
654 unmerged_set = set(unmerged)
655 modified = [path for path in modified if path not in unmerged_set]
657 # Look for upstream modified files if this is a tracking branch
658 upstream_changed = diff_upstream(context, head)
660 # Keep stuff sorted
661 staged.sort()
662 modified.sort()
663 unmerged.sort()
664 untracked.sort()
665 upstream_changed.sort()
667 return {
668 'staged': staged,
669 'modified': modified,
670 'unmerged': unmerged,
671 'untracked': untracked,
672 'upstream_changed': upstream_changed,
673 'staged_deleted': staged_deleted,
674 'unstaged_deleted': unstaged_deleted,
675 'submodules': staged_submods | modified_submods,
679 def _parse_raw_diff(out):
680 while out:
681 info, path, out = out.split('\0', 2)
682 status = info[-1]
683 is_submodule = '160000' in info[1:14]
684 yield (path, status, is_submodule)
687 def diff_index(context, head, cached=True, paths=None):
688 git = context.git
689 staged = []
690 unmerged = []
691 deleted = set()
692 submodules = set()
694 if paths is None:
695 paths = []
696 args = [head, '--'] + paths
697 status, out, _ = git.diff_index(cached=cached, z=True, _readonly=True, *args)
698 if status != 0:
699 # handle git init
700 args[0] = EMPTY_TREE_OID
701 status, out, _ = git.diff_index(cached=cached, z=True, _readonly=True, *args)
703 for path, status, is_submodule in _parse_raw_diff(out):
704 if is_submodule:
705 submodules.add(path)
706 if status in 'DAMT':
707 staged.append(path)
708 if status == 'D':
709 deleted.add(path)
710 elif status == 'U':
711 unmerged.append(path)
713 return staged, unmerged, deleted, submodules
716 def diff_worktree(context, paths=None):
717 git = context.git
718 ignore_submodules_value = context.cfg.get('diff.ignoresubmodules', 'none')
719 ignore_submodules = ignore_submodules_value in {'all', 'dirty', 'untracked'}
720 modified = []
721 deleted = set()
722 submodules = set()
724 if paths is None:
725 paths = []
726 args = ['--'] + paths
727 status, out, _ = git.diff_files(z=True, _readonly=True, *args)
728 for path, status, is_submodule in _parse_raw_diff(out):
729 if is_submodule:
730 submodules.add(path)
731 if ignore_submodules:
732 continue
733 if status in 'DAMT':
734 modified.append(path)
735 if status == 'D':
736 deleted.add(path)
738 return modified, deleted, submodules
741 def diff_upstream(context, head):
742 """Given `ref`, return $(git merge-base ref HEAD)..ref."""
743 tracked = tracked_branch(context)
744 if not tracked:
745 return []
746 base = merge_base(context, head, tracked)
747 return diff_filenames(context, base, tracked)
750 def list_submodule(context):
751 """Return submodules in the format(state, sha_1, path, describe)"""
752 git = context.git
753 status, data, _ = git.submodule('status')
754 ret = []
755 if status == 0 and data:
756 data = data.splitlines()
757 # see git submodule status
758 for line in data:
759 state = line[0].strip()
760 sha1 = line[1 : OID_LENGTH + 1]
761 left_bracket = line.find('(', OID_LENGTH + 3)
762 if left_bracket == -1:
763 left_bracket = len(line) + 1
764 path = line[OID_LENGTH + 2 : left_bracket - 1]
765 describe = line[left_bracket + 1 : -1]
766 ret.append((state, sha1, path, describe))
767 return ret
770 def merge_base(context, head, ref):
771 """Return the merge-base of head and ref"""
772 git = context.git
773 return git.merge_base(head, ref, _readonly=True)[STDOUT]
776 def merge_base_parent(context, branch):
777 tracked = tracked_branch(context, branch=branch)
778 if tracked:
779 return tracked
780 return 'HEAD'
783 def ls_tree(context, path, ref='HEAD'):
784 """Return a parsed git ls-tree result for a single directory"""
785 git = context.git
786 result = []
787 status, out, _ = git.ls_tree(
788 ref, '--', path, z=True, full_tree=True, _readonly=True
790 if status == 0 and out:
791 path_offset = 6 + 1 + 4 + 1 + OID_LENGTH + 1
792 for line in out[:-1].split('\0'):
793 # 1 1 1
794 # .....6 ...4 ......................................40
795 # 040000 tree c127cde9a0c644a3a8fef449a244f47d5272dfa6 relative
796 # 100644 blob 139e42bf4acaa4927ec9be1ec55a252b97d3f1e2 relative/path
797 # 0..... 7... 12...................................... 53
798 # path_offset = 6 + 1 + 4 + 1 + OID_LENGTH(40) + 1
799 objtype = line[7:11]
800 relpath = line[path_offset:]
801 result.append((objtype, relpath))
803 return result
806 # A regex for matching the output of git(log|rev-list) --pretty=oneline
807 REV_LIST_REGEX = re.compile(r'^([0-9a-f]{40}) (.*)$')
810 def parse_rev_list(raw_revs):
811 """Parse `git log --pretty=online` output into (oid, summary) pairs."""
812 revs = []
813 for line in raw_revs.splitlines():
814 match = REV_LIST_REGEX.match(line)
815 if match:
816 rev_id = match.group(1)
817 summary = match.group(2)
818 revs.append((
819 rev_id,
820 summary,
822 return revs
825 # pylint: disable=redefined-builtin
826 def log_helper(context, all=False, extra_args=None):
827 """Return parallel arrays containing oids and summaries."""
828 revs = []
829 summaries = []
830 args = []
831 if extra_args:
832 args = extra_args
833 git = context.git
834 output = log(git, pretty='oneline', all=all, *args)
835 for line in output.splitlines():
836 match = REV_LIST_REGEX.match(line)
837 if match:
838 revs.append(match.group(1))
839 summaries.append(match.group(2))
840 return (revs, summaries)
843 def rev_list_range(context, start, end):
844 """Return (oid, summary) pairs between start and end."""
845 git = context.git
846 revrange = f'{start}..{end}'
847 out = git.rev_list(revrange, pretty='oneline', _readonly=True)[STDOUT]
848 return parse_rev_list(out)
851 def commit_message_path(context):
852 """Return the path to .git/GIT_COLA_MSG"""
853 git = context.git
854 path = git.git_path('GIT_COLA_MSG')
855 if core.exists(path):
856 return path
857 return None
860 def merge_message_path(context):
861 """Return the path to .git/MERGE_MSG or .git/SQUASH_MSG."""
862 git = context.git
863 for basename in ('MERGE_MSG', 'SQUASH_MSG'):
864 path = git.git_path(basename)
865 if core.exists(path):
866 return path
867 return None
870 def read_merge_commit_message(context, path):
871 """Read a merge commit message from disk while stripping commentary"""
872 content = core.read(path)
873 cleanup_mode = prefs.commit_cleanup(context)
874 if cleanup_mode in ('verbatim', 'scissors', 'whitespace'):
875 return content
876 comment_char = prefs.comment_char(context)
877 return '\n'.join(
878 line for line in content.splitlines() if not line.startswith(comment_char)
882 def prepare_commit_message_hook(context):
883 """Run the cola.preparecommitmessagehook to prepare the commit message"""
884 config = context.cfg
885 default_hook = config.hooks_path('cola-prepare-commit-msg')
886 return config.get('cola.preparecommitmessagehook', default=default_hook)
889 def cherry_pick(context, revs):
890 """Cherry-picks each revision into the current branch.
892 Returns (0, out, err) where stdout and stderr across all "git cherry-pick"
893 invocations are combined into single values when all cherry-picks succeed.
895 Returns a combined (status, out, err) of the first failing "git cherry-pick"
896 in the event of a non-zero exit status.
898 if not revs:
899 return []
900 outs = []
901 errs = []
902 status = 0
903 for rev in revs:
904 status, out, err = context.git.cherry_pick(rev)
905 if status != 0:
906 details = N_(
907 'Hint: The "Actions > Abort Cherry-Pick" menu action can be used to '
908 'cancel the current cherry-pick.'
910 output = f'# git cherry-pick {rev}\n# {details}\n\n{out}'
911 return (status, output, err)
912 outs.append(out)
913 errs.append(err)
914 return (0, '\n'.join(outs), '\n'.join(errs))
917 def abort_apply_patch(context):
918 """Abort a "git am" session."""
919 # Reset the worktree
920 git = context.git
921 status, out, err = git.am(abort=True)
922 return status, out, err
925 def abort_cherry_pick(context):
926 """Abort a cherry-pick."""
927 # Reset the worktree
928 git = context.git
929 status, out, err = git.cherry_pick(abort=True)
930 return status, out, err
933 def abort_merge(context):
934 """Abort a merge"""
935 # Reset the worktree
936 git = context.git
937 status, out, err = git.merge(abort=True)
938 return status, out, err
941 def strip_remote(remotes, remote_branch):
942 """Get branch names with the "<remote>/" prefix removed"""
943 for remote in remotes:
944 prefix = remote + '/'
945 if remote_branch.startswith(prefix):
946 return remote_branch[len(prefix) :]
947 return remote_branch.split('/', 1)[-1]
950 def parse_refs(context, argv):
951 """Parse command-line arguments into object IDs"""
952 git = context.git
953 status, out, _ = git.rev_parse(_readonly=True, *argv)
954 if status == 0:
955 oids = [oid for oid in out.splitlines() if oid]
956 else:
957 oids = argv
958 return oids
961 def prev_commitmsg(context, *args):
962 """Queries git for the latest commit message."""
963 git = context.git
964 return git.log(
965 '-1', no_color=True, pretty='format:%s%n%n%b', _readonly=True, *args
966 )[STDOUT]
969 def rev_parse(context, name):
970 """Call git rev-parse and return the output"""
971 git = context.git
972 status, out, _ = git.rev_parse(name, _readonly=True)
973 if status == 0:
974 result = out.strip()
975 else:
976 result = name
977 return result
980 def write_blob(context, oid, filename):
981 """Write a blob to a temporary file and return the path
983 Modern versions of Git allow invoking filters. Older versions
984 get the object content as-is.
987 if version.check_git(context, 'cat-file-filters-path'):
988 return cat_file_to_path(context, filename, oid)
989 return cat_file_blob(context, filename, oid)
992 def cat_file_blob(context, filename, oid):
993 """Write a blob from git to the specified filename"""
994 return cat_file(context, filename, 'blob', oid)
997 def cat_file_to_path(context, filename, oid):
998 """Extract a file from an commit ref and a write it to the specified filename"""
999 return cat_file(context, filename, oid, path=filename, filters=True)
1002 def cat_file(context, filename, *args, **kwargs):
1003 """Redirect git cat-file output to a path"""
1004 result = None
1005 git = context.git
1006 # Use the original filename in the suffix so that the generated filename
1007 # has the correct extension, and so that it resembles the original name.
1008 basename = os.path.basename(filename)
1009 suffix = '-' + basename # ensures the correct filename extension
1010 path = utils.tmp_filename('blob', suffix=suffix)
1011 with open(path, 'wb') as tmp_file:
1012 status, out, err = git.cat_file(
1013 _raw=True, _readonly=True, _stdout=tmp_file, *args, **kwargs
1015 Interaction.command(N_('Error'), 'git cat-file', status, out, err)
1016 if status == 0:
1017 result = path
1018 if not result:
1019 core.unlink(path)
1020 return result
1023 def write_blob_path(context, head, oid, filename):
1024 """Use write_blob() when modern git is available"""
1025 if version.check_git(context, 'cat-file-filters-path'):
1026 return write_blob(context, oid, filename)
1027 return cat_file_blob(context, filename, head + ':' + filename)
1030 def annex_path(context, head, filename):
1031 """Return the git-annex path for a filename at the specified commit"""
1032 git = context.git
1033 path = None
1034 annex_info = {}
1036 # unfortunately there's no way to filter this down to a single path
1037 # so we just have to scan all reported paths
1038 status, out, _ = git.annex('findref', '--json', head, _readonly=True)
1039 if status == 0:
1040 for line in out.splitlines():
1041 info = json.loads(line)
1042 try:
1043 annex_file = info['file']
1044 except (ValueError, KeyError):
1045 continue
1046 # we only care about this file so we can skip the rest
1047 if annex_file == filename:
1048 annex_info = info
1049 break
1050 key = annex_info.get('key', '')
1051 if key:
1052 status, out, _ = git.annex('contentlocation', key, _readonly=True)
1053 if status == 0 and os.path.exists(out):
1054 path = out
1056 return path
1059 def is_binary(context, filename):
1060 """A heuristic to determine whether `filename` contains (non-text) binary content"""
1061 cfg_is_binary = context.cfg.is_binary(filename)
1062 if cfg_is_binary is not None:
1063 return cfg_is_binary
1064 # This is the same heuristic as xdiff-interface.c:buffer_is_binary().
1065 size = 8000
1066 try:
1067 result = core.read(filename, size=size, encoding='bytes')
1068 except OSError:
1069 result = b''
1071 return b'\0' in result
1074 def is_valid_ref(context, ref):
1075 """Is the provided Git ref a valid refname?"""
1076 status, _, _ = context.git.rev_parse(ref, quiet=True, verify=True, _readonly=True)
1077 return status == 0