# BEGIN BPS TAGGED BLOCK {{{
# COPYRIGHT:
#
# This software is Copyright (c) 2003-2006 Best Practical Solutions, LLC
# <clkao@bestpractical.com>
#
# (Except where explicitly superseded by other copyright notices)
#
#
# LICENSE:
#
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of either:
#
# a) Version 2 of the GNU General Public License. You should have
# received a copy of the GNU General Public License along with this
# program. If not, write to the Free Software Foundation, Inc., 51
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 or visit
# their web page on the internet at
# http://www.gnu.org/copyleft/gpl.html.
#
# b) Version 1 of Perl's "Artistic License". You should have received
# a copy of the Artistic License with this package, in the file
# named "ARTISTIC". The license is also available at
# http://opensource.org/licenses/artistic-license.php.
#
# This work is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# General Public License for more details.
#
# CONTRIBUTION SUBMISSION POLICY:
#
# (The following paragraph is not intended to limit the rights granted
# to you to modify and distribute this software under the terms of the
# GNU General Public License and is only of importance to you if you
# choose to contribute your changes and enhancements to the community
# by submitting them to Best Practical Solutions, LLC.)
#
# By intentionally submitting any modifications, corrections or
# derivatives to this work, or any other work intended for use with SVK,
# to Best Practical Solutions, LLC, you confirm that you are the
# copyright holder for those contributions and you grant Best Practical
# Solutions, LLC a nonexclusive, worldwide, irrevocable, royalty-free,
# perpetual, license to use, copy, create derivative works based on
# those contributions, and sublicense and distribute those contributions
# and any derivatives thereof.
#
# END BPS TAGGED BLOCK }}}
package SVK::Merge;
use strict;
use SVK::Util qw(traverse_history is_path_inside);
use SVK::I18N;
use SVK::Editor::Merge;
use SVK::Editor::Rename;
use SVK::Editor::Translate;
use SVK::Editor::Delay;
use SVK::Logger;
use List::Util qw(min);
=head1 NAME
SVK::Merge - Merge context class
=head1 SYNOPSIS
use SVK::Merge;
SVK::Merge->auto (repos => $repos, src => $src, dst => $dst)->run ($editor, %cb);
=head1 DESCRIPTION
The C<SVK::Merge> class is for representing merge contexts, mainly
including what delta is used for this merge, and what target the delta
applies to.
Given the 3 L<SVK::Path> objects:
=over
=item src
=item dst
=item base
=back
C<SVK::Merge> will be applying I<delta> (C<base>, C<src>) to C<dst>.
=head1 CONSTRUCTORS
=head2 new
Takes parameters the usual way.
=head2 auto
Like new, but the C<base> object will be found automatically as the
nearest ancestor of C<src> and C<dst>.
=head1 METHODS
=over
=cut
sub new {
my ($class, @arg) = @_;
my $self = bless {}, $class;
%$self = @arg;
return $self;
}
sub auto {
my $self = new (@_);
@{$self}{qw/base fromrev/} = $self->find_merge_base(@{$self}{qw/src dst/});
$self->_rebase;
return $self;
}
sub _rebase {
my $self = shift;
return unless $self->{base}->path eq $self->{dst}->path;
$self->{src}->is_merged_from($self->{base})
or return;
my $dst = $self->{src}->prev or return;
$dst->root->check_path($dst->path) or return;
# If the previous source hasn't been merged, use the original base
# logic. Otherwise we are merging changes between the alleged
# merge and actual revision.
$self->{dst}->is_merged_from($dst) or return;
require SVK::Path::Txn;
$dst = $dst->clone;
bless $dst, 'SVK::Path::Txn'; # XXX: need a saner api for this
my $xmerge = SVK::Merge->auto(%$self, quiet => 1,
src => $self->{base},
dst => $dst);
my ($editor, $inspector, %cb) = $xmerge->{dst}->get_editor();
local $ENV{SVKRESOLVE} = 's';
unless ($xmerge->run( $editor, inspector => $inspector, %cb )) {
# XXX why isn't the txnroot uptodate??
$self->{base} = $xmerge->{dst};
$self->{base}->inspector->root($self->{base}->txn->root($self->{base}->pool));
}
}
# DEPRECATED
sub _is_merge_from {
my ($self, $path, $target, $rev) = @_;
my $fs = $self->{repos}->fs;
my $u = $target->universal;
my $resource = join (':', $u->{uuid}, $u->{path});
local $@;
Carp::cluck unless defined $rev;
my ($merge, $pmerge) =
map {SVK::Merge::Info->new (eval { $fs->revision_root ($_)->node_prop
($path, 'svk:merge') })->{$resource}{rev} || 0}
($rev, $rev-1);
return ($merge != $pmerge) ? $merge : 0;
}
sub _next_is_merge {
my ($self, $repos, $path, $rev, $checkfrom) = @_;
return if $rev == $checkfrom;
my $fs = $repos->fs;
my $nextrev;
(traverse_history (
root => $fs->revision_root ($checkfrom),
path => $path,
cross => 0,
callback => sub {
return 0 if ($_[1] == $rev); # last
$nextrev = $_[1];
return 1;
}
) == 0) or return;
return unless $nextrev;
my ($merge, $pmerge) =
map {$fs->revision_root ($_)->node_prop ($path, 'svk:merge') || ''}
($nextrev, $rev);
return if $merge eq $pmerge;
return ($nextrev, $merge);
}
sub find_merge_base {
my ($self, $src, $dst) = @_;
my $repos = $self->{repos};
my $fs = $repos->fs;
my $yrev = $fs->youngest_rev;
my ($srcinfo, $dstinfo) = map {$self->find_merge_sources ($_)} ($src, $dst);
my ($basepath, $baserev, $baseentry);
my ($merge_base, $merge_baserev) = $self->{merge_base} ?
split(/:/, $self->{merge_base}) : ('', undef);
($merge_base, $merge_baserev) = (undef, $merge_base)
if $merge_base =~ /^\d+$/;
return ($src->as_depotpath->new
(path => $merge_base, revision => $merge_baserev, targets => undef),
$merge_baserev)
if $merge_base && $merge_baserev;
if ($merge_base) {
my %allowed = map { ($_ =~ /:(.*)$/) => $_ }
grep exists $srcinfo->{$_} && exists $dstinfo->{$_},
keys %{ { %$srcinfo, %$dstinfo } };
unless ($allowed{$merge_base}) {
die loc("base '%1' is not allowed without revision specification.\nUse one of the next or provide revision:%2\n",
$merge_base, (join '', map "\n $_", sort keys %allowed) );
}
my $rev = min ($srcinfo->{$allowed{$merge_base}}, $dstinfo->{$allowed{$merge_base}});
return ($src->as_depotpath->new
(path => $merge_base, revision => $rev, targets => undef),
$rev);
}
for (grep {exists $srcinfo->{$_} && exists $dstinfo->{$_}}
(sort keys %{ { %$srcinfo, %$dstinfo } })) {
my ($path) = m/:(.*)$/;
my $rev = min ($srcinfo->{$_}, $dstinfo->{$_});
# when the base is one of src or dst, make sure the base is
# still the same node (not removed and replaced)
if ($rev && $path eq $dst->path) {
next unless $dst->related_to($dst->as_depotpath->seek_to($rev));
}
if ($rev && $path eq $src->path) {
next unless $src->related_to($src->as_depotpath->seek_to($rev));
}
if ($path eq $dst->path &&
(my $src_base = $src->is_merged_from($src->mclone(path => $path, revision => $rev)))) {
($basepath, $baserev, $baseentry) = ($path, $rev, $_);
last;
}
($basepath, $baserev, $baseentry) = ($path, $rev, $_)
if !$basepath || $fs->revision_prop($rev, 'svn:date') gt $fs->revision_prop($baserev, 'svn:date');
}
return ($src->new (revision => $merge_baserev), $merge_baserev)
if $merge_baserev;
unless ($basepath) {
return ($src->new (path => '/', revision => 0), 0)
if $self->{baseless};
die loc("Can't find merge base for %1 and %2\n", $src->path, $dst->path);
}
# XXX: document this, cf t/07smerge-foreign.t
if ($basepath ne $src->path && $basepath ne $dst->path) {
my ($fromrev, $torev) = ($srcinfo->{$baseentry}, $dstinfo->{$baseentry});
($fromrev, $torev) = ($torev, $fromrev) if $torev < $fromrev;
if (my ($mrev, $merge) =
$self->_next_is_merge ($repos, $basepath, $fromrev, $torev)) {
my $minfo = SVK::Merge::Info->new ($merge);
my $root = $fs->revision_root ($yrev);
my ($srcinfo, $dstinfo) = map { SVK::Merge::Info->new ($root->node_prop ($_->path, 'svk:merge')) }
($src, $dst);
$baserev = $mrev
if $minfo->subset_of ($srcinfo) && $minfo->subset_of ($dstinfo);
}
}
my $base = $src->as_depotpath->new
(path => $basepath, revision => $baserev, targets => undef);
$base->anchorify if exists $src->{targets}[0];
$base->{path} = '/' if $base->revision == 0;
# When /A:1 is copied to /B:2, then removed, /B:2 copied to /A:5
# the fromrev shouldn't be /A:1, as it confuses the copy detection during merge.
my $from = $dstinfo->{$fs->get_uuid.':'.$src->path};
if ($from) {
my ($toroot, $fromroot) = $src->nearest_copy;
$from = 0 if $toroot && $from < $toroot->revision_root_revision;
}
return ($base, $from || ($basepath eq $src->path ? $baserev : 0));
}
sub merge_info {
my ($self, $target) = @_;
my $tgt = $target->path_target;
return SVK::Merge::Info->new
( $target->inspector->localprop($tgt, 'svk:merge') );
}
sub merge_info_with_copy {
my ($self, $target) = @_;
my $minfo = $self->merge_info($target);
for ($self->copy_ancestors($target)) {
my $srckey = join(':', $_->{uuid}, $_->{path});
$minfo->{$srckey} = $_
unless $minfo->{$srckey} && $minfo->{$srckey} > $_->{rev};
}
return $minfo;
}
sub copy_ancestors {
my ($self, $target) = @_;
$target = $target->as_depotpath;
return map { $target->new
( path => $_->[0],
targets => undef,
revision => $_->[1])->universal;
} $target->copy_ancestors;
}
sub find_merge_sources {
my ($self, $target, $verbatim, $noself) = @_;
my $pool = SVN::Pool->new_default;
my $info = $self->merge_info ($target->new);
$target = $target->new->as_depotpath ($self->{xd}{checkout}->get ($target->copath. 1)->{revision})
if $target->isa('SVK::Path::Checkout');
$info->add_target ($target, $self->{xd}) unless $noself;
return $info->verbatim if $verbatim || !$target->root->check_path($target->path);
my $minfo = $info->resolve($target->depot);
my $myuuid = $target->repos->fs->get_uuid ();
for (reverse $target->copy_ancestors) {
my ($path, $rev) = @$_;
my $entry = "$myuuid:$path";
$minfo->{$entry} = $rev
unless $minfo->{$entry} && $minfo->{$entry} > $rev;
}
return $minfo;
}
sub get_new_ticket {
my ($self, $srcinfo) = @_;
my $dstinfo = $self->merge_info ($self->{dst});
# We want the ticket representing src, but not dst.
my $newinfo = $dstinfo->union ($srcinfo)->del_target ($self->{dst});
unless ($self->{quiet}) {
for (sort keys %$newinfo) {
$logger->info(loc("New merge ticket: %1:%2", $_, $newinfo->{$_}{rev}))
if !$dstinfo->{$_} || $newinfo->{$_}{rev} > $dstinfo->{$_}{rev};
}
}
return $newinfo->as_string;
}
sub log {
my ($self, $no_separator) = @_;
open my $buf, '>', \ (my $tmp = '');
no warnings 'uninitialized';
require Sys::Hostname;
my $get_remoterev = SVK::Command::Log::_log_remote_rev(
$self->{src},
$self->{remoterev}
);
my $host = $self->{host} || (split ('\.', Sys::Hostname::hostname(), 2))[0];
require SVK::Log::FilterPipeline;
my $pipeline = SVK::Log::FilterPipeline->new(
presentation => 'std',
output => $buf,
indent => 1,
remote_only => $self->{remoterev},
host => $host,
get_remoterev => $get_remoterev,
no_sep => $no_separator,
verbatim => $self->{verbatim} ? 1 : 0,
quiet => 0,
suppress => sub {
$self->_is_merge_from ($self->{src}->path, $self->{dst}, $_[0])
},
);
SVK::Command::Log::do_log(
repos => $self->{repos},
path => $self->{src}->path,
fromrev => $self->{fromrev} + 1,
torev => $self->{src}->revision,
pipeline => $pipeline,
);
return $tmp;
}
=item info
Return a string about how the merge is done.
=cut
sub info {
my $self = shift;
return loc("Auto-merging (%1, %2) %3 to %4 (base %5%6:%7).\n",
$self->{fromrev}, $self->{src}->revision, $self->{src}->path,
$self->{dst}->path,
$self->{base}->isa('SVK::Path::Txn') ? '*' : '',
$self->{base}->path,
$self->{base}->revision,
);
}
sub _collect_renamed {
my ($renamed, $pathref, $reverse, $rev, $root, $props) = @_;
my $entries;
my $path = $$pathref;
my $paths = $root->paths_changed();
for (keys %$paths) {
my $entry = $paths->{$_};
require SVK::Command;
my $action = $SVK::Command::Log::chg->[$entry->change_kind];
$entries->{$_} = [$action , $action eq 'D' ? (-1) : $root->copied_from ($_)];
# anchor is copied
if ($action eq 'A' && $entries->{$_}[1] != -1 &&
(is_path_inside($path, $_))) {
$path =~ s/^\Q$_\E/$entries->{$_}[2]/;
$$pathref = $path;
}
}
for (keys %$entries) {
my $entry = $entries->{$_};
my $from = $entry->[2] or next;
if (exists $entries->{$from} && $entries->{$from}[0] eq 'D') {
s|^\Q$path\E/|| or next;
$from =~ s|^\Q$path\E/|| or next;
push @$renamed, $reverse ? [$from, $_] : [$_, $from];
}
}
}
sub _collect_rename_for {
my ($self, $renamed, $target, $base, $reverse) = @_;
my $path = $target->path;
SVK::Command::Log::do_log(
repos => $target->repos,
path => $path,
torev => $base->revision + 1,
fromrev => $target->revision,
cb_log => sub { _collect_renamed( $renamed, \$path, $reverse, @_ ) }
);
}
sub track_rename {
my ($self, $editor, $cb) = @_;
my ($base) = $self->find_merge_base (@{$self}{qw/base dst/});
my ($renamed, $path) = ([]);
print "Collecting renames, this might take a while.\n";
$self->_collect_rename_for($renamed, $self->{base}, $base, 0)
unless $self->{track_rename} eq 'dst';
$self->_collect_rename_for($renamed, $self->{dst}, $base, 1);
return $editor unless @$renamed;
my $rename_editor = SVK::Editor::Rename->new (editor => $editor, rename_map => $renamed);
return $rename_editor;
}
=item run
Given the storage editor and L<SVK::Editor::Merge> callbacks, apply
the merge to the storage editor. Returns the number of conflicts.
=back
=cut
sub run {
my ($self, $storage, %cb) = @_;
my ($base, $src) = @{$self}{qw/base src/};
my $base_root = $self->{base_root} || $base->root;
# XXX: for merge editor; this should really be in SVK::Path
my ($report, $target) = ($self->{report}, $src->path_target);
my $dsttarget = $self->{dst}->path_target;
my $is_copath = $self->{dst}->isa('SVK::Path::Checkout');
my $notify_target = defined $self->{target} ? $self->{target} : $target;
my $notify = $self->{notify} || SVK::Notify->new_with_report
($report, $notify_target, $is_copath);
$notify->{quiet} = 1 if $self->{quiet};
my $translate_target;
if ($target && $dsttarget && $target ne $dsttarget) {
$translate_target = sub { $_[0] =~ s/^\Q$target\E/$dsttarget/ };
$storage = SVK::Editor::Translate->new (_editor => [$storage],
translate => $translate_target);
# if there's notify_target, the translation is done by svk::notify
$notify->notify_translate ($translate_target) unless length $notify_target;
}
$storage = SVK::Editor::Delay->new ($storage)
unless $self->{nodelay};
$storage = $self->track_rename ($storage, \%cb)
if $self->{track_rename};
$cb{inspector} = $self->{dst}->inspector
unless ref($cb{inspector}) eq 'SVK::Inspector::Compat' ;
my $meditor = SVK::Editor::Merge->new
( anchor => $src->path_anchor,
repospath => $src->repospath, # for stupid copyfrom url
base_anchor => $base->path_anchor,
base_root => $base_root,
target => $target,
storage => $storage,
notify => $notify,
g_merge_no_a_change => ($src->path ne $base->path),
# if storage editor is E::XD, applytext_delta returns undef
# for failed operations, and merge editor should mark them as skipped
storage_has_unwritable => $is_copath && !$self->{check_only},
allow_conflicts => $is_copath,
resolve => $self->resolver,
open_nonexist => $self->{track_rename},
# XXX: make the prop resolver more pluggable
$self->{ticket} ?
( prop_resolver => { 'svk:merge' =>
sub { my ($path, $prop) = @_;
return (undef, undef, 1)
if $path eq $target;
return ('G', SVK::Merge::Info->new
($prop->{new})->union
(SVK::Merge::Info->new ($prop->{local}))->as_string);
}
},
ticket =>
sub { $self->get_new_ticket ($self->merge_info_with_copy ($src)->add_target ($src)) }
) :
( prop_resolver => { 'svk:merge' => sub { ('G', undef, 1)} # skip
}),
%cb,
);
$meditor->inspector_translate($translate_target)
if $translate_target;
my $editor = $meditor;
if ($self->{notice_copy}) {
my $dstinfo = $self->merge_info_with_copy($self->{dst}->new);
my $srcinfo = $self->merge_info_with_copy($self->{src}->new);
my $boundry_rev;
if ($self->{base}->path eq $self->{src}->path) {
$boundry_rev = $self->{base}->revision;
}
else {
my $usrc = $src->universal;
my $srckey = join(':', $usrc->{uuid}, $usrc->{path});
if ($dstinfo->{$srckey}) {
$boundry_rev = $src->merged_from
($self->{base}, $self, $self->{base}{path});
}
else {
# when did the branch first got created?
$boundry_rev = $src->search_revision
( cmp => sub {
my $rev = shift;
my $root = $src->mclone(revision => $rev)->root(undef);
return $root->node_history($src->path)->prev(0)->prev(0) ? 1 : 0;
}) or die loc("Can't find the first revision of %1.\n", $src->path);
}
}
$logger->debug("==> got $boundry_rev as copyboundry, add $self->{fromrev} as boundry as well");
if (defined $boundry_rev) {
require SVK::Editor::Copy;
$editor = SVK::Editor::Copy->new
( _editor => [$meditor],
merge => $self, # XXX: just for merge_from, move it out
copyboundry_rev => [$boundry_rev, $self->{fromrev}],
copyboundry_root => $self->{repos}->fs->revision_root($boundry_rev
),
src => $src,
dst => $self->{dst},
cb_resolve_copy => sub {
my $path = shift;
my $replace = shift;
my ($src_from, $src_fromrev) = @_;
# If the target exists, don't use copy unless it's a
# replace, because merge editor can't handle it yet.
return if !$replace && $self->{dst}->inspector->exist($path);
my ($dst_from, $dst_fromrev) =
$self->resolve_copy($srcinfo, $dstinfo, @_);
return unless defined $dst_from;
# ensure the dst from path exists
return unless $self->{dst}->root->fs->revision_root($dst_fromrev)->check_path($dst_from);
# Because the delta still need to carry the copy
# information of the source, make merge editor note
# the mapping so it can do the translation
$meditor->copy_info($src_from, $src_fromrev,
$dst_from, $dst_fromrev);
return ($src_from, $src_fromrev);
} );
$editor = SVK::Editor::Delay->new ($editor);
}
}
SVK::XD->depot_delta
( oldroot => $base_root, newroot => $src->root,
oldpath => [$base->path_anchor, $base->path_target],
newpath => $src->path,
# pool => SVN::Pool->new,
no_recurse => $self->{no_recurse}, editor => $editor,
);
unless ($self->{quiet}) {
$logger->warn(loc("%*(%1,conflict) found.", $meditor->{conflicts}))
if $meditor->{conflicts};
$logger->warn(loc("%*(%1,file) skipped, you might want to rerun merge with --track-rename.",
$meditor->{skipped})) if $meditor->{skipped} && !$self->{track_rename} && !$self->{auto};
}
return $meditor->{conflicts};
}
# translate to (path, rev) for dst
sub resolve_copy {
my ($self, $srcinfo, $dstinfo, $cp_path, $cp_rev) = @_;
$logger->debug("==> to resolve $cp_path $cp_rev");
my $path = $cp_path;
my $src = $self->{src};
my $srcpath = $src->path;
my $dstpath = $self->{dst}->path;
return ($cp_path, $cp_rev) if $path =~ m{^\Q$dstpath/};
my $cpsrc = $src->new( path => $path,
revision => $cp_rev );
if ($path !~ m{^\Q$srcpath/}) {
# if the copy source is not within the merge source path, only
# allows using the copy if they are both not mirrored
return !$src->is_mirrored && !$cpsrc->is_mirrored ?
($cp_path, $cp_rev) : ();
}
$path =~ s/^\Q$srcpath/$dstpath/;
$cpsrc->normalize;
$cp_rev = $cpsrc->revision;
# now the hard part, reoslve the revision
my $usrc = $src->universal;
my $srckey = join(':', $usrc->{uuid}, $usrc->{path});
unless ($dstinfo->{$srckey}) {
my $udst = $self->{dst}->universal;
my $dstkey = join(':', $udst->{uuid}, $udst->{path});
return $srcinfo->{$dstkey}{rev} ?
($path, $srcinfo->{$dstkey}->local($self->{dst}->depot)->revision) : ();
}
if ($dstinfo->{$srckey}->local($self->{dst}->depot)->revision < $cp_rev) {
# same as re-base in editor::copy
my $rev = $self->{src}->merged_from
($self->{base}, $self, $self->{base}->path_anchor);
# XXX: compare rev and cp_rev
return ($path, $rev) if defined $rev;
return;
}
# XXX: get rid of the merge context needed for
# merged_from(); actually what the function needs is
# just XD
my $rev = $self->{dst}->
merged_from($src->new(revision => $cp_rev),
$self, $cp_path);
return ($path, $rev) if defined $rev;
return;
}
sub resolver {
return undef if $_[0]->{check_only};
require SVK::Resolve;
return SVK::Resolve->new (action => $ENV{SVKRESOLVE},
external => $ENV{SVKMERGE});
}
package SVK::Merge::Info;
sub new {
my ($class, $merge) = @_;
my $minfo = { map { my ($uuid, $path, $rev) = m/(.*?):(.*):(\d+$)/;
("$uuid:$path" => SVK::Target::Universal->new ($uuid, $path, $rev))
} grep { length $_ } split (/\n/, $merge || '') };
bless $minfo, $class;
return $minfo;
}
sub add_target {
my ($self, $target) = @_;
$target = $target->universal
if $target->can('universal');
$self->{$target->ukey} = $target;
return $self;
}
sub del_target {
my ($self, $target) = @_;
$target = $target->universal
if $target->can('universal');
delete $self->{$target->ukey};
return $self;
}
sub remove_duplicated {
my ($self, $other) = @_;
for (keys %$other) {
if ($self->{$_} && $self->{$_}{rev} <= $other->{$_}{rev}) {
delete $self->{$_};
}
}
return $self;
}
sub subset_of {
my ($self, $other) = @_;
my $subset = 1;
for (keys %$self) {
return unless exists $other->{$_} && $self->{$_}{rev} <= $other->{$_}{rev};
}
return 1;
}
sub union {
my ($self, $other) = @_;
# bring merge history up to date as from source
my $new = SVK::Merge::Info->new;
for (keys %{ { %$self, %$other } }) {
if ($self->{$_} && $other->{$_}) {
$new->{$_} = $self->{$_}{rev} > $other->{$_}{rev}
? $self->{$_} : $other->{$_};
}
else {
$new->{$_} = $self->{$_} ? $self->{$_} : $other->{$_};
}
}
return $new;
}
sub resolve {
my ($self, $depot) = @_;
my $uuid = $depot->repos->fs->get_uuid;
return { map { my $local = $self->{$_}->local($depot);
$local ? ("$uuid:".$local->path_anchor => $local->revision) : ()
} keys %$self };
}
sub verbatim {
my ($self) = @_;
return { map { $_ => $self->{$_}{rev} } keys %$self };
}
sub as_string {
my $self = shift;
return join ("\n", map {"$_:$self->{$_}{rev}"} sort keys %$self);
}
=head1 TODO
Document the merge and ticket tracking mechanism.
=head1 SEE ALSO
L<SVK::Editor::Merge>, L<SVK::Command::Merge>, Star-merge from GNU Arch
=cut
1;