Patchwork [2,of,2] scmutil: add a cleanupnodes method for developers

login
register
mail settings
Submitter Jun Wu
Date June 25, 2017, 8:32 p.m.
Message ID <c96acc70ab665ea76c41.1498422769@x1c>
Download mbox | patch
Permalink /patch/21714/
State Accepted
Headers show

Comments

Jun Wu - June 25, 2017, 8:32 p.m.
# HG changeset patch
# User Jun Wu <quark@fb.com>
# Date 1498422716 25200
#      Sun Jun 25 13:31:56 2017 -0700
# Node ID c96acc70ab665ea76c4154b793c4071c4c57da1e
# Parent  b70ebbb5b3ffb417d8fe13548b39c9196fadda1f
# Available At https://bitbucket.org/quark-zju/hg-draft
#              hg pull https://bitbucket.org/quark-zju/hg-draft -r c96acc70ab66
scmutil: add a cleanupnodes method for developers

It's now common that an old node gets replaced by zero or more new nodes,
that could happen with amend, rebase, histedit, etc. And it's a common
requirement to do bookmark movements, strip or obsolete nodes and even
moving working copy parent.

Previously, amend, rebase, history have their own logic doing the above.
This patch is an attempt to unify them and future code.

This enables new developers to be able to do "replace X with Y" thing
correctly, without any knowledge about bookmarks, strip or obsstore.

The next step will be migrating rebase to the new API, so it works inside a
transaction, and its code could be simplified.
Augie Fackler - June 26, 2017, 2:53 p.m.
On Sun, Jun 25, 2017 at 01:32:49PM -0700, Jun Wu wrote:
> # HG changeset patch
> # User Jun Wu <quark@fb.com>
> # Date 1498422716 25200
> #      Sun Jun 25 13:31:56 2017 -0700
> # Node ID c96acc70ab665ea76c4154b793c4071c4c57da1e
> # Parent  b70ebbb5b3ffb417d8fe13548b39c9196fadda1f
> # Available At https://bitbucket.org/quark-zju/hg-draft
> #              hg pull https://bitbucket.org/quark-zju/hg-draft -r c96acc70ab66
> scmutil: add a cleanupnodes method for developers

queued, thanks

Very nice direction on this, I look forward to cleaning up lots of things. :)
via Mercurial-devel - June 28, 2017, 5:18 p.m.
On Sun, Jun 25, 2017 at 1:32 PM, Jun Wu <quark@fb.com> wrote:
> # HG changeset patch
> # User Jun Wu <quark@fb.com>
> # Date 1498422716 25200
> #      Sun Jun 25 13:31:56 2017 -0700
> # Node ID c96acc70ab665ea76c4154b793c4071c4c57da1e
> # Parent  b70ebbb5b3ffb417d8fe13548b39c9196fadda1f
> # Available At https://bitbucket.org/quark-zju/hg-draft
> #              hg pull https://bitbucket.org/quark-zju/hg-draft -r c96acc70ab66
> scmutil: add a cleanupnodes method for developers

Thanks! I was thinking of something like this when I was looking at
your "hg split" patch.

I've already accepted the patch so it's in public phase, but I stil
have a few questions below.

> @@ -562,4 +565,66 @@ def origpath(ui, repo, filepath):
>      return fullorigpath + ".orig"
>
> +def cleanupnodes(repo, mapping, operation):
> +    """do common cleanups when old nodes are replaced by new nodes
> +
> +    That includes writing obsmarkers or stripping nodes, and moving bookmarks.
> +    (we might also want to move working directory parent in the future)
> +
> +    mapping is {oldnode: [newnode]} or a iterable of nodes if they do not have
> +    replacements. operation is a string, like "rebase".
> +    """
> +    if not util.safehasattr(mapping, 'items'):
> +        mapping = {n: () for n in mapping}
> +
> +    with repo.transaction('cleanup') as tr:

I wonder if we should pass in the transaction instead to force the
caller to think about the transaction scope, even if they all just
start a new transaction for the cleanup.

> +        # Move bookmarks
> +        bmarks = repo._bookmarks
> +        bmarkchanged = False
> +        for oldnode, newnodes in mapping.items():
> +            oldbmarks = repo.nodebookmarks(oldnode)
> +            if not oldbmarks:
> +                continue
> +            bmarkchanged = True
> +            if len(newnodes) > 1:
> +                heads = list(repo.set('heads(%ln)', newnodes))
> +                if len(heads) != 1:
> +                    raise error.ProgrammingError(
> +                        'cannot figure out bookmark movement')
> +                newnode = heads[0].node()
> +            elif len(newnodes) == 0:
> +                # move bookmark backwards
> +                roots = list(repo.set('max((::%n) - %ln)', oldnode,
> +                                      list(mapping)))

If we're pruning a merge, this puts the bookmark on an "arbitrary"
parent. I see that that's how it already works and I suppose you just
preserved that behavior. Should we simply do the same for the
divergence case above? The ProgrammingError is a little unfortunate,
because the only way to prevent it is by not calling this method,
AFAICT (sure, you can handle that specific bookmark outside, but then
you're not saving that much). So maybe just use max() for the
divergence too?

> +                if roots:
> +                    newnode = roots[0].node()
> +                else:
> +                    newnode = nullid
> +            else:
> +                newnode = newnodes[0]
> +            repo.ui.debug('moving bookmarks %r from %s to %s\n' %
> +                          (oldbmarks, hex(oldnode), hex(newnode)))
> +            for name in oldbmarks:
> +                bmarks[name] = newnode
> +        if bmarkchanged:
> +            bmarks.recordchange(tr)
> +
> +        # Obsolete or strip nodes
> +        if obsolete.isenabled(repo, obsolete.createmarkersopt):
> +            # If a node is already obsoleted, and we want to obsolete it
> +            # without a successor, skip that obssolete request since it's
> +            # unnecessary. That's the "if s or not isobs(n)" check below.
> +            # Also sort the node in topology order, that might be useful for
> +            # some obsstore logic.
> +            # NOTE: the filtering and sorting might belong to createmarkers.
> +            isobs = repo.obsstore.successors.__contains__
> +            sortfunc = lambda ns: repo.changelog.rev(ns[0])
> +            rels = [(repo[n], (repo[m] for m in s))
> +                    for n, s in sorted(mapping.items(), key=sortfunc)
> +                    if s or not isobs(n)]

Nit: We could probably make that code a bit clearer so the comment
above about "if s or not isobs(n)" is not needed. Maybe something like
this:

            rels = []
            for node, successors in sorted(mapping.items(), key=sortfunc):
                ctx = unfi[node]
                if not successors and ctx.obsolete():
                    continue # already done
                rels.append((ctx, (unfi[s] for s in successors)))


> +            obsolete.createmarkers(repo, rels, operation=operation)
> +        else:
> +            from . import repair # avoid import cycle
> +            repair.delayedstrip(repo.ui, repo, list(mapping), operation)
> +
>  def addremove(repo, matcher, prefix, opts=None, dry_run=None, similarity=None):
>      if opts is None:
> diff --git a/tests/test-strip.t b/tests/test-strip.t
> --- a/tests/test-strip.t
> +++ b/tests/test-strip.t
> @@ -956,4 +956,5 @@ Use delayedstrip to strip inside a trans
>    >   A   Z
>    > EOS
> +  $ cp -R . ../scmutilcleanup
>
>    $ hg up -C I
> @@ -991,2 +992,103 @@ Use delayedstrip to strip inside a trans
>    o  0:426bada5c675 A
>
> +Test high-level scmutil.cleanupnodes API
> +
> +  $ cd $TESTTMP/scmutilcleanup
> +  $ hg debugdrawdag <<'EOS'
> +  >   D2  F2  G2   # D2, F2, G2 are replacements for D, F, G
> +  >   |   |   |
> +  >   C   H   G
> +  > EOS
> +  $ for i in B C D F G I Z; do
> +  >     hg bookmark -i -r $i b-$i
> +  > done
> +  $ cp -R . ../scmutilcleanup.obsstore
> +
> +  $ cat > $TESTTMP/scmutilcleanup.py <<EOF
> +  > from mercurial import scmutil
> +  > def reposetup(ui, repo):
> +  >     def nodes(expr):
> +  >         return [repo.changelog.node(r) for r in repo.revs(expr)]
> +  >     def node(expr):
> +  >         return nodes(expr)[0]
> +  >     with repo.wlock():
> +  >         with repo.lock():
> +  >             with repo.transaction('delayedstrip'):

Same comment as on another patch: we're on Python 2.7, so can combine
these with-blocks.

> +  >                 mapping = {node('F'): [node('F2')],
> +  >                            node('D'): [node('D2')],
> +  >                            node('G'): [node('G2')]}
> +  >                 scmutil.cleanupnodes(repo, mapping, 'replace')
> +  >                 scmutil.cleanupnodes(repo, nodes('((B::)+I+Z)-D2'), 'replace')
> +  > EOF
> +  $ hg log -r . -T '\n' --config extensions.t=$TESTTMP/scmutilcleanup.py
> +  warning: orphaned descendants detected, not stripping 112478962961, 1fc8102cda62, 26805aba1e60
> +  saved backup bundle to $TESTTMP/scmutilcleanup/.hg/strip-backup/f585351a92f8-73fb7c03-replace.hg (glob)
> +
> +  $ hg log -G -T '{rev}:{node|short} {desc} {bookmarks}' -r 'sort(all(), topo)'
> +  o  8:1473d4b996d1 G2 b-G
> +  |
> +  | o  7:d94e89b773b6 F2 b-F
> +  | |
> +  | o  5:7fe5bac4c918 H
> +  |/|
> +  | o  3:7fb047a69f22 E
> +  | |
> +  | | o  6:7c78f703e465 D2 b-D
> +  | | |
> +  | | o  4:26805aba1e60 C
> +  | | |
> +  | | o  2:112478962961 B
> +  | |/
> +  o |  1:1fc8102cda62 G
> +   /
> +  o  0:426bada5c675 A b-B b-C b-I
> +
> +  $ hg bookmark
> +     b-B                       0:426bada5c675
> +     b-C                       0:426bada5c675
> +     b-D                       6:7c78f703e465
> +     b-F                       7:d94e89b773b6
> +     b-G                       8:1473d4b996d1
> +     b-I                       0:426bada5c675
> +     b-Z                       -1:000000000000
> +
> +Test the above using obsstore "by the way". Not directly related to strip, but
> +we have reusable code here
> +
> +  $ cd $TESTTMP/scmutilcleanup.obsstore
> +  $ cat >> .hg/hgrc <<EOF
> +  > [experimental]
> +  > evolution=all
> +  > evolution.track-operation=1
> +  > EOF
> +
> +  $ hg log -r . -T '\n' --config extensions.t=$TESTTMP/scmutilcleanup.py
> +
> +  $ rm .hg/localtags
> +  $ hg log -G -T '{rev}:{node|short} {desc} {bookmarks}' -r 'sort(all(), topo)'
> +  o  12:1473d4b996d1 G2 b-G
> +  |
> +  | o  11:d94e89b773b6 F2 b-F
> +  | |
> +  | o  8:7fe5bac4c918 H
> +  |/|
> +  | o  4:7fb047a69f22 E
> +  | |
> +  | | o  10:7c78f703e465 D2 b-D
> +  | | |
> +  | | x  6:26805aba1e60 C
> +  | | |
> +  | | x  3:112478962961 B
> +  | |/
> +  x |  1:1fc8102cda62 G
> +   /
> +  o  0:426bada5c675 A b-B b-C b-I
> +
> +  $ hg debugobsolete
> +  1fc8102cda6204549f031015641606ccf5513ec3 1473d4b996d1d1b121de6b39fab6a04fbf9d873e 0 (Thu Jan 01 00:00:00 1970 +0000) {'operation': 'replace', 'user': 'test'}
> +  64a8289d249234b9886244d379f15e6b650b28e3 d94e89b773b67e72642a931159ada8d1a9246998 0 (Thu Jan 01 00:00:00 1970 +0000) {'operation': 'replace', 'user': 'test'}
> +  f585351a92f85104bff7c284233c338b10eb1df7 7c78f703e465d73102cc8780667ce269c5208a40 0 (Thu Jan 01 00:00:00 1970 +0000) {'operation': 'replace', 'user': 'test'}
> +  48b9aae0607f43ff110d84e6883c151942add5ab 0 {0000000000000000000000000000000000000000} (Thu Jan 01 00:00:00 1970 +0000) {'operation': 'replace', 'user': 'test'}
> +  112478962961147124edd43549aedd1a335e44bf 0 {426bada5c67598ca65036d57d9e4b64b0c1ce7a0} (Thu Jan 01 00:00:00 1970 +0000) {'operation': 'replace', 'user': 'test'}
> +  08ebfeb61bac6e3f12079de774d285a0d6689eba 0 {426bada5c67598ca65036d57d9e4b64b0c1ce7a0} (Thu Jan 01 00:00:00 1970 +0000) {'operation': 'replace', 'user': 'test'}
> +  26805aba1e600a82e93661149f2313866a221a7b 0 {112478962961147124edd43549aedd1a335e44bf} (Thu Jan 01 00:00:00 1970 +0000) {'operation': 'replace', 'user': 'test'}
> _______________________________________________
> Mercurial-devel mailing list
> Mercurial-devel@mercurial-scm.org
> https://www.mercurial-scm.org/mailman/listinfo/mercurial-devel
Jun Wu - June 28, 2017, 6:08 p.m.
Excerpts from Martin von Zweigbergk's message of 2017-06-28 10:18:22 -0700:
> I wonder if we should pass in the transaction instead to force the
> caller to think about the transaction scope, even if they all just
> start a new transaction for the cleanup.

Good find. Durham also suggested to remove "operation". I have planned to
add the "desc" information to transaction and use that as operation here.
This function will then require an active transaction, which looks good.

> If we're pruning a merge, this puts the bookmark on an "arbitrary"
> parent. I see that that's how it already works and I suppose you just
> preserved that behavior. Should we simply do the same for the
> divergence case above?  The ProgrammingError is a little unfortunate,
> because the only way to prevent it is by not calling this method, AFAICT
> (sure, you can handle that specific bookmark outside, but then you're not
> saving that much). So maybe just use max() for the divergence too?

The current ProgrammingError only happens when split a commit to something
with multiple roots. Like split A to B and C, and ancestor(B+C) is not in
[B, C]. I think that's a wrong split implementation and therefore raised
ProgrammingError.  Normally you won't be able to get into that situation.

If we don't care about that corner case, I think "max()" makes sense to
simplify the code and user experience.

> Nit: We could probably make that code a bit clearer so the comment
> above about "if s or not isobs(n)" is not needed. Maybe something like
> this:
> 
>             rels = []
>             for node, successors in sorted(mapping.items(), key=sortfunc):
>                 ctx = unfi[node]
>                 if not successors and ctx.obsolete():
>                     continue # already done
>                 rels.append((ctx, (unfi[s] for s in successors)))

Sure. I was just wanting to make the corner case clear: amend A to B, then
prune A. The latter does provide some extra information - intention to
remove A and is in theory a "divergent" from the intention to "amend" A.
That "divergent" is causing unwanted trouble for users since a user will
almost always choose to keep B instead of remove both.

I'll clean up the code and put the explanation in commit message.

> Same comment as on another patch: we're on Python 2.7, so can combine
> these with-blocks.

I actually didn't know that syntax until yesterday :)
via Mercurial-devel - June 28, 2017, 6:22 p.m.
On Wed, Jun 28, 2017 at 11:08 AM, Jun Wu <quark@fb.com> wrote:
> Excerpts from Martin von Zweigbergk's message of 2017-06-28 10:18:22 -0700:
>
>> If we're pruning a merge, this puts the bookmark on an "arbitrary"
>> parent. I see that that's how it already works and I suppose you just
>> preserved that behavior. Should we simply do the same for the
>> divergence case above?  The ProgrammingError is a little unfortunate,
>> because the only way to prevent it is by not calling this method, AFAICT
>> (sure, you can handle that specific bookmark outside, but then you're not
>> saving that much). So maybe just use max() for the divergence too?
>
> The current ProgrammingError only happens when split a commit to something
> with multiple roots. Like split A to B and C, and ancestor(B+C) is not in
> [B, C]. I think that's a wrong split implementation and therefore raised
> ProgrammingError.  Normally you won't be able to get into that situation.

For the regular "hg split", I agree that's wrong. But one could
imagine a "hg splitindependent" that takes a commit and splits it up
into two that can be sent for review independently.

>
> If we don't care about that corner case, I think "max()" makes sense to
> simplify the code and user experience.

I think that makes sense. I like the symmetry that gives with pruning of merges.

One could of course consider adding a flag "hg strip
--move-bookmark-to-parent={1,2}" (and similar for my hypothetical
command above) or asking interactively (probably better), but that
also seems awkward. I'd say we can continue to leave the bookmark on
an arbitrary node until someone complains about it.

Patch

diff --git a/mercurial/scmutil.py b/mercurial/scmutil.py
--- a/mercurial/scmutil.py
+++ b/mercurial/scmutil.py
@@ -17,4 +17,6 @@  import socket
 from .i18n import _
 from .node import (
+    hex,
+    nullid,
     wdirid,
     wdirrev,
@@ -25,4 +27,5 @@  from . import (
     error,
     match as matchmod,
+    obsolete,
     pathutil,
     phases,
@@ -562,4 +565,66 @@  def origpath(ui, repo, filepath):
     return fullorigpath + ".orig"
 
+def cleanupnodes(repo, mapping, operation):
+    """do common cleanups when old nodes are replaced by new nodes
+
+    That includes writing obsmarkers or stripping nodes, and moving bookmarks.
+    (we might also want to move working directory parent in the future)
+
+    mapping is {oldnode: [newnode]} or a iterable of nodes if they do not have
+    replacements. operation is a string, like "rebase".
+    """
+    if not util.safehasattr(mapping, 'items'):
+        mapping = {n: () for n in mapping}
+
+    with repo.transaction('cleanup') as tr:
+        # Move bookmarks
+        bmarks = repo._bookmarks
+        bmarkchanged = False
+        for oldnode, newnodes in mapping.items():
+            oldbmarks = repo.nodebookmarks(oldnode)
+            if not oldbmarks:
+                continue
+            bmarkchanged = True
+            if len(newnodes) > 1:
+                heads = list(repo.set('heads(%ln)', newnodes))
+                if len(heads) != 1:
+                    raise error.ProgrammingError(
+                        'cannot figure out bookmark movement')
+                newnode = heads[0].node()
+            elif len(newnodes) == 0:
+                # move bookmark backwards
+                roots = list(repo.set('max((::%n) - %ln)', oldnode,
+                                      list(mapping)))
+                if roots:
+                    newnode = roots[0].node()
+                else:
+                    newnode = nullid
+            else:
+                newnode = newnodes[0]
+            repo.ui.debug('moving bookmarks %r from %s to %s\n' %
+                          (oldbmarks, hex(oldnode), hex(newnode)))
+            for name in oldbmarks:
+                bmarks[name] = newnode
+        if bmarkchanged:
+            bmarks.recordchange(tr)
+
+        # Obsolete or strip nodes
+        if obsolete.isenabled(repo, obsolete.createmarkersopt):
+            # If a node is already obsoleted, and we want to obsolete it
+            # without a successor, skip that obssolete request since it's
+            # unnecessary. That's the "if s or not isobs(n)" check below.
+            # Also sort the node in topology order, that might be useful for
+            # some obsstore logic.
+            # NOTE: the filtering and sorting might belong to createmarkers.
+            isobs = repo.obsstore.successors.__contains__
+            sortfunc = lambda ns: repo.changelog.rev(ns[0])
+            rels = [(repo[n], (repo[m] for m in s))
+                    for n, s in sorted(mapping.items(), key=sortfunc)
+                    if s or not isobs(n)]
+            obsolete.createmarkers(repo, rels, operation=operation)
+        else:
+            from . import repair # avoid import cycle
+            repair.delayedstrip(repo.ui, repo, list(mapping), operation)
+
 def addremove(repo, matcher, prefix, opts=None, dry_run=None, similarity=None):
     if opts is None:
diff --git a/tests/test-strip.t b/tests/test-strip.t
--- a/tests/test-strip.t
+++ b/tests/test-strip.t
@@ -956,4 +956,5 @@  Use delayedstrip to strip inside a trans
   >   A   Z
   > EOS
+  $ cp -R . ../scmutilcleanup
 
   $ hg up -C I
@@ -991,2 +992,103 @@  Use delayedstrip to strip inside a trans
   o  0:426bada5c675 A
   
+Test high-level scmutil.cleanupnodes API
+
+  $ cd $TESTTMP/scmutilcleanup
+  $ hg debugdrawdag <<'EOS'
+  >   D2  F2  G2   # D2, F2, G2 are replacements for D, F, G
+  >   |   |   |
+  >   C   H   G
+  > EOS
+  $ for i in B C D F G I Z; do
+  >     hg bookmark -i -r $i b-$i
+  > done
+  $ cp -R . ../scmutilcleanup.obsstore
+
+  $ cat > $TESTTMP/scmutilcleanup.py <<EOF
+  > from mercurial import scmutil
+  > def reposetup(ui, repo):
+  >     def nodes(expr):
+  >         return [repo.changelog.node(r) for r in repo.revs(expr)]
+  >     def node(expr):
+  >         return nodes(expr)[0]
+  >     with repo.wlock():
+  >         with repo.lock():
+  >             with repo.transaction('delayedstrip'):
+  >                 mapping = {node('F'): [node('F2')],
+  >                            node('D'): [node('D2')],
+  >                            node('G'): [node('G2')]}
+  >                 scmutil.cleanupnodes(repo, mapping, 'replace')
+  >                 scmutil.cleanupnodes(repo, nodes('((B::)+I+Z)-D2'), 'replace')
+  > EOF
+  $ hg log -r . -T '\n' --config extensions.t=$TESTTMP/scmutilcleanup.py
+  warning: orphaned descendants detected, not stripping 112478962961, 1fc8102cda62, 26805aba1e60
+  saved backup bundle to $TESTTMP/scmutilcleanup/.hg/strip-backup/f585351a92f8-73fb7c03-replace.hg (glob)
+  
+  $ hg log -G -T '{rev}:{node|short} {desc} {bookmarks}' -r 'sort(all(), topo)'
+  o  8:1473d4b996d1 G2 b-G
+  |
+  | o  7:d94e89b773b6 F2 b-F
+  | |
+  | o  5:7fe5bac4c918 H
+  |/|
+  | o  3:7fb047a69f22 E
+  | |
+  | | o  6:7c78f703e465 D2 b-D
+  | | |
+  | | o  4:26805aba1e60 C
+  | | |
+  | | o  2:112478962961 B
+  | |/
+  o |  1:1fc8102cda62 G
+   /
+  o  0:426bada5c675 A b-B b-C b-I
+  
+  $ hg bookmark
+     b-B                       0:426bada5c675
+     b-C                       0:426bada5c675
+     b-D                       6:7c78f703e465
+     b-F                       7:d94e89b773b6
+     b-G                       8:1473d4b996d1
+     b-I                       0:426bada5c675
+     b-Z                       -1:000000000000
+
+Test the above using obsstore "by the way". Not directly related to strip, but
+we have reusable code here
+
+  $ cd $TESTTMP/scmutilcleanup.obsstore
+  $ cat >> .hg/hgrc <<EOF
+  > [experimental]
+  > evolution=all
+  > evolution.track-operation=1
+  > EOF
+
+  $ hg log -r . -T '\n' --config extensions.t=$TESTTMP/scmutilcleanup.py
+  
+  $ rm .hg/localtags
+  $ hg log -G -T '{rev}:{node|short} {desc} {bookmarks}' -r 'sort(all(), topo)'
+  o  12:1473d4b996d1 G2 b-G
+  |
+  | o  11:d94e89b773b6 F2 b-F
+  | |
+  | o  8:7fe5bac4c918 H
+  |/|
+  | o  4:7fb047a69f22 E
+  | |
+  | | o  10:7c78f703e465 D2 b-D
+  | | |
+  | | x  6:26805aba1e60 C
+  | | |
+  | | x  3:112478962961 B
+  | |/
+  x |  1:1fc8102cda62 G
+   /
+  o  0:426bada5c675 A b-B b-C b-I
+  
+  $ hg debugobsolete
+  1fc8102cda6204549f031015641606ccf5513ec3 1473d4b996d1d1b121de6b39fab6a04fbf9d873e 0 (Thu Jan 01 00:00:00 1970 +0000) {'operation': 'replace', 'user': 'test'}
+  64a8289d249234b9886244d379f15e6b650b28e3 d94e89b773b67e72642a931159ada8d1a9246998 0 (Thu Jan 01 00:00:00 1970 +0000) {'operation': 'replace', 'user': 'test'}
+  f585351a92f85104bff7c284233c338b10eb1df7 7c78f703e465d73102cc8780667ce269c5208a40 0 (Thu Jan 01 00:00:00 1970 +0000) {'operation': 'replace', 'user': 'test'}
+  48b9aae0607f43ff110d84e6883c151942add5ab 0 {0000000000000000000000000000000000000000} (Thu Jan 01 00:00:00 1970 +0000) {'operation': 'replace', 'user': 'test'}
+  112478962961147124edd43549aedd1a335e44bf 0 {426bada5c67598ca65036d57d9e4b64b0c1ce7a0} (Thu Jan 01 00:00:00 1970 +0000) {'operation': 'replace', 'user': 'test'}
+  08ebfeb61bac6e3f12079de774d285a0d6689eba 0 {426bada5c67598ca65036d57d9e4b64b0c1ce7a0} (Thu Jan 01 00:00:00 1970 +0000) {'operation': 'replace', 'user': 'test'}
+  26805aba1e600a82e93661149f2313866a221a7b 0 {112478962961147124edd43549aedd1a335e44bf} (Thu Jan 01 00:00:00 1970 +0000) {'operation': 'replace', 'user': 'test'}