~bzr-pqm/bzr/bzr.dev

« back to all changes in this revision

Viewing changes to bzrlib/commit.py

  • Committer: John Arbash Meinel
  • Date: 2006-10-16 01:50:48 UTC
  • mfrom: (2078 +trunk)
  • mto: This revision was merged to the branch mainline in revision 2080.
  • Revision ID: john@arbash-meinel.com-20061016015048-0f22df07e38093da
[merge] bzr.dev 2078

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
1
# Copyright (C) 2005, 2006 Canonical Ltd
2
 
 
 
2
#
3
3
# This program is free software; you can redistribute it and/or modify
4
4
# it under the terms of the GNU General Public License as published by
5
5
# the Free Software Foundation; either version 2 of the License, or
16
16
 
17
17
 
18
18
# XXX: Can we do any better about making interrupted commits change
19
 
# nothing?  Perhaps the best approach is to integrate commit of
20
 
# AtomicFiles with releasing the lock on the Branch.
 
19
# nothing?  
21
20
 
22
21
# TODO: Separate 'prepare' phase where we find a list of potentially
23
22
# committed files.  We then can then pause the commit to prompt for a
62
61
 
63
62
# TODO: If commit fails, leave the message in a file somewhere.
64
63
 
 
64
# TODO: Change the parameter 'rev_id' to 'revision_id' to be consistent with
 
65
# the rest of the code; add a deprecation of the old name.
65
66
 
66
67
import os
67
68
import re
68
69
import sys
69
70
import time
70
 
import pdb
71
71
 
72
 
from binascii import hexlify
73
72
from cStringIO import StringIO
74
73
 
75
 
from bzrlib.atomicfile import AtomicFile
76
 
from bzrlib.osutils import (local_time_offset,
77
 
                            rand_bytes, compact_date,
78
 
                            kind_marker, is_inside_any, quotefn,
79
 
                            sha_file, isdir, isfile,
80
 
                            split_lines)
 
74
from bzrlib import (
 
75
    errors,
 
76
    tree,
 
77
    )
81
78
import bzrlib.config
82
 
import bzrlib.errors as errors
83
79
from bzrlib.errors import (BzrError, PointlessCommit,
84
 
                           HistoryMissing,
85
80
                           ConflictsInTree,
86
81
                           StrictCommitFailed
87
82
                           )
88
 
from bzrlib.revision import Revision
 
83
from bzrlib.osutils import (kind_marker, isdir,isfile, is_inside_any, 
 
84
                            is_inside_or_parent_of_any,
 
85
                            quotefn, sha_file, split_lines)
89
86
from bzrlib.testament import Testament
90
87
from bzrlib.trace import mutter, note, warning
91
88
from bzrlib.xml5 import serializer_v5
92
 
from bzrlib.inventory import Inventory, ROOT_ID, InventoryEntry
93
 
from bzrlib.symbol_versioning import *
 
89
from bzrlib.inventory import Inventory, InventoryEntry
 
90
from bzrlib import symbol_versioning
 
91
from bzrlib.symbol_versioning import (deprecated_passed,
 
92
        deprecated_function,
 
93
        DEPRECATED_PARAMETER)
94
94
from bzrlib.workingtree import WorkingTree
95
95
 
96
96
 
97
 
@deprecated_function(zero_seven)
98
 
def commit(*args, **kwargs):
99
 
    """Commit a new revision to a branch.
100
 
 
101
 
    Function-style interface for convenience of old callers.
102
 
 
103
 
    New code should use the Commit class instead.
104
 
    """
105
 
    ## XXX: Remove this in favor of Branch.commit?
106
 
    Commit().commit(*args, **kwargs)
107
 
 
108
 
 
109
97
class NullCommitReporter(object):
110
98
    """I report on progress of a commit."""
111
99
 
138
126
    def snapshot_change(self, change, path):
139
127
        if change == 'unchanged':
140
128
            return
 
129
        if change == 'added' and path == '':
 
130
            return
141
131
        note("%s %s", change, path)
142
132
 
143
133
    def completed(self, revno, rev_id):
225
215
        mutter('preparing to commit')
226
216
 
227
217
        if deprecated_passed(branch):
228
 
            warn("Commit.commit (branch, ...): The branch parameter is "
 
218
            symbol_versioning.warn("Commit.commit (branch, ...): The branch parameter is "
229
219
                 "deprecated as of bzr 0.8. Please use working_tree= instead.",
230
220
                 DeprecationWarning, stacklevel=2)
231
221
            self.branch = branch
238
228
        if message is None:
239
229
            raise BzrError("The message keyword parameter is required for commit().")
240
230
 
241
 
        self.weave_store = self.branch.repository.weave_store
242
231
        self.bound_branch = None
243
232
        self.local = local
244
233
        self.master_branch = None
245
234
        self.master_locked = False
246
 
        self.rev_id = rev_id
 
235
        self.rev_id = None
247
236
        self.specific_files = specific_files
248
237
        self.allow_pointless = allow_pointless
249
 
        self.revprops = {}
250
 
        if revprops is not None:
251
 
            self.revprops.update(revprops)
252
238
 
253
239
        if reporter is None and self.reporter is None:
254
240
            self.reporter = NullCommitReporter()
266
252
            self._check_bound_branch()
267
253
 
268
254
            # check for out of date working trees
269
 
            # if we are bound, then self.branch is the master branch and this
270
 
            # test is thus all we need.
271
 
            if self.work_tree.last_revision() != self.master_branch.last_revision():
 
255
            try:
 
256
                first_tree_parent = self.work_tree.get_parent_ids()[0]
 
257
            except IndexError:
 
258
                # if there are no parents, treat our parent as 'None'
 
259
                # this is so that we still consier the master branch
 
260
                # - in a checkout scenario the tree may have no
 
261
                # parents but the branch may do.
 
262
                first_tree_parent = None
 
263
            master_last = self.master_branch.last_revision()
 
264
            if (master_last is not None and
 
265
                master_last != first_tree_parent):
272
266
                raise errors.OutOfDateTree(self.work_tree)
273
267
    
274
268
            if strict:
275
269
                # raise an exception as soon as we find a single unknown.
276
270
                for unknown in self.work_tree.unknowns():
277
271
                    raise StrictCommitFailed()
278
 
    
279
 
            if timestamp is None:
280
 
                self.timestamp = time.time()
281
 
            else:
282
 
                self.timestamp = long(timestamp)
283
 
                
 
272
                   
284
273
            if self.config is None:
285
 
                self.config = bzrlib.config.BranchConfig(self.branch)
286
 
    
287
 
            if rev_id is None:
288
 
                self.rev_id = _gen_revision_id(self.config, self.timestamp)
289
 
            else:
290
 
                self.rev_id = rev_id
291
 
    
292
 
            if committer is None:
293
 
                self.committer = self.config.username()
294
 
            else:
295
 
                assert isinstance(committer, basestring), type(committer)
296
 
                self.committer = committer
297
 
    
298
 
            if timezone is None:
299
 
                self.timezone = local_time_offset()
300
 
            else:
301
 
                self.timezone = int(timezone)
302
 
    
 
274
                self.config = self.branch.get_config()
 
275
      
303
276
            if isinstance(message, str):
304
277
                message = message.decode(bzrlib.user_encoding)
305
278
            assert isinstance(message, unicode), type(message)
309
282
            self.work_inv = self.work_tree.inventory
310
283
            self.basis_tree = self.work_tree.basis_tree()
311
284
            self.basis_inv = self.basis_tree.inventory
 
285
            if specific_files is not None:
 
286
                # Ensure specified files are versioned
 
287
                # (We don't actually need the ids here)
 
288
                tree.find_ids_across_trees(specific_files, 
 
289
                                           [self.basis_tree, self.work_tree])
312
290
            # one to finish, one for rev and inventory, and one for each
313
291
            # inventory entry, and the same for the new inventory.
314
292
            # note that this estimate is too long when we do a partial tree
315
293
            # commit which excludes some new files from being considered.
316
294
            # The estimate is corrected when we populate the new inv.
317
 
            self.pb_total = len(self.basis_inv) + len(self.work_inv) + 3 - 1
 
295
            self.pb_total = len(self.work_inv) + 5
318
296
            self.pb_count = 0
319
297
 
320
298
            self._gather_parents()
321
299
            if len(self.parents) > 1 and self.specific_files:
322
300
                raise NotImplementedError('selected-file commit of merges is not supported yet: files %r',
323
301
                        self.specific_files)
324
 
            self._check_parents_present()
 
302
            
 
303
            self.builder = self.branch.get_commit_builder(self.parents, 
 
304
                self.config, timestamp, timezone, committer, revprops, rev_id)
325
305
            
326
306
            self._remove_deleted()
327
307
            self._populate_new_inv()
328
 
            self._store_snapshot()
329
308
            self._report_deletes()
330
309
 
331
 
            if not (self.allow_pointless
332
 
                    or len(self.parents) > 1
333
 
                    or self.new_inv != self.basis_inv):
334
 
                raise PointlessCommit()
 
310
            self._check_pointless()
335
311
 
336
312
            self._emit_progress_update()
337
 
            self.inv_sha1 = self.branch.repository.add_inventory(
338
 
                self.rev_id,
339
 
                self.new_inv,
340
 
                self.present_parents
341
 
                )
342
 
            self._emit_progress_update()
343
 
            self._make_revision()
 
313
            # TODO: Now the new inventory is known, check for conflicts and
 
314
            # prompt the user for a commit message.
 
315
            # ADHB 2006-08-08: If this is done, populate_new_inv should not add
 
316
            # weave lines, because nothing should be recorded until it is known
 
317
            # that commit will succeed.
 
318
            self.builder.finish_inventory()
 
319
            self._emit_progress_update()
 
320
            self.rev_id = self.builder.commit(self.message)
 
321
            self._emit_progress_update()
344
322
            # revision data is in the local branch now.
345
323
            
346
324
            # upload revision data to the master.
347
 
            # this will propogate merged revisions too if needed.
 
325
            # this will propagate merged revisions too if needed.
348
326
            if self.bound_branch:
349
327
                self.master_branch.repository.fetch(self.branch.repository,
350
328
                                                    revision_id=self.rev_id)
356
334
            # and now do the commit locally.
357
335
            self.branch.append_revision(self.rev_id)
358
336
 
359
 
            self.work_tree.set_pending_merges([])
360
 
            self.work_tree.set_last_revision(self.rev_id)
 
337
            rev_tree = self.builder.revision_tree()
 
338
            self.work_tree.set_parent_trees([(self.rev_id, rev_tree)])
361
339
            # now the work tree is up to date with the branch
362
340
            
363
341
            self.reporter.completed(self.branch.revno(), self.rev_id)
372
350
            self._emit_progress_update()
373
351
        finally:
374
352
            self._cleanup()
 
353
        return self.rev_id
 
354
 
 
355
    def _any_real_changes(self):
 
356
        """Are there real changes between new_inventory and basis?
 
357
 
 
358
        For trees without rich roots, inv.root.revision changes every commit.
 
359
        But if that is the only change, we want to treat it as though there
 
360
        are *no* changes.
 
361
        """
 
362
        new_entries = self.builder.new_inventory.iter_entries()
 
363
        basis_entries = self.basis_inv.iter_entries()
 
364
        new_path, new_root_ie = new_entries.next()
 
365
        basis_path, basis_root_ie = basis_entries.next()
 
366
 
 
367
        # This is a copy of InventoryEntry.__eq__ only leaving out .revision
 
368
        def ie_equal_no_revision(this, other):
 
369
            return ((this.file_id == other.file_id)
 
370
                    and (this.name == other.name)
 
371
                    and (this.symlink_target == other.symlink_target)
 
372
                    and (this.text_sha1 == other.text_sha1)
 
373
                    and (this.text_size == other.text_size)
 
374
                    and (this.text_id == other.text_id)
 
375
                    and (this.parent_id == other.parent_id)
 
376
                    and (this.kind == other.kind)
 
377
                    and (this.executable == other.executable)
 
378
                    )
 
379
        if not ie_equal_no_revision(new_root_ie, basis_root_ie):
 
380
            return True
 
381
 
 
382
        for new_ie, basis_ie in zip(new_entries, basis_entries):
 
383
            if new_ie != basis_ie:
 
384
                return True
 
385
 
 
386
        # No actual changes present
 
387
        return False
 
388
 
 
389
    def _check_pointless(self):
 
390
        if self.allow_pointless:
 
391
            return
 
392
        # A merge with no effect on files
 
393
        if len(self.parents) > 1:
 
394
            return
 
395
        # work around the fact that a newly-initted tree does differ from its
 
396
        # basis
 
397
        if len(self.basis_inv) == 0 and len(self.builder.new_inventory) == 1:
 
398
            raise PointlessCommit()
 
399
        # Shortcut, if the number of entries changes, then we obviously have
 
400
        # a change
 
401
        if len(self.builder.new_inventory) != len(self.basis_inv):
 
402
            return
 
403
        # If length == 1, then we only have the root entry. Which means
 
404
        # that there is no real difference (only the root could be different)
 
405
        if (len(self.builder.new_inventory) != 1 and self._any_real_changes()):
 
406
            return
 
407
        raise PointlessCommit()
375
408
 
376
409
    def _check_bound_branch(self):
377
410
        """Check to see if the local branch is bound.
414
447
        self.bound_branch = self.branch
415
448
        self.master_branch.lock_write()
416
449
        self.master_locked = True
417
 
####        
418
 
####        # Check to see if we have any pending merges. If we do
419
 
####        # those need to be pushed into the master branch
420
 
####        pending_merges = self.work_tree.pending_merges()
421
 
####        if pending_merges:
422
 
####            for revision_id in pending_merges:
423
 
####                self.master_branch.repository.fetch(self.bound_branch.repository,
424
 
####                                                    revision_id=revision_id)
425
450
 
426
451
    def _cleanup(self):
427
452
        """Cleanup any open locks, progress bars etc."""
439
464
            except Exception, e:
440
465
                found_exception = e
441
466
        if found_exception is not None: 
442
 
            # dont do a plan raise, because the last exception may have been
 
467
            # don't do a plan raise, because the last exception may have been
443
468
            # trashed, e is our sure-to-work exception even though it loses the
444
469
            # full traceback. XXX: RBC 20060421 perhaps we could check the
445
470
            # exc_info and if its the same one do a plain raise otherwise 
478
503
 
479
504
    def _gather_parents(self):
480
505
        """Record the parents of a merge for merge detection."""
481
 
        pending_merges = self.work_tree.pending_merges()
482
 
        self.parents = []
 
506
        # TODO: Make sure that this list doesn't contain duplicate 
 
507
        # entries and the order is preserved when doing this.
 
508
        self.parents = self.work_tree.get_parent_ids()
483
509
        self.parent_invs = []
484
 
        self.present_parents = []
485
 
        precursor_id = self.branch.last_revision()
486
 
        if precursor_id:
487
 
            self.parents.append(precursor_id)
488
 
        self.parents += pending_merges
489
510
        for revision in self.parents:
490
511
            if self.branch.repository.has_revision(revision):
 
512
                mutter('commit parent revision {%s}', revision)
491
513
                inventory = self.branch.repository.get_inventory(revision)
492
514
                self.parent_invs.append(inventory)
493
 
                self.present_parents.append(revision)
494
 
 
495
 
    def _check_parents_present(self):
496
 
        for parent_id in self.parents:
497
 
            mutter('commit parent revision {%s}', parent_id)
498
 
            if not self.branch.repository.has_revision(parent_id):
499
 
                if parent_id == self.branch.last_revision():
500
 
                    warning("parent is missing %r", parent_id)
501
 
                    raise HistoryMissing(self.branch, 'revision', parent_id)
502
 
                else:
503
 
                    mutter("commit will ghost revision %r", parent_id)
504
 
            
505
 
    def _make_revision(self):
506
 
        """Record a new revision object for this commit."""
507
 
        rev = Revision(timestamp=self.timestamp,
508
 
                       timezone=self.timezone,
509
 
                       committer=self.committer,
510
 
                       message=self.message,
511
 
                       inventory_sha1=self.inv_sha1,
512
 
                       revision_id=self.rev_id,
513
 
                       properties=self.revprops)
514
 
        rev.parent_ids = self.parents
515
 
        self.branch.repository.add_revision(self.rev_id, rev, self.new_inv, self.config)
 
515
            else:
 
516
                mutter('commit parent ghost revision {%s}', revision)
516
517
 
517
518
    def _remove_deleted(self):
518
519
        """Remove deleted files from the working inventories.
527
528
        """
528
529
        specific = self.specific_files
529
530
        deleted_ids = []
 
531
        deleted_paths = set()
530
532
        for path, ie in self.work_inv.iter_entries():
 
533
            if is_inside_any(deleted_paths, path):
 
534
                # The tree will delete the required ids recursively.
 
535
                continue
531
536
            if specific and not is_inside_any(specific, path):
532
537
                continue
533
538
            if not self.work_tree.has_filename(path):
 
539
                deleted_paths.add(path)
534
540
                self.reporter.missing(path)
535
 
                deleted_ids.append((path, ie.file_id))
536
 
        if deleted_ids:
537
 
            deleted_ids.sort(reverse=True)
538
 
            for path, file_id in deleted_ids:
539
 
                del self.work_inv[file_id]
540
 
            self.work_tree._write_inventory(self.work_inv)
541
 
 
542
 
    def _store_snapshot(self):
543
 
        """Pass over inventory and record a snapshot.
544
 
 
545
 
        Entries get a new revision when they are modified in 
546
 
        any way, which includes a merge with a new set of
547
 
        parents that have the same entry. 
548
 
        """
549
 
        # XXX: Need to think more here about when the user has
550
 
        # made a specific decision on a particular value -- c.f.
551
 
        # mark-merge.  
552
 
 
553
 
        # iter_entries does not visit the ROOT_ID node so we need to call
554
 
        # self._emit_progress_update once by hand.
555
 
        self._emit_progress_update()
556
 
        for path, ie in self.new_inv.iter_entries():
557
 
            self._emit_progress_update()
558
 
            previous_entries = ie.find_previous_heads(
559
 
                self.parent_invs,
560
 
                self.weave_store,
561
 
                self.branch.repository.get_transaction())
562
 
            if ie.revision is None:
563
 
                # we are creating a new revision for ie in the history store
564
 
                # and inventory.
565
 
                ie.snapshot(self.rev_id, path, previous_entries,
566
 
                    self.work_tree, self.weave_store,
567
 
                    self.branch.repository.get_transaction())
568
 
            # describe the nature of the change that has occured relative to
569
 
            # the basis inventory.
570
 
            if (self.basis_inv.has_id(ie.file_id)):
571
 
                basis_ie = self.basis_inv[ie.file_id]
572
 
            else:
573
 
                basis_ie = None
574
 
            change = ie.describe_change(basis_ie, ie)
575
 
            if change in (InventoryEntry.RENAMED, 
576
 
                InventoryEntry.MODIFIED_AND_RENAMED):
577
 
                old_path = self.basis_inv.id2path(ie.file_id)
578
 
                self.reporter.renamed(change, old_path, path)
579
 
            else:
580
 
                self.reporter.snapshot_change(change, path)
 
541
                deleted_ids.append(ie.file_id)
 
542
        self.work_tree.unversion(deleted_ids)
581
543
 
582
544
    def _populate_new_inv(self):
583
545
        """Build revision inventory.
589
551
        None; inventory entries that are carried over untouched have their
590
552
        revision set to their prior value.
591
553
        """
 
554
        # ESEPARATIONOFCONCERNS: this function is diffing and using the diff
 
555
        # results to create a new inventory at the same time, which results
 
556
        # in bugs like #46635.  Any reason not to use/enhance Tree.changes_from?
 
557
        # ADHB 11-07-2006
592
558
        mutter("Selecting files for commit with filter %s", self.specific_files)
593
 
        self.new_inv = Inventory(revision_id=self.rev_id)
594
 
        # iter_entries does not visit the ROOT_ID node so we need to call
595
 
        # self._emit_progress_update once by hand.
596
 
        self._emit_progress_update()
597
 
        for path, new_ie in self.work_inv.iter_entries():
 
559
        assert self.work_inv.root is not None
 
560
        entries = self.work_inv.iter_entries()
 
561
        if not self.builder.record_root_entry:
 
562
            symbol_versioning.warn('CommitBuilders should support recording'
 
563
                ' the root entry as of bzr 0.10.', DeprecationWarning, 
 
564
                stacklevel=1)
 
565
            self.builder.new_inventory.add(self.basis_inv.root.copy())
 
566
            entries.next()
 
567
            self._emit_progress_update()
 
568
        for path, new_ie in entries:
598
569
            self._emit_progress_update()
599
570
            file_id = new_ie.file_id
600
 
            mutter('check %s {%s}', path, new_ie.file_id)
601
 
            if self.specific_files:
602
 
                if not is_inside_any(self.specific_files, path):
603
 
                    mutter('%s not selected for commit', path)
604
 
                    self._carry_entry(file_id)
 
571
            # mutter('check %s {%s}', path, file_id)
 
572
            if (not self.specific_files or 
 
573
                is_inside_or_parent_of_any(self.specific_files, path)):
 
574
                    # mutter('%s selected for commit', path)
 
575
                    ie = new_ie.copy()
 
576
                    ie.revision = None
 
577
            else:
 
578
                # mutter('%s not selected for commit', path)
 
579
                if self.basis_inv.has_id(file_id):
 
580
                    ie = self.basis_inv[file_id].copy()
 
581
                else:
 
582
                    # this entry is new and not being committed
605
583
                    continue
606
 
                else:
607
 
                    # this is selected, ensure its parents are too.
608
 
                    parent_id = new_ie.parent_id
609
 
                    while parent_id != ROOT_ID:
610
 
                        if not self.new_inv.has_id(parent_id):
611
 
                            ie = self._select_entry(self.work_inv[parent_id])
612
 
                            mutter('%s selected for commit because of %s',
613
 
                                   self.new_inv.id2path(parent_id), path)
614
 
 
615
 
                        ie = self.new_inv[parent_id]
616
 
                        if ie.revision is not None:
617
 
                            ie.revision = None
618
 
                            mutter('%s selected for commit because of %s',
619
 
                                   self.new_inv.id2path(parent_id), path)
620
 
                        parent_id = ie.parent_id
621
 
            mutter('%s selected for commit', path)
622
 
            self._select_entry(new_ie)
 
584
            self.builder.record_entry_contents(ie, self.parent_invs, 
 
585
                path, self.work_tree)
 
586
            # describe the nature of the change that has occurred relative to
 
587
            # the basis inventory.
 
588
            if (self.basis_inv.has_id(ie.file_id)):
 
589
                basis_ie = self.basis_inv[ie.file_id]
 
590
            else:
 
591
                basis_ie = None
 
592
            change = ie.describe_change(basis_ie, ie)
 
593
            if change in (InventoryEntry.RENAMED, 
 
594
                InventoryEntry.MODIFIED_AND_RENAMED):
 
595
                old_path = self.basis_inv.id2path(ie.file_id)
 
596
                self.reporter.renamed(change, old_path, path)
 
597
            else:
 
598
                self.reporter.snapshot_change(change, path)
 
599
 
 
600
        if not self.specific_files:
 
601
            return
 
602
 
 
603
        # ignore removals that don't match filespec
 
604
        for path, new_ie in self.basis_inv.iter_entries():
 
605
            if new_ie.file_id in self.work_inv:
 
606
                continue
 
607
            if is_inside_any(self.specific_files, path):
 
608
                continue
 
609
            ie = new_ie.copy()
 
610
            ie.revision = None
 
611
            self.builder.record_entry_contents(ie, self.parent_invs, path,
 
612
                                               self.basis_tree)
623
613
 
624
614
    def _emit_progress_update(self):
625
615
        """Emit an update to the progress bar."""
626
616
        self.pb.update("Committing", self.pb_count, self.pb_total)
627
617
        self.pb_count += 1
628
618
 
629
 
    def _select_entry(self, new_ie):
630
 
        """Make new_ie be considered for committing."""
631
 
        ie = new_ie.copy()
632
 
        ie.revision = None
633
 
        self.new_inv.add(ie)
634
 
        return ie
635
 
 
636
 
    def _carry_entry(self, file_id):
637
 
        """Carry the file unchanged from the basis revision."""
638
 
        if self.basis_inv.has_id(file_id):
639
 
            self.new_inv.add(self.basis_inv[file_id].copy())
640
 
        else:
641
 
            # this entry is new and not being committed
642
 
            self.pb_total -= 1
643
 
 
644
619
    def _report_deletes(self):
645
620
        for path, ie in self.basis_inv.iter_entries():
646
 
            if ie.file_id not in self.new_inv:
 
621
            if ie.file_id not in self.builder.new_inventory:
647
622
                self.reporter.deleted(path)
648
623
 
649
 
def _gen_revision_id(config, when):
650
 
    """Return new revision-id."""
651
 
    s = '%s-%s-' % (config.user_email(), compact_date(when))
652
 
    s += hexlify(rand_bytes(8))
653
 
    return s
 
624