gitcmds: add a changed_files() helper function
[git-cola.git] / cola / gitcmds.py
blob47f306810dc4bfcb004ae5fba4b341cf3fc8365f
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 git = context.git
73 out = diff_tree(context, *args)[STDOUT]
74 return _parse_diff_filenames(out)
77 def changed_files(context, oid):
78 """Return the list of filenames that changed in a given commit oid"""
79 status, out, _ = diff_tree(context, oid + '~', oid)
80 if status != 0:
81 # git init
82 status, out, _ = diff_tree(context, EMPTY_TREE_OID, oid)
83 if status == 0:
84 result = _parse_diff_filenames(out)
85 else:
86 result = []
87 return result
90 def diff_tree(context, *args):
91 """Return a list of filenames that have been modified"""
92 git = context.git
93 return git_diff_tree(git, *args)
96 def git_diff_tree(git, *args):
97 return git.diff_tree(
98 name_only=True, no_commit_id=True, r=True, z=True, _readonly=True, *args
102 def listdir(context, dirname, ref='HEAD'):
103 """Get the contents of a directory according to Git
105 Query Git for the content of a directory, taking ignored
106 files into account.
109 dirs = []
110 files = []
112 # first, parse git ls-tree to get the tracked files
113 # in a list of (type, path) tuples
114 entries = ls_tree(context, dirname, ref=ref)
115 for entry in entries:
116 if entry[0][0] == 't': # tree
117 dirs.append(entry[1])
118 else:
119 files.append(entry[1])
121 # gather untracked files
122 untracked = untracked_files(context, paths=[dirname], directory=True)
123 for path in untracked:
124 if path.endswith('/'):
125 dirs.append(path[:-1])
126 else:
127 files.append(path)
129 dirs.sort()
130 files.sort()
132 return (dirs, files)
135 def diff(context, args):
136 """Return a list of filenames for the given diff arguments
138 :param args: list of arguments to pass to "git diff --name-only"
141 git = context.git
142 out = git.diff(name_only=True, z=True, _readonly=True, *args)[STDOUT]
143 return _parse_diff_filenames(out)
146 def _parse_diff_filenames(out):
147 if out:
148 return out[:-1].split('\0')
149 return []
152 def tracked_files(context, *args):
153 """Return the names of all files in the repository"""
154 git = context.git
155 out = git.ls_files('--', *args, z=True, _readonly=True)[STDOUT]
156 if out:
157 return sorted(out[:-1].split('\0'))
158 return []
161 def all_files(context, *args):
162 """Returns a sorted list of all files, including untracked files."""
163 git = context.git
164 ls_files = git.ls_files(
165 '--',
166 *args,
167 z=True,
168 cached=True,
169 others=True,
170 exclude_standard=True,
171 _readonly=True,
172 )[STDOUT]
173 return sorted([f for f in ls_files.split('\0') if f])
176 class CurrentBranchCache:
177 """Cache for current_branch()"""
179 key = None
180 value = None
183 def reset():
184 """Reset cached value in this module (e.g. the cached current branch)"""
185 CurrentBranchCache.key = None
188 def current_branch(context):
189 """Return the current branch"""
190 git = context.git
191 head = git.git_path('HEAD')
192 try:
193 key = core.stat(head).st_mtime
194 if CurrentBranchCache.key == key:
195 return CurrentBranchCache.value
196 except OSError:
197 # OSError means we can't use the stat cache
198 key = 0
200 status, data, _ = git.rev_parse('HEAD', symbolic_full_name=True, _readonly=True)
201 if status != 0:
202 # git init -- read .git/HEAD. We could do this unconditionally...
203 data = _read_git_head(context, head)
205 for refs_prefix in ('refs/heads/', 'refs/remotes/', 'refs/tags/'):
206 if data.startswith(refs_prefix):
207 value = data[len(refs_prefix) :]
208 CurrentBranchCache.key = key
209 CurrentBranchCache.value = value
210 return value
211 # Detached head
212 return data
215 def _read_git_head(context, head, default='main'):
216 """Pure-python .git/HEAD reader"""
217 # Common .git/HEAD "ref: refs/heads/main" files
218 git = context.git
219 islink = core.islink(head)
220 if core.isfile(head) and not islink:
221 data = core.read(head).rstrip()
222 ref_prefix = 'ref: '
223 if data.startswith(ref_prefix):
224 return data[len(ref_prefix) :]
225 # Detached head
226 return data
227 # Legacy .git/HEAD symlinks
228 if islink:
229 refs_heads = core.realpath(git.git_path('refs', 'heads'))
230 path = core.abspath(head).replace('\\', '/')
231 if path.startswith(refs_heads + '/'):
232 return path[len(refs_heads) + 1 :]
234 return default
237 def branch_list(context, remote=False):
239 Return a list of local or remote branches
241 This explicitly removes HEAD from the list of remote branches.
244 if remote:
245 return for_each_ref_basename(context, 'refs/remotes')
246 return for_each_ref_basename(context, 'refs/heads')
249 def _version_sort(context, key='version:refname'):
250 if version.check_git(context, 'version-sort'):
251 sort = key
252 else:
253 sort = False
254 return sort
257 def for_each_ref_basename(context, refs):
258 """Return refs starting with 'refs'."""
259 git = context.git
260 sort = _version_sort(context)
261 _, out, _ = git.for_each_ref(refs, format='%(refname)', sort=sort, _readonly=True)
262 output = out.splitlines()
263 non_heads = [x for x in output if not x.endswith('/HEAD')]
264 offset = len(refs) + 1
265 return [x[offset:] for x in non_heads]
268 def _prefix_and_size(prefix, values):
269 """Return a tuple of (prefix, len(prefix) + 1, y) for <prefix>/ stripping"""
270 return (prefix, len(prefix) + 1, values)
273 def all_refs(context, split=False, sort_key='version:refname'):
274 """Return a tuple of (local branches, remote branches, tags)."""
275 git = context.git
276 local_branches = []
277 remote_branches = []
278 tags = []
279 query = (
280 _prefix_and_size('refs/tags', tags),
281 _prefix_and_size('refs/heads', local_branches),
282 _prefix_and_size('refs/remotes', remote_branches),
284 sort = _version_sort(context, key=sort_key)
285 _, out, _ = git.for_each_ref(format='%(refname)', sort=sort, _readonly=True)
286 for ref in out.splitlines():
287 for prefix, prefix_len, dst in query:
288 if ref.startswith(prefix) and not ref.endswith('/HEAD'):
289 dst.append(ref[prefix_len:])
290 continue
291 tags.reverse()
292 if split:
293 return local_branches, remote_branches, tags
294 return local_branches + remote_branches + tags
297 def tracked_branch(context, branch=None):
298 """Return the remote branch associated with 'branch'."""
299 if branch is None:
300 branch = current_branch(context)
301 if branch is None:
302 return None
303 config = context.cfg
304 remote = config.get('branch.%s.remote' % branch)
305 if not remote:
306 return None
307 merge_ref = config.get('branch.%s.merge' % branch)
308 if not merge_ref:
309 return None
310 refs_heads = 'refs/heads/'
311 if merge_ref.startswith(refs_heads):
312 return remote + '/' + merge_ref[len(refs_heads) :]
313 return None
316 def parse_remote_branch(branch):
317 """Split a remote branch apart into (remote, name) components"""
318 rgx = re.compile(r'^(?P<remote>[^/]+)/(?P<branch>.+)$')
319 match = rgx.match(branch)
320 remote = ''
321 branch = ''
322 if match:
323 remote = match.group('remote')
324 branch = match.group('branch')
325 return (remote, branch)
328 def untracked_files(context, paths=None, **kwargs):
329 """Returns a sorted list of untracked files."""
330 git = context.git
331 if paths is None:
332 paths = []
333 args = ['--'] + paths
334 out = git.ls_files(
335 z=True, others=True, exclude_standard=True, _readonly=True, *args, **kwargs
336 )[STDOUT]
337 if out:
338 return out[:-1].split('\0')
339 return []
342 def tag_list(context):
343 """Return a list of tags."""
344 result = for_each_ref_basename(context, 'refs/tags')
345 result.reverse()
346 return result
349 def log(git, *args, **kwargs):
350 return git.log(
351 no_color=True,
352 no_abbrev_commit=True,
353 no_ext_diff=True,
354 _readonly=True,
355 *args,
356 **kwargs,
357 )[STDOUT]
360 def commit_diff(context, oid):
361 git = context.git
362 return log(git, '-1', oid, '--') + '\n\n' + oid_diff(context, oid)
365 _diff_overrides = {}
368 def update_diff_overrides(space_at_eol, space_change, all_space, function_context):
369 _diff_overrides['ignore_space_at_eol'] = space_at_eol
370 _diff_overrides['ignore_space_change'] = space_change
371 _diff_overrides['ignore_all_space'] = all_space
372 _diff_overrides['function_context'] = function_context
375 def common_diff_opts(context):
376 config = context.cfg
377 # Default to --patience when diff.algorithm is unset
378 patience = not config.get('diff.algorithm', default='')
379 submodule = version.check_git(context, 'diff-submodule')
380 opts = {
381 'patience': patience,
382 'submodule': submodule,
383 'no_color': True,
384 'no_ext_diff': True,
385 'unified': config.get('gui.diffcontext', default=3),
386 '_raw': True,
387 '_readonly': True,
389 opts.update(_diff_overrides)
390 return opts
393 def _add_filename(args, filename):
394 if filename:
395 args.extend(['--', filename])
398 def oid_diff(context, oid, filename=None):
399 """Return the diff for an oid"""
400 # Naively "$oid^!" is what we'd like to use but that doesn't
401 # give the correct result for merges--the diff is reversed.
402 # Be explicit and compare oid against its first parent.
403 return oid_diff_range(context, oid + '~', oid, filename=filename)
406 def oid_diff_range(context, start, end, filename=None):
407 """Return the diff for a commit range"""
408 args = [start, end]
409 git = context.git
410 opts = common_diff_opts(context)
411 _add_filename(args, filename)
412 status, out, _ = git.diff(*args, **opts)
413 if status != 0:
414 # We probably don't have "$oid~" because this is the root commit.
415 # "git show" is clever enough to handle the root commit.
416 args = [end + '^!']
417 _add_filename(args, filename)
418 _, out, _ = git.show(pretty='format:', *args, **opts)
419 out = out.lstrip()
420 return out
423 def diff_info(context, oid, filename=None):
424 """Return the diff for the specified oid"""
425 return diff_range(context, oid + '~', oid, filename=filename)
428 def diff_range(context, start, end, filename=None):
429 """Return the diff for the specified commit range"""
430 git = context.git
431 decoded = log(git, '-1', end, '--', pretty='format:%b').strip()
432 if decoded:
433 decoded += '\n\n'
434 return decoded + oid_diff_range(context, start, end, filename=filename)
437 # pylint: disable=too-many-arguments
438 def diff_helper(
439 context,
440 commit=None,
441 ref=None,
442 endref=None,
443 filename=None,
444 cached=True,
445 deleted=False,
446 head=None,
447 amending=False,
448 with_diff_header=False,
449 suppress_header=True,
450 reverse=False,
451 untracked=False,
453 """Invoke git diff on a path"""
454 git = context.git
455 cfg = context.cfg
456 if commit:
457 ref, endref = commit + '^', commit
458 argv = []
459 if ref and endref:
460 argv.append(f'{ref}..{endref}')
461 elif ref:
462 argv.extend(utils.shell_split(ref.strip()))
463 elif head and amending and cached:
464 argv.append(head)
466 encoding = None
467 if untracked:
468 argv.append('--no-index')
469 argv.append(os.devnull)
470 argv.append(filename)
471 elif filename:
472 argv.append('--')
473 if isinstance(filename, (list, tuple)):
474 argv.extend(filename)
475 else:
476 argv.append(filename)
477 encoding = cfg.file_encoding(filename)
479 status, out, _ = git.diff(
480 R=reverse,
481 M=True,
482 cached=cached,
483 _encoding=encoding,
484 *argv,
485 **common_diff_opts(context),
488 success = status == 0
490 # Diff will return 1 when comparing untracked file and it has change,
491 # therefore we will check for diff header from output to differentiate
492 # from actual error such as file not found.
493 if untracked and status == 1:
494 try:
495 _, second, _ = out.split('\n', 2)
496 except ValueError:
497 second = ''
498 success = second.startswith('new file mode ')
500 if not success:
501 # git init
502 if with_diff_header:
503 return ('', '')
504 return ''
506 result = extract_diff_header(deleted, with_diff_header, suppress_header, out)
507 return core.UStr(result, out.encoding)
510 def extract_diff_header(deleted, with_diff_header, suppress_header, diffoutput):
511 """Split a diff into a header section and payload section"""
513 if diffoutput.startswith('Submodule'):
514 if with_diff_header:
515 return ('', diffoutput)
516 return diffoutput
518 start = False
519 del_tag = 'deleted file mode '
521 output = StringIO()
522 headers = StringIO()
524 for line in diffoutput.split('\n'):
525 if not start and line[:2] == '@@' and '@@' in line[2:]:
526 start = True
527 if start or (deleted and del_tag in line):
528 output.write(line + '\n')
529 else:
530 if with_diff_header:
531 headers.write(line + '\n')
532 elif not suppress_header:
533 output.write(line + '\n')
535 output_text = output.getvalue()
536 output.close()
538 headers_text = headers.getvalue()
539 headers.close()
541 if with_diff_header:
542 return (headers_text, output_text)
543 return output_text
546 def format_patchsets(context, to_export, revs, output='patches'):
548 Group contiguous revision selection into patch sets
550 Exists to handle multi-selection.
551 Multiple disparate ranges in the revision selection
552 are grouped into continuous lists.
556 outs = []
557 errs = []
559 cur_rev = to_export[0]
560 cur_rev_idx = revs.index(cur_rev)
562 patches_to_export = [[cur_rev]]
563 patchset_idx = 0
565 # Group the patches into continuous sets
566 for rev in to_export[1:]:
567 # Limit the search to the current neighborhood for efficiency
568 try:
569 rev_idx = revs[cur_rev_idx:].index(rev)
570 rev_idx += cur_rev_idx
571 except ValueError:
572 rev_idx = revs.index(rev)
574 if rev_idx == cur_rev_idx + 1:
575 patches_to_export[patchset_idx].append(rev)
576 cur_rev_idx += 1
577 else:
578 patches_to_export.append([rev])
579 cur_rev_idx = rev_idx
580 patchset_idx += 1
582 # Export each patch set
583 status = 0
584 for patchset in patches_to_export:
585 stat, out, err = export_patchset(
586 context,
587 patchset[0],
588 patchset[-1],
589 output=output,
590 n=len(patchset) > 1,
591 thread=True,
592 patch_with_stat=True,
594 outs.append(out)
595 if err:
596 errs.append(err)
597 status = max(stat, status)
598 return (status, '\n'.join(outs), '\n'.join(errs))
601 def export_patchset(context, start, end, output='patches', **kwargs):
602 """Export patches from start^ to end."""
603 git = context.git
604 return git.format_patch('-o', output, start + '^..' + end, **kwargs)
607 def reset_paths(context, head, items):
608 """Run "git reset" while preventing argument overflow"""
609 items = list(set(items))
610 func = context.git.reset
611 status, out, err = utils.slice_func(items, lambda paths: func(head, '--', *paths))
612 return (status, out, err)
615 def unstage_paths(context, args, head='HEAD'):
616 """Unstage paths while accounting for git init"""
617 status, out, err = reset_paths(context, head, args)
618 if status == 128:
619 # handle git init: we have to use 'git rm --cached'
620 # detect this condition by checking if the file is still staged
621 return untrack_paths(context, args)
622 return (status, out, err)
625 def untrack_paths(context, args):
626 if not args:
627 return (-1, N_('Nothing to do'), '')
628 git = context.git
629 return git.update_index('--', force_remove=True, *set(args))
632 def worktree_state(
633 context, head='HEAD', update_index=False, display_untracked=True, paths=None
635 """Return a dict of files in various states of being
637 :rtype: dict, keys are staged, unstaged, untracked, unmerged,
638 changed_upstream, and submodule.
640 git = context.git
641 if update_index:
642 git.update_index(refresh=True)
644 staged, unmerged, staged_deleted, staged_submods = diff_index(
645 context, head, paths=paths
647 modified, unstaged_deleted, modified_submods = diff_worktree(context, paths)
648 if display_untracked:
649 untracked = untracked_files(context, paths=paths)
650 else:
651 untracked = []
653 # Remove unmerged paths from the modified list
654 if unmerged:
655 unmerged_set = set(unmerged)
656 modified = [path for path in modified if path not in unmerged_set]
658 # Look for upstream modified files if this is a tracking branch
659 upstream_changed = diff_upstream(context, head)
661 # Keep stuff sorted
662 staged.sort()
663 modified.sort()
664 unmerged.sort()
665 untracked.sort()
666 upstream_changed.sort()
668 return {
669 'staged': staged,
670 'modified': modified,
671 'unmerged': unmerged,
672 'untracked': untracked,
673 'upstream_changed': upstream_changed,
674 'staged_deleted': staged_deleted,
675 'unstaged_deleted': unstaged_deleted,
676 'submodules': staged_submods | modified_submods,
680 def _parse_raw_diff(out):
681 while out:
682 info, path, out = out.split('\0', 2)
683 status = info[-1]
684 is_submodule = '160000' in info[1:14]
685 yield (path, status, is_submodule)
688 def diff_index(context, head, cached=True, paths=None):
689 git = context.git
690 staged = []
691 unmerged = []
692 deleted = set()
693 submodules = set()
695 if paths is None:
696 paths = []
697 args = [head, '--'] + paths
698 status, out, _ = git.diff_index(cached=cached, z=True, _readonly=True, *args)
699 if status != 0:
700 # handle git init
701 args[0] = EMPTY_TREE_OID
702 status, out, _ = git.diff_index(cached=cached, z=True, _readonly=True, *args)
704 for path, status, is_submodule in _parse_raw_diff(out):
705 if is_submodule:
706 submodules.add(path)
707 if status in 'DAMT':
708 staged.append(path)
709 if status == 'D':
710 deleted.add(path)
711 elif status == 'U':
712 unmerged.append(path)
714 return staged, unmerged, deleted, submodules
717 def diff_worktree(context, paths=None):
718 git = context.git
719 ignore_submodules_value = context.cfg.get('diff.ignoresubmodules', 'none')
720 ignore_submodules = ignore_submodules_value in {'all', 'dirty', 'untracked'}
721 modified = []
722 deleted = set()
723 submodules = set()
725 if paths is None:
726 paths = []
727 args = ['--'] + paths
728 status, out, _ = git.diff_files(z=True, _readonly=True, *args)
729 for path, status, is_submodule in _parse_raw_diff(out):
730 if is_submodule:
731 submodules.add(path)
732 if ignore_submodules:
733 continue
734 if status in 'DAMT':
735 modified.append(path)
736 if status == 'D':
737 deleted.add(path)
739 return modified, deleted, submodules
742 def diff_upstream(context, head):
743 """Given `ref`, return $(git merge-base ref HEAD)..ref."""
744 tracked = tracked_branch(context)
745 if not tracked:
746 return []
747 base = merge_base(context, head, tracked)
748 return diff_filenames(context, base, tracked)
751 def list_submodule(context):
752 """Return submodules in the format(state, sha_1, path, describe)"""
753 git = context.git
754 status, data, _ = git.submodule('status')
755 ret = []
756 if status == 0 and data:
757 data = data.splitlines()
758 # see git submodule status
759 for line in data:
760 state = line[0].strip()
761 sha1 = line[1 : OID_LENGTH + 1]
762 left_bracket = line.find('(', OID_LENGTH + 3)
763 if left_bracket == -1:
764 left_bracket = len(line) + 1
765 path = line[OID_LENGTH + 2 : left_bracket - 1]
766 describe = line[left_bracket + 1 : -1]
767 ret.append((state, sha1, path, describe))
768 return ret
771 def merge_base(context, head, ref):
772 """Return the merge-base of head and ref"""
773 git = context.git
774 return git.merge_base(head, ref, _readonly=True)[STDOUT]
777 def merge_base_parent(context, branch):
778 tracked = tracked_branch(context, branch=branch)
779 if tracked:
780 return tracked
781 return 'HEAD'
784 def ls_tree(context, path, ref='HEAD'):
785 """Return a parsed git ls-tree result for a single directory"""
786 git = context.git
787 result = []
788 status, out, _ = git.ls_tree(
789 ref, '--', path, z=True, full_tree=True, _readonly=True
791 if status == 0 and out:
792 path_offset = 6 + 1 + 4 + 1 + OID_LENGTH + 1
793 for line in out[:-1].split('\0'):
794 # 1 1 1
795 # .....6 ...4 ......................................40
796 # 040000 tree c127cde9a0c644a3a8fef449a244f47d5272dfa6 relative
797 # 100644 blob 139e42bf4acaa4927ec9be1ec55a252b97d3f1e2 relative/path
798 # 0..... 7... 12...................................... 53
799 # path_offset = 6 + 1 + 4 + 1 + OID_LENGTH(40) + 1
800 objtype = line[7:11]
801 relpath = line[path_offset:]
802 result.append((objtype, relpath))
804 return result
807 # A regex for matching the output of git(log|rev-list) --pretty=oneline
808 REV_LIST_REGEX = re.compile(r'^([0-9a-f]{40}) (.*)$')
811 def parse_rev_list(raw_revs):
812 """Parse `git log --pretty=online` output into (oid, summary) pairs."""
813 revs = []
814 for line in raw_revs.splitlines():
815 match = REV_LIST_REGEX.match(line)
816 if match:
817 rev_id = match.group(1)
818 summary = match.group(2)
819 revs.append((
820 rev_id,
821 summary,
823 return revs
826 # pylint: disable=redefined-builtin
827 def log_helper(context, all=False, extra_args=None):
828 """Return parallel arrays containing oids and summaries."""
829 revs = []
830 summaries = []
831 args = []
832 if extra_args:
833 args = extra_args
834 git = context.git
835 output = log(git, pretty='oneline', all=all, *args)
836 for line in output.splitlines():
837 match = REV_LIST_REGEX.match(line)
838 if match:
839 revs.append(match.group(1))
840 summaries.append(match.group(2))
841 return (revs, summaries)
844 def rev_list_range(context, start, end):
845 """Return (oid, summary) pairs between start and end."""
846 git = context.git
847 revrange = f'{start}..{end}'
848 out = git.rev_list(revrange, pretty='oneline', _readonly=True)[STDOUT]
849 return parse_rev_list(out)
852 def commit_message_path(context):
853 """Return the path to .git/GIT_COLA_MSG"""
854 git = context.git
855 path = git.git_path('GIT_COLA_MSG')
856 if core.exists(path):
857 return path
858 return None
861 def merge_message_path(context):
862 """Return the path to .git/MERGE_MSG or .git/SQUASH_MSG."""
863 git = context.git
864 for basename in ('MERGE_MSG', 'SQUASH_MSG'):
865 path = git.git_path(basename)
866 if core.exists(path):
867 return path
868 return None
871 def read_merge_commit_message(context, path):
872 """Read a merge commit message from disk while stripping commentary"""
873 content = core.read(path)
874 cleanup_mode = prefs.commit_cleanup(context)
875 if cleanup_mode in ('verbatim', 'scissors', 'whitespace'):
876 return content
877 comment_char = prefs.comment_char(context)
878 return '\n'.join(
879 line for line in content.splitlines() if not line.startswith(comment_char)
883 def prepare_commit_message_hook(context):
884 """Run the cola.preparecommitmessagehook to prepare the commit message"""
885 config = context.cfg
886 default_hook = config.hooks_path('cola-prepare-commit-msg')
887 return config.get('cola.preparecommitmessagehook', default=default_hook)
890 def cherry_pick(context, revs):
891 """Cherry-picks each revision into the current branch.
893 Returns (0, out, err) where stdout and stderr across all "git cherry-pick"
894 invocations are combined into single values when all cherry-picks succeed.
896 Returns a combined (status, out, err) of the first failing "git cherry-pick"
897 in the event of a non-zero exit status.
899 if not revs:
900 return []
901 outs = []
902 errs = []
903 status = 0
904 for rev in revs:
905 status, out, err = context.git.cherry_pick(rev)
906 if status != 0:
907 details = N_(
908 'Hint: The "Actions > Abort Cherry-Pick" menu action can be used to '
909 'cancel the current cherry-pick.'
911 output = f'# git cherry-pick {rev}\n# {details}\n\n{out}'
912 return (status, output, err)
913 outs.append(out)
914 errs.append(err)
915 return (0, '\n'.join(outs), '\n'.join(errs))
918 def abort_apply_patch(context):
919 """Abort a "git am" session."""
920 # Reset the worktree
921 git = context.git
922 status, out, err = git.am(abort=True)
923 return status, out, err
926 def abort_cherry_pick(context):
927 """Abort a cherry-pick."""
928 # Reset the worktree
929 git = context.git
930 status, out, err = git.cherry_pick(abort=True)
931 return status, out, err
934 def abort_merge(context):
935 """Abort a merge"""
936 # Reset the worktree
937 git = context.git
938 status, out, err = git.merge(abort=True)
939 return status, out, err
942 def strip_remote(remotes, remote_branch):
943 """Get branch names with the "<remote>/" prefix removed"""
944 for remote in remotes:
945 prefix = remote + '/'
946 if remote_branch.startswith(prefix):
947 return remote_branch[len(prefix) :]
948 return remote_branch.split('/', 1)[-1]
951 def parse_refs(context, argv):
952 """Parse command-line arguments into object IDs"""
953 git = context.git
954 status, out, _ = git.rev_parse(_readonly=True, *argv)
955 if status == 0:
956 oids = [oid for oid in out.splitlines() if oid]
957 else:
958 oids = argv
959 return oids
962 def prev_commitmsg(context, *args):
963 """Queries git for the latest commit message."""
964 git = context.git
965 return git.log(
966 '-1', no_color=True, pretty='format:%s%n%n%b', _readonly=True, *args
967 )[STDOUT]
970 def rev_parse(context, name):
971 """Call git rev-parse and return the output"""
972 git = context.git
973 status, out, _ = git.rev_parse(name, _readonly=True)
974 if status == 0:
975 result = out.strip()
976 else:
977 result = name
978 return result
981 def write_blob(context, oid, filename):
982 """Write a blob to a temporary file and return the path
984 Modern versions of Git allow invoking filters. Older versions
985 get the object content as-is.
988 if version.check_git(context, 'cat-file-filters-path'):
989 return cat_file_to_path(context, filename, oid)
990 return cat_file_blob(context, filename, oid)
993 def cat_file_blob(context, filename, oid):
994 """Write a blob from git to the specified filename"""
995 return cat_file(context, filename, 'blob', oid)
998 def cat_file_to_path(context, filename, oid):
999 """Extract a file from an commit ref and a write it to the specified filename"""
1000 return cat_file(context, filename, oid, path=filename, filters=True)
1003 def cat_file(context, filename, *args, **kwargs):
1004 """Redirect git cat-file output to a path"""
1005 result = None
1006 git = context.git
1007 # Use the original filename in the suffix so that the generated filename
1008 # has the correct extension, and so that it resembles the original name.
1009 basename = os.path.basename(filename)
1010 suffix = '-' + basename # ensures the correct filename extension
1011 path = utils.tmp_filename('blob', suffix=suffix)
1012 with open(path, 'wb') as tmp_file:
1013 status, out, err = git.cat_file(
1014 _raw=True, _readonly=True, _stdout=tmp_file, *args, **kwargs
1016 Interaction.command(N_('Error'), 'git cat-file', status, out, err)
1017 if status == 0:
1018 result = path
1019 if not result:
1020 core.unlink(path)
1021 return result
1024 def write_blob_path(context, head, oid, filename):
1025 """Use write_blob() when modern git is available"""
1026 if version.check_git(context, 'cat-file-filters-path'):
1027 return write_blob(context, oid, filename)
1028 return cat_file_blob(context, filename, head + ':' + filename)
1031 def annex_path(context, head, filename):
1032 """Return the git-annex path for a filename at the specified commit"""
1033 git = context.git
1034 path = None
1035 annex_info = {}
1037 # unfortunately there's no way to filter this down to a single path
1038 # so we just have to scan all reported paths
1039 status, out, _ = git.annex('findref', '--json', head, _readonly=True)
1040 if status == 0:
1041 for line in out.splitlines():
1042 info = json.loads(line)
1043 try:
1044 annex_file = info['file']
1045 except (ValueError, KeyError):
1046 continue
1047 # we only care about this file so we can skip the rest
1048 if annex_file == filename:
1049 annex_info = info
1050 break
1051 key = annex_info.get('key', '')
1052 if key:
1053 status, out, _ = git.annex('contentlocation', key, _readonly=True)
1054 if status == 0 and os.path.exists(out):
1055 path = out
1057 return path
1060 def is_binary(context, filename):
1061 """A heuristic to determine whether `filename` contains (non-text) binary content"""
1062 cfg_is_binary = context.cfg.is_binary(filename)
1063 if cfg_is_binary is not None:
1064 return cfg_is_binary
1065 # This is the same heuristic as xdiff-interface.c:buffer_is_binary().
1066 size = 8000
1067 try:
1068 result = core.read(filename, size=size, encoding='bytes')
1069 except OSError:
1070 result = b''
1072 return b'\0' in result
1075 def is_valid_ref(context, ref):
1076 """Is the provided Git ref a valid refname?"""
1077 status, _, _ = context.git.rev_parse(ref, quiet=True, verify=True, _readonly=True)
1078 return status == 0