3 # git-branchmove -- move branches to or from a remote
5 # Copyright (C) 2019 Sean Whitton
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or (at
10 # your option) any later version.
12 # This program is distributed in the hope that it will be useful, but
13 # WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
15 # General Public License for more details.
17 # You should have received a copy of the GNU General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
20 # This script is based on Ian Jackson's git-branchmove script, in the
21 # chiark-utils Debian source package. Ian's script assumes throughout
22 # that it is possible to have unrestricted shell access to the remote,
23 # however, while this script avoids that global assumption.
25 # As much as possible we treat the remote argument as opaque, i.e., we
26 # don't distinguish between git URIs and named remotes. That means
27 # that git will expand insteadOf and pushInsteadOf user config for us.
31 git-branchmove - move branches to or from a remote
35 B<git-branchmove> [B<--detach>|B<-d>] B<get>|B<put> I<remote> I<pattern>...
39 Move branches matching I<pattern> to or from git remote I<remote>.
45 =item B<--detach>|B<-d>
47 If the move would delete the currently checked out branch in the
48 source repository, attempt to detach HEAD first.
50 Note that in the case of the B<get> operation, the attempt to detach
51 HEAD is somewhat fragile. You will need unrestricted SSH access to
52 the remote, and pushInsteadOf git configuration keys will not always
53 be expanded, due to limitations in git.
59 This Perl version of B<git-branchmove> was written by Sean Whitton
60 <spwhitton@spwhitton.name>, based on an earlier shell script by Ian
61 Jackson. That script made some assumptions that we try to avoid, for
62 compatibility with more git remotes and local git configurations.
73 my $git = Git::Wrapper->new(".");
75 $git->rev_parse({ git_dir => 1 });
77 die "git-branchmove: pwd doesn't look like a git repository ..\n";
81 die "git-branchmove: not enough arguments\n" if @ARGV < 3;
82 my $attempt_detach = 0;
83 if ($ARGV[0] eq '-d' or $ARGV[0] eq '--detach') {
87 my ($op, $remote, @patterns) = @ARGV;
88 die "git-branchmove: unknown operation\n"
89 unless $op eq 'get' or $op eq 'put';
91 # is this a named remote or a git URL? See "GIT URLS" in git-fetch(1)
92 my $named_remote = not($remote =~ m|:| or $remote =~ m|^[/.]|);
94 # Attempt to determine how we might be able to run commands in the
95 # remote repo. This will only be used if we need to try to detach the
96 # remote HEAD. These regexps are lifted from Ian's version of
98 my ($rurl, $rrune, $rdir);
100 # this will expand insteadOf and pushInsteadOf
101 ($rurl) = $git->remote("get-url", "--push", $remote);
103 # this will expand insteadOf but not pushInsteadOf, which is the
104 # best we can do; see <https://stackoverflow.com/a/32991784>
105 ($rurl) = $git->ls_remote("--get-url", $remote);
107 if ($rurl =~ m#^ssh://([^:/]+)(?:\:(\w+))?#) {
110 $rrune .= "-p $2 " if $2;
112 } elsif ($rurl =~ m#^([-+_.0-9a-zA-Z\@]+):(?!//|:)#) {
115 } elsif ($rurl =~ m#^[/.]#) {
119 # If we don't prefix the patterns, we might match branches the user
120 # doesn't intend. E.g. 'foo' would match 'wip/foo'
121 my @branch_pats = map { s|^|[r]efs/heads/|r } @patterns;
123 # get lists of branches, prefixed with 'refs/heads/' in each case
124 my (@source_branches, @dest_branches);
125 my @local_branches = map {
126 my ($hash, undef, $ref) = split ' ';
127 { hash => $hash, ref => $ref }
128 } $git->for_each_ref(@branch_pats);
129 my @remote_branches = map {
130 my ($hash, $ref) = split ' ';
131 { hash => $hash, ref => $ref }
132 } $git->ls_remote($remote, @branch_pats);
134 @source_branches = @local_branches;
135 @dest_branches = @remote_branches;
136 } elsif ($op eq 'get') {
137 @source_branches = @remote_branches;
138 @dest_branches = @local_branches;
141 # do we have anything to move?
142 die "git-branchmove: nothing to do\n" unless @source_branches;
144 # check for deleting the current branch on the source
147 my @lines = try { $git->symbolic_ref('-q', 'HEAD') };
148 $source_head = $lines[0] if @lines; # the HEAD is not detached
149 } elsif ($op eq "get") {
150 my @lines = try { $git->ls_remote('--symref', $remote, 'HEAD') };
151 if (@lines and $lines[0] =~ m|^ref: refs/heads/|) {
152 # the HEAD is not detached
153 (undef, $source_head) = split ' ', $lines[0];
156 if (defined $source_head and grep /^\Q$source_head\E$/,
157 map { $_->{ref} } @source_branches) {
158 if ($attempt_detach) {
160 $git->checkout('--detach');
161 } elsif ($op eq 'get') {
162 if (defined $rrune and defined $rdir) {
163 system "$rrune \"set -e; cd $rdir; git checkout --detach\"";
164 die "failed to detach remote HEAD" unless $? eq 0;
165 } elsif (!defined $rrune and defined $rdir) {
166 my $dest_git = Git::Wrapper->new($rdir);
167 $dest_git->checkout('--detach');
169 die "git-branchmove: don't know how to detach remote HEAD";
173 die "git-branchmove: would delete checked-out branch $source_head\n";
177 # check whether we would overwrite anything
178 foreach my $source_branch (@source_branches) {
179 foreach my $dest_branch (@dest_branches) {
180 die "git-branchmove: would overwrite $source_branch->{ref}"
181 if ( $source_branch->{ref} eq $dest_branch->{ref}
182 and $source_branch->{hash} ne $dest_branch->{hash});
186 # time to actually move the branches
187 my @refspecs = map { "$_->{ref}:$_->{ref}" } @source_branches;
188 my @nuke_refspecs = map { ":$_->{ref}" } @source_branches;
190 $git->push('--no-follow-tags', $remote, @refspecs);
191 $git->update_ref('-m', "git-branchmove: moved to $remote ($rurl)",
192 '-d', $_->{ref}, $_->{hash})
193 for @source_branches;
194 } elsif ($op eq 'get') {
195 $git->fetch('--no-tags', $remote, @refspecs);
196 $git->push('--no-follow-tags', $remote, @nuke_refspecs);
199 # if the remote is a named remote, rather than just a URI, update
200 # remote-tracking branches
202 foreach my $source_branch (@source_branches) {
203 my $branch = $source_branch->{ref} =~ s|^refs/heads/||r;
204 my $tracking_ref = "refs/remotes/$remote/$branch";
206 $git->update_ref($tracking_ref, $source_branch->{hash});
207 } elsif ($op eq 'get') {
208 $git->update_ref('-d', $tracking_ref);