Patches delete(), update(), and rename()
[stgit.git] / stgit / lib / stack.py
blob8e567cf167a56b8fadb1a0d8a864ebd0d44302ea
1 """A Python class hierarchy wrapping the StGit on-disk metadata."""
2 import re
4 from stgit.config import config
5 from stgit.exception import StackException
6 from stgit.lib import log, stackupgrade
7 from stgit.lib.git import Repository
8 from stgit.lib.git.branch import Branch, BranchException
11 def _stack_state_ref(stack_name):
12 """Reference to stack state metadata. A.k.a. the stack's "log"."""
13 return 'refs/heads/%s.stgit' % (stack_name,)
16 def _patch_ref(stack_name, patch_name):
17 """Reference to a named patch's commit."""
18 return 'refs/patches/%s/%s' % (stack_name, patch_name)
21 def _patches_ref_prefix(stack_name):
22 return _patch_ref(stack_name, '')
25 class Patch:
26 """Represents an StGit patch."""
28 def __init__(self, stack, name):
29 self._stack = stack
30 self.name = name
32 @property
33 def _ref(self):
34 return _patch_ref(self._stack.name, self.name)
36 @property
37 def commit(self):
38 return self._stack.repository.refs.get(self._ref)
40 def set_commit(self, commit, msg):
41 try:
42 old_sha1 = self.commit.sha1
43 except KeyError:
44 old_sha1 = None
45 self._stack.repository.refs.set(self._ref, commit, msg)
46 if old_sha1 and old_sha1 != commit.sha1:
47 self._stack.repository.copy_notes(old_sha1, commit.sha1)
49 def set_name(self, name, msg):
50 commit = self.commit
51 self._stack.repository.refs.delete(self._ref)
52 self.name = name
53 self._stack.repository.refs.set(self._ref, commit, msg)
55 def is_empty(self):
56 return self.commit.data.is_nochange()
58 def files(self):
59 """Return the set of files this patch touches."""
60 fs = set()
61 for dt in self._stack.repository.diff_tree_files(
62 self.commit.data.parent.data.tree,
63 self.commit.data.tree,
65 _, _, _, _, _, oldname, newname = dt
66 fs.add(oldname)
67 fs.add(newname)
68 return fs
71 class PatchOrder:
72 """Keeps track of patch order, and which patches are applied.
74 Works with patch names, not actual patches.
76 """
78 def __init__(self, state):
79 self._applied = tuple(state.applied)
80 self._unapplied = tuple(state.unapplied)
81 self._hidden = tuple(state.hidden)
83 @property
84 def applied(self):
85 return self._applied
87 @property
88 def unapplied(self):
89 return self._unapplied
91 @property
92 def hidden(self):
93 return self._hidden
95 @property
96 def all(self):
97 return self.applied + self.unapplied + self.hidden
99 @property
100 def all_visible(self):
101 return self.applied + self.unapplied
103 def set_order(self, applied, unapplied, hidden):
104 self._applied = tuple(applied)
105 self._unapplied = tuple(unapplied)
106 self._hidden = tuple(hidden)
108 def rename_patch(self, old_name, new_name):
109 for attr in ['_applied', '_unapplied', '_hidden']:
110 patch_list = list(getattr(self, attr))
111 try:
112 index = patch_list.index(old_name)
113 except ValueError:
114 continue
115 else:
116 patch_list[index] = new_name
117 setattr(self, attr, tuple(patch_list))
118 break
119 else:
120 raise AssertionError('"%s" not found in patchorder' % old_name)
123 class Patches:
124 """Manage the set of :class:`Patch` objects.
126 Ensures a single :class:`Patch` instance per patch.
130 def __init__(self, stack, state):
131 self._stack = stack
132 self._patches = {pn: Patch(stack, pn) for pn in state.patches}
134 # Ensure patch refs in repository match those from stack state.
135 repository = stack.repository
136 patch_ref_prefix = _patches_ref_prefix(stack.name)
138 state_patch_ref_map = {
139 _patch_ref(stack.name, pn): commit for pn, commit in state.patches.items()
142 state_patch_refs = set(state_patch_ref_map)
143 repo_patch_refs = {
144 ref for ref in repository.refs if ref.startswith(patch_ref_prefix)
147 delete_patch_refs = repo_patch_refs - state_patch_refs
148 create_patch_refs = state_patch_refs - repo_patch_refs
149 update_patch_refs = {
151 for ref in state_patch_refs - create_patch_refs
152 if state_patch_ref_map[ref].sha1 != repository.refs.get(ref).sha1
155 if create_patch_refs or update_patch_refs or delete_patch_refs:
156 repository.refs.batch_update(
157 msg='restore from stack state',
158 create=[(ref, state_patch_ref_map[ref]) for ref in create_patch_refs],
159 update=[(ref, state_patch_ref_map[ref]) for ref in update_patch_refs],
160 delete=delete_patch_refs,
163 def exists(self, name):
164 try:
165 self.get(name)
166 return True
167 except KeyError:
168 return False
170 def get(self, name):
171 return self._patches[name]
173 def name_from_sha1(self, partial_sha1):
174 for pn, patch in self._patches.items():
175 if patch.commit.sha1.startswith(partial_sha1.lower()):
176 return pn
177 else:
178 return None
180 def is_name_valid(self, name):
181 if '/' in name:
182 # TODO slashes in patch names could be made to be okay
183 return False
184 ref = _patch_ref(self._stack.name, name)
185 p = self._stack.repository.run(['git', 'check-ref-format', ref])
186 p.returns([0, 1]).discard_stderr().discard_output()
187 return p.exitcode == 0
189 def new(self, name, commit, msg):
190 assert name not in self._patches
191 assert self.is_name_valid(name)
192 p = Patch(self._stack, name)
193 p.set_commit(commit, msg)
194 self._patches[name] = p
195 return p
197 def update(self, name, commit, msg):
198 self._patches[name].set_commit(commit, msg)
200 def rename(self, old_name, new_name, msg):
201 patch = self._patches[old_name]
202 patch.set_name(new_name, msg)
203 self._patches[new_name] = patch
204 del self._patches[old_name]
206 def delete(self, name):
207 patch = self._patches.pop(name)
208 self._stack.repository.refs.delete(patch._ref)
210 def make_name(self, raw, unique=True, lower=True, allow=(), disallow=()):
211 """Make a unique and valid patch name from provided raw name.
213 The raw name may come from a filename, commit message, or email subject line.
215 The generated patch name will meet the rules of `git check-ref-format` along
216 with some additional StGit patch name rules.
219 default_name = 'patch'
221 for line in raw.split('\n'):
222 if line:
223 break
225 if not line:
226 line = default_name
228 if lower:
229 line = line.lower()
231 parts = []
232 for part in line.split('/'):
233 # fmt: off
234 part = re.sub(r'\.lock$', '', part) # Disallowed in Git refs
235 part = re.sub(r'^\.+|\.+$', '', part) # Cannot start or end with '.'
236 part = re.sub(r'\.+', '.', part) # No consecutive '.'
237 part = re.sub(r'[^\w.]+', '-', part) # Non-word and whitespace to dashes
238 part = re.sub(r'-+', '-', part) # Squash consecutive dashes
239 part = re.sub(r'^-+|-+$', '', part) # Remove leading and trailing dashes
240 # fmt: on
241 if part:
242 parts.append(part)
244 long_name = '/'.join(parts)
246 # TODO: slashes could be allowed in the future.
247 long_name = long_name.replace('/', '-')
249 if not long_name:
250 long_name = default_name
252 assert self.is_name_valid(long_name)
254 name_len = config.getint('stgit.namelength')
256 words = long_name.split('-')
257 short_name = words[0]
258 for word in words[1:]:
259 new_name = '%s-%s' % (short_name, word)
260 if name_len <= 0 or len(new_name) <= name_len:
261 short_name = new_name
262 else:
263 break
264 assert self.is_name_valid(short_name)
266 if not unique:
267 return short_name
269 unique_name = short_name
270 while unique_name not in allow and (
271 self.exists(unique_name) or unique_name in disallow
273 m = re.match(r'(.*?)(-)?(\d+)$', unique_name)
274 if m:
275 base, sep, n_str = m.groups()
276 n = int(n_str) + 1
277 if sep:
278 unique_name = '%s%s%d' % (base, sep, n)
279 else:
280 unique_name = '%s%d' % (base, n)
281 else:
282 unique_name = '%s-1' % unique_name
284 assert self.is_name_valid(unique_name)
285 return unique_name
288 class Stack(Branch):
289 """Represents a StGit stack.
291 A StGit stack is a Git branch with extra metadata for patch stack state.
295 def __init__(self, repository, name):
296 super().__init__(repository, name)
297 if not stackupgrade.update_to_current_format_version(repository, name):
298 raise StackException('%s: branch not initialized' % name)
299 state = log.get_stack_state(self.repository, self.state_ref)
300 self.patchorder = PatchOrder(state)
301 self.patches = Patches(self, state)
303 @property
304 def base(self):
305 if self.patchorder.applied:
306 return self.patches.get(self.patchorder.applied[0]).commit.data.parent
307 else:
308 return self.head
310 @property
311 def top(self):
312 """Commit of the topmost patch, or the stack base if no patches are applied."""
313 if self.patchorder.applied:
314 return self.patches.get(self.patchorder.applied[-1]).commit
315 else:
316 # When no patches are applied, base == head.
317 return self.head
319 def head_top_equal(self):
320 if not self.patchorder.applied:
321 return True
322 top = self.patches.get(self.patchorder.applied[-1]).commit
323 return self.head == top
325 def set_parents(self, remote, branch):
326 if remote:
327 self.set_parent_remote(remote)
328 if branch:
329 self.set_parent_branch(branch)
330 config.set('branch.%s.stgit.parentbranch' % self.name, branch)
332 @property
333 def protected(self):
334 return config.getbool('branch.%s.stgit.protect' % self.name)
336 @protected.setter
337 def protected(self, protect):
338 protect_key = 'branch.%s.stgit.protect' % self.name
339 if protect:
340 config.set(protect_key, 'true')
341 elif self.protected:
342 config.unset(protect_key)
344 @property
345 def state_ref(self):
346 return _stack_state_ref(self.name)
348 def cleanup(self):
349 assert not self.protected, 'attempt to delete protected stack'
350 for pn in self.patchorder.all:
351 self.patches.delete(pn)
352 self.repository.refs.delete(self.state_ref)
353 config.remove_section('branch.%s.stgit' % self.name)
355 def clear_log(self, msg='clear log'):
356 stack_state = log.StackState.from_stack(prev=None, stack=self)
357 state_commit = stack_state.commit_state(self.repository, msg)
358 self.repository.refs.set(self.state_ref, state_commit, msg=msg)
360 def rename(self, new_name):
361 old_name = self.name
362 patch_names = self.patchorder.all
363 super().rename(new_name)
364 renames = []
365 for pn in patch_names:
366 renames.append((_patch_ref(old_name, pn), _patch_ref(new_name, pn)))
367 renames.append((_stack_state_ref(old_name), _stack_state_ref(new_name)))
369 self.repository.refs.rename('rename %s to %s' % (old_name, new_name), *renames)
371 config.rename_section(
372 'branch.%s.stgit' % old_name,
373 'branch.%s.stgit' % new_name,
376 def rename_patch(self, old_name, new_name, msg='rename'):
377 if new_name == old_name:
378 raise StackException('New patch name same as old: "%s"' % new_name)
379 elif self.patches.exists(new_name):
380 raise StackException('Patch already exists: "%s"' % new_name)
381 elif not self.patches.is_name_valid(new_name):
382 raise StackException('Invalid patch name: "%s"' % new_name)
383 elif not self.patches.exists(old_name):
384 raise StackException('Unknown patch name: "%s"' % old_name)
385 self.patchorder.rename_patch(old_name, new_name)
386 self.patches.rename(old_name, new_name, msg)
388 def clone(self, clone_name, msg):
389 clone = self.create(
390 self.repository,
391 name=clone_name,
392 msg=msg,
393 create_at=self.base,
394 parent_remote=self.parent_remote,
395 parent_branch=self.name,
398 for pn in self.patchorder.all_visible:
399 patch = self.patches.get(pn)
400 clone.patches.new(pn, patch.commit, 'clone from %s' % self.name)
402 clone.patchorder.set_order(
403 applied=[],
404 unapplied=self.patchorder.all_visible,
405 hidden=[],
408 prefix = 'branch.%s.' % self.name
409 clone_prefix = 'branch.%s.' % clone_name
410 for k, v in list(config.getstartswith(prefix)):
411 clone_key = k.replace(prefix, clone_prefix, 1)
412 config.set(clone_key, v)
414 self.repository.refs.set(
415 clone.state_ref,
416 self.repository.refs.get(self.state_ref),
417 msg=msg,
420 return clone
422 @classmethod
423 def initialise(cls, repository, name=None, msg='initialise', switch_to=False):
424 """Initialise a Git branch to handle patch stack.
426 :param repository: :class:`Repository` where the :class:`Stack` will be created
427 :param name: the name of the :class:`Stack`
430 if not name:
431 name = repository.current_branch_name
432 # make sure that the corresponding Git branch exists
433 branch = Branch(repository, name)
435 stack_state_ref = _stack_state_ref(name)
436 if repository.refs.exists(stack_state_ref):
437 raise StackException('%s: stack already initialized' % name)
439 if switch_to:
440 branch.switch_to()
442 stack_state = log.StackState.new_empty(branch.head)
443 state_commit = stack_state.commit_state(repository, msg)
444 repository.refs.set(stack_state_ref, state_commit, msg)
446 return repository.get_stack(name)
448 @classmethod
449 def create(
450 cls,
451 repository,
452 name,
453 msg,
454 create_at=None,
455 parent_remote=None,
456 parent_branch=None,
457 switch_to=False,
459 """Create and initialise a Git branch returning the :class:`Stack` object.
461 :param repository: :class:`Repository` where the :class:`Stack` will be created
462 :param name: name of the :class:`Stack`
463 :param msg: message to use in newly created log
464 :param create_at: Git id used as the base for the newly created Git branch
465 :param parent_remote: name of the parent remote Git branch
466 :param parent_branch: name of the parent Git branch
469 branch = Branch.create(repository, name, create_at=create_at)
470 try:
471 stack = cls.initialise(repository, name, msg, switch_to=switch_to)
472 except (BranchException, StackException):
473 branch.delete()
474 raise
475 stack.set_parents(parent_remote, parent_branch)
476 return stack
479 class StackRepository(Repository):
480 """A Git :class:`Repository` with some added StGit-specific operations."""
482 def __init__(self, directory):
483 super().__init__(directory)
484 self._stacks = {} # name -> Stack
486 @property
487 def current_stack(self):
488 return self.get_stack()
490 def get_stack(self, name=None):
491 if not name:
492 name = self.current_branch_name
493 if name not in self._stacks:
494 self._stacks[name] = Stack(self, name)
495 return self._stacks[name]