All patches and comments are welcome. Please squash your changes to logical
commits before using git-format-patch and git-send-email to
patches@git.madduck.net.
If you'd read over the Git project's submission guidelines and adhered to them,
I'd be especially grateful.
1 from __future__ import print_function
13 DATE_FORMAT = '%Y%m%dT%H%M%SZ'
16 COMPLETED = 'completed'
18 VERSION_2_1_0 = six.u('2.1.0')
19 VERSION_2_2_0 = six.u('2.2.0')
20 VERSION_2_3_0 = six.u('2.3.0')
21 VERSION_2_4_0 = six.u('2.4.0')
22 VERSION_2_4_1 = six.u('2.4.1')
23 VERSION_2_4_2 = six.u('2.4.2')
25 logger = logging.getLogger(__name__)
26 local_zone = tzlocal.get_localzone()
29 class TaskWarriorException(Exception):
33 class ReadOnlyDictView(object):
35 Provides simplified read-only view upon dict object.
38 def __init__(self, viewed_dict):
39 self.viewed_dict = viewed_dict
41 def __getitem__(self, key):
42 return copy.deepcopy(self.viewed_dict.__getitem__(key))
44 def __contains__(self, k):
45 return self.viewed_dict.__contains__(k)
48 for value in self.viewed_dict:
49 yield copy.deepcopy(value)
52 return len(self.viewed_dict)
54 def get(self, key, default=None):
55 return copy.deepcopy(self.viewed_dict.get(key, default))
58 return [copy.deepcopy(v) for v in self.viewed_dict.items()]
61 return [copy.deepcopy(v) for v in self.viewed_dict.values()]
64 class SerializingObject(object):
66 Common ancestor for TaskResource & TaskFilter, since they both
67 need to serialize arguments.
69 Serializing method should hold the following contract:
70 - any empty value (meaning removal of the attribute)
71 is deserialized into a empty string
72 - None denotes a empty value for any attribute
74 Deserializing method should hold the following contract:
75 - None denotes a empty value for any attribute (however,
76 this is here as a safeguard, TaskWarrior currently does
77 not export empty-valued attributes) if the attribute
78 is not iterable (e.g. list or set), in which case
79 a empty iterable should be used.
81 Normalizing methods should hold the following contract:
82 - They are used to validate and normalize the user input.
83 Any attribute value that comes from the user (during Task
84 initialization, assignign values to Task attributes, or
85 filtering by user-provided values of attributes) is first
86 validated and normalized using the normalize_{key} method.
87 - If validation or normalization fails, normalizer is expected
91 def _deserialize(self, key, value):
92 hydrate_func = getattr(self, 'deserialize_{0}'.format(key),
93 lambda x: x if x != '' else None)
94 return hydrate_func(value)
96 def _serialize(self, key, value):
97 dehydrate_func = getattr(self, 'serialize_{0}'.format(key),
98 lambda x: x if x is not None else '')
99 return dehydrate_func(value)
101 def _normalize(self, key, value):
103 Use normalize_<key> methods to normalize user input. Any user
104 input will be normalized at the moment it is used as filter,
105 or entered as a value of Task attribute.
108 # None value should not be converted by normalizer
112 normalize_func = getattr(self, 'normalize_{0}'.format(key),
115 return normalize_func(value)
117 def timestamp_serializer(self, date):
121 # Any serialized timestamp should be localized, we need to
122 # convert to UTC before converting to string (DATE_FORMAT uses UTC)
123 date = date.astimezone(pytz.utc)
125 return date.strftime(DATE_FORMAT)
127 def timestamp_deserializer(self, date_str):
131 # Return timestamp localized in the local zone
132 naive_timestamp = datetime.datetime.strptime(date_str, DATE_FORMAT)
133 localized_timestamp = pytz.utc.localize(naive_timestamp)
134 return localized_timestamp.astimezone(local_zone)
136 def serialize_entry(self, value):
137 return self.timestamp_serializer(value)
139 def deserialize_entry(self, value):
140 return self.timestamp_deserializer(value)
142 def normalize_entry(self, value):
143 return self.datetime_normalizer(value)
145 def serialize_modified(self, value):
146 return self.timestamp_serializer(value)
148 def deserialize_modified(self, value):
149 return self.timestamp_deserializer(value)
151 def normalize_modified(self, value):
152 return self.datetime_normalizer(value)
154 def serialize_start(self, value):
155 return self.timestamp_serializer(value)
157 def deserialize_start(self, value):
158 return self.timestamp_deserializer(value)
160 def normalize_start(self, value):
161 return self.datetime_normalizer(value)
163 def serialize_end(self, value):
164 return self.timestamp_serializer(value)
166 def deserialize_end(self, value):
167 return self.timestamp_deserializer(value)
169 def normalize_end(self, value):
170 return self.datetime_normalizer(value)
172 def serialize_due(self, value):
173 return self.timestamp_serializer(value)
175 def deserialize_due(self, value):
176 return self.timestamp_deserializer(value)
178 def normalize_due(self, value):
179 return self.datetime_normalizer(value)
181 def serialize_scheduled(self, value):
182 return self.timestamp_serializer(value)
184 def deserialize_scheduled(self, value):
185 return self.timestamp_deserializer(value)
187 def normalize_scheduled(self, value):
188 return self.datetime_normalizer(value)
190 def serialize_until(self, value):
191 return self.timestamp_serializer(value)
193 def deserialize_until(self, value):
194 return self.timestamp_deserializer(value)
196 def normalize_until(self, value):
197 return self.datetime_normalizer(value)
199 def serialize_wait(self, value):
200 return self.timestamp_serializer(value)
202 def deserialize_wait(self, value):
203 return self.timestamp_deserializer(value)
205 def normalize_wait(self, value):
206 return self.datetime_normalizer(value)
208 def serialize_annotations(self, value):
209 value = value if value is not None else []
211 # This may seem weird, but it's correct, we want to export
212 # a list of dicts as serialized value
213 serialized_annotations = [json.loads(annotation.export_data())
214 for annotation in value]
215 return serialized_annotations if serialized_annotations else ''
217 def deserialize_annotations(self, data):
218 return [TaskAnnotation(self, d) for d in data] if data else []
220 def serialize_tags(self, tags):
221 return ','.join(tags) if tags else ''
223 def deserialize_tags(self, tags):
224 if isinstance(tags, six.string_types):
225 return tags.split(',') if tags else []
228 def serialize_depends(self, value):
229 # Return the list of uuids
230 value = value if value is not None else set()
231 return ','.join(task['uuid'] for task in value)
233 def deserialize_depends(self, raw_uuids):
234 raw_uuids = raw_uuids or '' # Convert None to empty string
235 uuids = raw_uuids.split(',')
236 return set(self.warrior.tasks.get(uuid=uuid) for uuid in uuids if uuid)
238 def datetime_normalizer(self, value):
240 Normalizes date/datetime value (considered to come from user input)
241 to localized datetime value. Following conversions happen:
243 naive date -> localized datetime with the same date, and time=midnight
244 naive datetime -> localized datetime with the same value
245 localized datetime -> localized datetime (no conversion)
248 if (isinstance(value, datetime.date)
249 and not isinstance(value, datetime.datetime)):
250 # Convert to local midnight
251 value_full = datetime.datetime.combine(value, datetime.time.min)
252 localized = local_zone.localize(value_full)
253 elif isinstance(value, datetime.datetime) and value.tzinfo is None:
254 # Convert to localized datetime object
255 localized = local_zone.localize(value)
257 # If the value is already localized, there is no need to change
258 # time zone at this point. Also None is a valid value too.
263 def normalize_uuid(self, value):
265 if not isinstance(value, six.string_types) or value == '':
266 raise ValueError("UUID must be a valid non-empty string, "
267 "not: {}".format(value))
272 class TaskResource(SerializingObject):
273 read_only_fields = []
275 def _load_data(self, data):
276 self._data = dict((key, self._deserialize(key, value))
277 for key, value in data.items())
278 # We need to use a copy for original data, so that changes
279 # are not propagated.
280 self._original_data = copy.deepcopy(self._data)
282 def _update_data(self, data, update_original=False):
284 Low level update of the internal _data dict. Data which are coming as
285 updates should already be serialized. If update_original is True, the
286 original_data dict is updated as well.
288 self._data.update(dict((key, self._deserialize(key, value))
289 for key, value in data.items()))
292 self._original_data = copy.deepcopy(self._data)
295 def __getitem__(self, key):
296 # This is a workaround to make TaskResource non-iterable
297 # over simple index-based iteration
304 if key not in self._data:
305 self._data[key] = self._deserialize(key, None)
307 return self._data.get(key)
309 def __setitem__(self, key, value):
310 if key in self.read_only_fields:
311 raise RuntimeError('Field \'%s\' is read-only' % key)
313 # Normalize the user input before saving it
314 value = self._normalize(key, value)
315 self._data[key] = value
318 s = six.text_type(self.__unicode__())
320 s = s.encode('utf-8')
326 def export_data(self):
328 Exports current data contained in the Task as JSON
331 # We need to remove spaces for TW-1504, use custom separators
332 data_tuples = ((key, self._serialize(key, value))
333 for key, value in six.iteritems(self._data))
335 # Empty string denotes empty serialized value, we do not want
336 # to pass that to TaskWarrior.
337 data_tuples = filter(lambda t: t[1] is not '', data_tuples)
338 data = dict(data_tuples)
339 return json.dumps(data, separators=(',',':'))
342 def _modified_fields(self):
343 writable_fields = set(self._data.keys()) - set(self.read_only_fields)
344 for key in writable_fields:
345 new_value = self._data.get(key)
346 old_value = self._original_data.get(key)
348 # Make sure not to mark data removal as modified field if the
349 # field originally had some empty value
350 if key in self._data and not new_value and not old_value:
353 if new_value != old_value:
358 return bool(list(self._modified_fields))
361 class TaskAnnotation(TaskResource):
362 read_only_fields = ['entry', 'description']
364 def __init__(self, task, data={}):
366 self._load_data(data)
369 self.task.remove_annotation(self)
371 def __unicode__(self):
372 return self['description']
374 def __eq__(self, other):
375 # consider 2 annotations equal if they belong to the same task, and
376 # their data dics are the same
377 return self.task == other.task and self._data == other._data
379 __repr__ = __unicode__
382 class Task(TaskResource):
383 read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
385 class DoesNotExist(Exception):
388 class CompletedTask(Exception):
390 Raised when the operation cannot be performed on the completed task.
394 class DeletedTask(Exception):
396 Raised when the operation cannot be performed on the deleted task.
400 class NotSaved(Exception):
402 Raised when the operation cannot be performed on the task, because
403 it has not been saved to TaskWarrior yet.
408 def from_input(cls, input_file=sys.stdin, modify=None, warrior=None):
410 Creates a Task object, directly from the stdin, by reading one line.
411 If modify=True, two lines are used, first line interpreted as the
412 original state of the Task object, and second line as its new,
413 modified value. This is consistent with the TaskWarrior's hook
416 Object created by this method should not be saved, deleted
417 or refreshed, as t could create a infinite loop. For this
418 reason, TaskWarrior instance is set to None.
420 Input_file argument can be used to specify the input file,
421 but defaults to sys.stdin.
424 # Detect the hook type if not given directly
425 name = os.path.basename(sys.argv[0])
426 modify = name.startswith('on-modify') if modify is None else modify
428 # Create the TaskWarrior instance if none passed
430 hook_parent_dir = os.path.dirname(os.path.dirname(sys.argv[0]))
431 warrior = TaskWarrior(data_location=hook_parent_dir)
433 # TaskWarrior instance is set to None
436 # Load the data from the input
437 task._load_data(json.loads(input_file.readline().strip()))
439 # If this is a on-modify event, we are provided with additional
440 # line of input, which provides updated data
442 task._update_data(json.loads(input_file.readline().strip()))
446 def __init__(self, warrior, **kwargs):
447 self.warrior = warrior
449 # Check that user is not able to set read-only value in __init__
450 for key in kwargs.keys():
451 if key in self.read_only_fields:
452 raise RuntimeError('Field \'%s\' is read-only' % key)
454 # We serialize the data in kwargs so that users of the library
455 # do not have to pass different data formats via __setitem__ and
456 # __init__ methods, that would be confusing
458 # Rather unfortunate syntax due to python2.6 comaptiblity
459 self._data = dict((key, self._normalize(key, value))
460 for (key, value) in six.iteritems(kwargs))
461 self._original_data = copy.deepcopy(self._data)
463 # Provide read only access to the original data
464 self.original = ReadOnlyDictView(self._original_data)
466 def __unicode__(self):
467 return self['description']
469 def __eq__(self, other):
470 if self['uuid'] and other['uuid']:
471 # For saved Tasks, just define equality by equality of uuids
472 return self['uuid'] == other['uuid']
474 # If the tasks are not saved, compare the actual instances
475 return id(self) == id(other)
480 # For saved Tasks, just define equality by equality of uuids
481 return self['uuid'].__hash__()
483 # If the tasks are not saved, return hash of instance id
484 return id(self).__hash__()
488 return self['status'] == six.text_type('completed')
492 return self['status'] == six.text_type('deleted')
496 return self['status'] == six.text_type('waiting')
500 return self['status'] == six.text_type('pending')
504 return self['uuid'] is not None or self['id'] is not None
506 def serialize_depends(self, cur_dependencies):
507 # Check that all the tasks are saved
508 for task in (cur_dependencies or set()):
510 raise Task.NotSaved('Task \'%s\' needs to be saved before '
511 'it can be set as dependency.' % task)
513 return super(Task, self).serialize_depends(cur_dependencies)
515 def format_depends(self):
516 # We need to generate added and removed dependencies list,
517 # since Taskwarrior does not accept redefining dependencies.
519 # This cannot be part of serialize_depends, since we need
520 # to keep a list of all depedencies in the _data dictionary,
521 # not just currently added/removed ones
523 old_dependencies = self._original_data.get('depends', set())
525 added = self['depends'] - old_dependencies
526 removed = old_dependencies - self['depends']
528 # Removed dependencies need to be prefixed with '-'
529 return 'depends:' + ','.join(
530 [t['uuid'] for t in added] +
531 ['-' + t['uuid'] for t in removed]
534 def format_description(self):
535 # Task version older than 2.4.0 ignores first word of the
536 # task description if description: prefix is used
537 if self.warrior.version < VERSION_2_4_0:
538 return self._data['description']
540 return "description:'{0}'".format(self._data['description'] or '')
544 raise Task.NotSaved("Task needs to be saved before it can be deleted")
546 # Refresh the status, and raise exception if the task is deleted
547 self.refresh(only_fields=['status'])
550 raise Task.DeletedTask("Task was already deleted")
552 self.warrior.execute_command([self['uuid'], 'delete'])
554 # Refresh the status again, so that we have updated info stored
555 self.refresh(only_fields=['status', 'start', 'end'])
559 raise Task.NotSaved("Task needs to be saved before it can be started")
561 # Refresh, and raise exception if task is already completed/deleted
562 self.refresh(only_fields=['status'])
565 raise Task.CompletedTask("Cannot start a completed task")
567 raise Task.DeletedTask("Deleted task cannot be started")
569 self.warrior.execute_command([self['uuid'], 'start'])
571 # Refresh the status again, so that we have updated info stored
572 self.refresh(only_fields=['status', 'start'])
576 raise Task.NotSaved("Task needs to be saved before it can be completed")
578 # Refresh, and raise exception if task is already completed/deleted
579 self.refresh(only_fields=['status'])
582 raise Task.CompletedTask("Cannot complete a completed task")
584 raise Task.DeletedTask("Deleted task cannot be completed")
586 self.warrior.execute_command([self['uuid'], 'done'])
588 # Refresh the status again, so that we have updated info stored
589 self.refresh(only_fields=['status', 'start', 'end'])
592 if self.saved and not self.modified:
595 args = [self['uuid'], 'modify'] if self.saved else ['add']
596 args.extend(self._get_modified_fields_as_args())
597 output = self.warrior.execute_command(args)
599 # Parse out the new ID, if the task is being added for the first time
601 id_lines = [l for l in output if l.startswith('Created task ')]
603 # Complain loudly if it seems that more tasks were created
605 if len(id_lines) != 1 or len(id_lines[0].split(' ')) != 3:
606 raise TaskWarriorException("Unexpected output when creating "
607 "task: %s" % '\n'.join(id_lines))
609 # Circumvent the ID storage, since ID is considered read-only
610 self._data['id'] = int(id_lines[0].split(' ')[2].rstrip('.'))
612 # Refreshing is very important here, as not only modification time
613 # is updated, but arbitrary attribute may have changed due hooks
614 # altering the data before saving
617 def add_annotation(self, annotation):
619 raise Task.NotSaved("Task needs to be saved to add annotation")
621 args = [self['uuid'], 'annotate', annotation]
622 self.warrior.execute_command(args)
623 self.refresh(only_fields=['annotations'])
625 def remove_annotation(self, annotation):
627 raise Task.NotSaved("Task needs to be saved to remove annotation")
629 if isinstance(annotation, TaskAnnotation):
630 annotation = annotation['description']
631 args = [self['uuid'], 'denotate', annotation]
632 self.warrior.execute_command(args)
633 self.refresh(only_fields=['annotations'])
635 def _get_modified_fields_as_args(self):
638 def add_field(field):
639 # Add the output of format_field method to args list (defaults to
641 serialized_value = self._serialize(field, self._data[field])
643 # Empty values should not be enclosed in quotation marks, see
645 if serialized_value is '':
646 escaped_serialized_value = ''
648 escaped_serialized_value = "'{0}'".format(serialized_value)
650 format_default = lambda: "{0}:{1}".format(field,
651 escaped_serialized_value)
653 format_func = getattr(self, 'format_{0}'.format(field),
656 args.append(format_func())
658 # If we're modifying saved task, simply pass on all modified fields
660 for field in self._modified_fields:
662 # For new tasks, pass all fields that make sense
664 for field in self._data.keys():
665 if field in self.read_only_fields:
671 def refresh(self, only_fields=[]):
672 # Raise error when trying to refresh a task that has not been saved
674 raise Task.NotSaved("Task needs to be saved to be refreshed")
676 # We need to use ID as backup for uuid here for the refreshes
677 # of newly saved tasks. Any other place in the code is fine
678 # with using UUID only.
679 args = [self['uuid'] or self['id'], 'export']
680 new_data = json.loads(self.warrior.execute_command(args)[0])
683 [(k, new_data.get(k)) for k in only_fields])
684 self._update_data(to_update, update_original=True)
686 self._load_data(new_data)
688 class TaskFilter(SerializingObject):
690 A set of parameters to filter the task list with.
693 def __init__(self, filter_params=[]):
694 self.filter_params = filter_params
696 def add_filter(self, filter_str):
697 self.filter_params.append(filter_str)
699 def add_filter_param(self, key, value):
700 key = key.replace('__', '.')
702 # Replace the value with empty string, since that is the
703 # convention in TW for empty values
704 attribute_key = key.split('.')[0]
706 # Since this is user input, we need to normalize before we serialize
707 value = self._normalize(attribute_key, value)
708 value = self._serialize(attribute_key, value)
710 # If we are filtering by uuid:, do not use uuid keyword
713 self.filter_params.insert(0, value)
715 # Surround value with aphostrophes unless it's a empty string
716 value = "'%s'" % value if value else ''
718 # We enforce equality match by using 'is' (or 'none') modifier
719 # Without using this syntax, filter fails due to TW-1479
720 modifier = '.is' if value else '.none'
721 key = key + modifier if '.' not in key else key
723 self.filter_params.append("{0}:{1}".format(key, value))
725 def get_filter_params(self):
726 return [f for f in self.filter_params if f]
730 c.filter_params = list(self.filter_params)
734 class TaskQuerySet(object):
736 Represents a lazy lookup for a task objects.
739 def __init__(self, warrior=None, filter_obj=None):
740 self.warrior = warrior
741 self._result_cache = None
742 self.filter_obj = filter_obj or TaskFilter()
744 def __deepcopy__(self, memo):
746 Deep copy of a QuerySet doesn't populate the cache
748 obj = self.__class__()
749 for k, v in self.__dict__.items():
750 if k in ('_iter', '_result_cache'):
751 obj.__dict__[k] = None
753 obj.__dict__[k] = copy.deepcopy(v, memo)
757 data = list(self[:REPR_OUTPUT_SIZE + 1])
758 if len(data) > REPR_OUTPUT_SIZE:
759 data[-1] = "...(remaining elements truncated)..."
763 if self._result_cache is None:
764 self._result_cache = list(self)
765 return len(self._result_cache)
768 if self._result_cache is None:
769 self._result_cache = self._execute()
770 return iter(self._result_cache)
772 def __getitem__(self, k):
773 if self._result_cache is None:
774 self._result_cache = list(self)
775 return self._result_cache.__getitem__(k)
778 if self._result_cache is not None:
779 return bool(self._result_cache)
782 except StopIteration:
786 def __nonzero__(self):
787 return type(self).__bool__(self)
789 def _clone(self, klass=None, **kwargs):
791 klass = self.__class__
792 filter_obj = self.filter_obj.clone()
793 c = klass(warrior=self.warrior, filter_obj=filter_obj)
794 c.__dict__.update(kwargs)
799 Fetch the tasks which match the current filters.
801 return self.warrior.filter_tasks(self.filter_obj)
805 Returns a new TaskQuerySet that is a copy of the current one.
810 return self.filter(status=PENDING)
813 return self.filter(status=COMPLETED)
815 def filter(self, *args, **kwargs):
817 Returns a new TaskQuerySet with the given filters added.
819 clone = self._clone()
821 clone.filter_obj.add_filter(f)
822 for key, value in kwargs.items():
823 clone.filter_obj.add_filter_param(key, value)
826 def get(self, **kwargs):
828 Performs the query and returns a single object matching the given
831 clone = self.filter(**kwargs)
834 return clone._result_cache[0]
836 raise Task.DoesNotExist(
837 'Task matching query does not exist. '
838 'Lookup parameters were {0}'.format(kwargs))
840 'get() returned more than one Task -- it returned {0}! '
841 'Lookup parameters were {1}'.format(num, kwargs))
844 class TaskWarrior(object):
845 def __init__(self, data_location='~/.task', create=True, taskrc_location='~/.taskrc'):
846 data_location = os.path.expanduser(data_location)
847 self.taskrc_location = os.path.expanduser(taskrc_location)
849 # If taskrc does not exist, pass / to use defaults and avoid creating
850 # dummy .taskrc file by TaskWarrior
851 if not os.path.exists(self.taskrc_location):
852 self.taskrc_location = '/'
854 if create and not os.path.exists(data_location):
855 os.makedirs(data_location)
857 'data.location': os.path.expanduser(data_location),
858 'confirmation': 'no',
859 'dependency.confirmation': 'no', # See TW-1483 or taskrc man page
860 'recurrence.confirmation': 'no', # Necessary for modifying R tasks
862 self.tasks = TaskQuerySet(self)
863 self.version = self._get_version()
865 def _get_command_args(self, args, config_override={}):
866 command_args = ['task', 'rc:{0}'.format(self.taskrc_location)]
867 config = self.config.copy()
868 config.update(config_override)
869 for item in config.items():
870 command_args.append('rc.{0}={1}'.format(*item))
871 command_args.extend(map(str, args))
874 def _get_version(self):
875 p = subprocess.Popen(
876 ['task', '--version'],
877 stdout=subprocess.PIPE,
878 stderr=subprocess.PIPE)
879 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
880 return stdout.strip('\n')
882 def execute_command(self, args, config_override={}, allow_failure=True):
883 command_args = self._get_command_args(
884 args, config_override=config_override)
885 logger.debug(' '.join(command_args))
886 p = subprocess.Popen(command_args, stdout=subprocess.PIPE,
887 stderr=subprocess.PIPE)
888 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
889 if p.returncode and allow_failure:
891 error_msg = stderr.strip()
893 error_msg = stdout.strip()
894 raise TaskWarriorException(error_msg)
895 return stdout.strip().split('\n')
897 def enforce_recurrence(self):
898 # Run arbitrary report command which will trigger generation
899 # of recurrent tasks.
901 # Only necessary for TW up to 2.4.1, fixed in 2.4.2.
902 if self.version < VERSION_2_4_2:
903 self.execute_command(['next'], allow_failure=False)
905 def filter_tasks(self, filter_obj):
906 self.enforce_recurrence()
907 args = ['export', '--'] + filter_obj.get_filter_params()
909 for line in self.execute_command(args):
911 data = line.strip(',')
913 filtered_task = Task(self)
914 filtered_task._load_data(json.loads(data))
915 tasks.append(filtered_task)
917 raise TaskWarriorException('Invalid JSON: %s' % data)
920 def merge_with(self, path, push=False):
921 path = path.rstrip('/') + '/'
922 self.execute_command(['merge', path], config_override={
923 'merge.autopush': 'yes' if push else 'no',
927 self.execute_command(['undo'])