chiark / gitweb /
fixes, upload-host
[dgit.git] / dgit
1 #!/usr/bin/perl -w
2 # dgit
3 # Integration between git and Debian-style archives
4 #
5 # Copyright (C)2013 Ian Jackson
6 #
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
10 # (at your option) any later version.
11 #
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU General Public License for more details.
16 #
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/>.
19
20 use strict;
21
22 use IO::Handle;
23 use Data::Dumper;
24 use LWP::UserAgent;
25 use Dpkg::Control::Hash;
26 use File::Path;
27 use POSIX;
28
29 our $mirror = 'http://mirror.relativity.greenend.org.uk/mirror/debian-ftp/';
30 our $suite = 'sid';
31 our $package;
32
33 our $sign = 1;
34 our $dryrun = 0;
35 our $changesfile;
36
37 our %format_ok = map { $_=>1 } ("1.0","3.0 (native)","3.0 (quilt)");
38
39 our (@git) = qw(git);
40 our (@dget) = qw(dget);
41 our (@dput) = qw(dput);
42 our (@debsign) = qw(debsign);
43 our $keyid;
44
45 open DEBUG, ">/dev/null" or die $!;
46
47 our %opts_opt_map = ('dget' => \@dget,
48                      'dput' => \@dput,
49                      'debsign' => \@debsign);
50
51 our $remotename = 'dgit';
52 our $ourdscfield = 'Vcs-Git-Master';
53 our $branchprefix = 'dgit';
54
55 sub lbranch () { return "$branchprefix/$suite"; }
56 my $lbranch_re = '^refs/heads/'.$branchprefix.'/([^/.]+)$';
57 sub lref () { return "refs/heads/".lbranch(); }
58 sub lrref () { return "refs/remotes/$remotename/$suite"; }
59 sub rrref () { return "refs/$branchprefix/$suite"; }
60 sub debiantag ($) { return "debian/$_[0]"; }
61
62 sub fetchspec () {
63     local $suite = '*';
64     return  "+".rrref().":".lrref();
65 }
66
67 our $ua;
68
69 sub url_get {
70     if (!$ua) {
71         $ua = LWP::UserAgent->new();
72         $ua->env_proxy;
73     }
74     print "downloading @_...\n";
75     my $r = $ua->get(@_) or die $!;
76     die "$_[0]: ".$r->status_line."; failed.\n" unless $r->is_success;
77     return $r->decoded_content();
78 }
79
80 our ($dscdata,$dscurl,$dsc);
81
82 sub printcmd {
83     my $fh = shift @_;
84     my $intro = shift @_;
85     print $fh $intro or die $!;
86     local $_;
87     foreach my $a (@_) {
88         $_ = $a;
89         if (s{['\\]}{\\$&}g || m{\s} || m{[^-_./0-9a-z]}i) {
90             print $fh " '$_'" or die $!;
91         } else {
92             print $fh " $_" or die $!;
93         }
94     }
95     print $fh "\n" or die $!;
96 }
97
98 sub runcmd {
99     printcmd(\*DEBUG,"+",@_);
100     $!=0; $?=0;
101     die "@_ $! $?" if system @_;
102 }
103
104 sub cmdoutput_errok {
105     die Dumper(\@_)." ?" if grep { !defined } @_;
106     printcmd(\*DEBUG,"|",@_);
107     open P, "-|", @_ or die $!;
108     my $d;
109     $!=0; $?=0;
110     { local $/ = undef; $d = <P>; }
111     die if P->error;
112     close P or return undef;
113     chomp $d;
114     return $d;
115 }
116
117 sub cmdoutput {
118     my $d = cmdoutput_errok @_;
119     defined $d or die "@_ $? $!";
120     return $d;
121 }
122
123 sub dryrun_report {
124     printcmd(\*STDOUT,"#",@_);
125 }
126
127 sub runcmd_ordryrun {
128     if (!$dryrun) {
129         runcmd @_;
130     } else {
131         dryrun_report @_;
132     }
133 }
134
135 our %defcfg = ('dgit.default.distro' => 'debian',
136                'dgit.default.username' => '',
137                'dgit.default.ssh' => 'ssh',
138                'dgit-distro.debian.git-host' => 'git.debian.org',
139                'dgit-distro.debian.git-proto' => 'git+ssh://',
140                'dgit-distro.debian.git-path' => '/git/dgit-repos',
141                'dgit-distro.debian.git-check' => 'ssh-cmd',
142                'dgit-distro.debian.git-create' => 'ssh-cmd');
143
144 sub cfg {
145     foreach my $c (@_) {
146         my $v = cmdoutput_errok(@git, qw(config --), $c);
147         if ($?==0) {
148             chomp $v;
149             return $v;
150         } elsif ($?!=256) {
151             die "$c $?";
152         }
153         my $dv = $defcfg{$c};
154         return $dv if defined $dv;
155     }
156     return undef;
157 }
158
159 sub access_cfg ($) {
160     my ($key) = @_;
161     my $distro = cfg("dgit-suite.$suite.distro",
162                      "dgit.default.distro");
163     my $value = cfg("dgit-distro.$distro.$key",
164                     "dgit.default.$key");
165     return $value;
166 }
167
168 sub access_gituserhost () {
169     my $user = access_cfg('git-user');
170     my $host = access_cfg('git-host');
171     return defined($user) && length($user) ? "$user\@$host" : $host;
172 }
173
174 sub access_giturl () {
175     my $url = access_cfg('git-url');
176     if (!defined $url) {
177         $url =
178             access_cfg('git-proto').
179             access_gituserhost().
180             access_cfg('git-path');
181     }
182     return "$url/$package.git";
183 }              
184
185 sub parsecontrol {
186     my $c = Dpkg::Control::Hash->new();
187     $c->load(@_) or return undef;
188     return $c;
189 }
190
191 sub parsechangelog {
192     my $c = Dpkg::Control::Hash->new();
193     my $p = new IO::Handle;
194     open $p, '-|', qw(dpkg-parsechangelog) or die $!;
195     $c->parse($p);
196     $?=0; $!=0; close $p or die "$! $?";
197     return $c;
198 }
199
200 our $rmad;
201
202 sub askmadison () {
203     $rmad ||= cmdoutput qw(rmadison -asource),"-s$suite",$package;
204     $rmad =~ m/^ \s*( [^ \t|]+ )\s* \|
205                  \s*( [^ \t|]+ )\s* \|
206                  \s*( [^ \t|]+ )\s* \|
207                  \s*( [^ \t|]+ )\s* /x or die "$rmad $?";
208     $1 eq $package or die "$rmad $package ?";
209     my $vsn = $2;
210     if ($suite ne $3) {
211         # madison canonicalises for us
212         print "canonical suite name for $suite is $3\n";
213         $suite = $3;
214     }
215     $4 eq 'source' or die "$rmad ?";
216     return $vsn;
217 }
218
219 sub canonicalise_suite () {
220     askmadison();
221 }
222
223 sub get_archive_dsc () {
224     my $vsn = askmadison();
225     # fixme madison does not show us the component
226     my $prefix = substr($package, 0, $package =~ m/^l/ ? 4 : 1);
227     $dscurl = "$mirror/pool/main/$prefix/$package/${package}_$vsn.dsc";
228     $dscdata = url_get($dscurl);
229     my $dscfh = new IO::File \$dscdata, '<' or die $!;
230     print DEBUG Dumper($dscdata);
231     $dsc = Dpkg::Control::Hash->new(allow_pgp=>1);
232     $dsc->parse($dscfh, 'dsc') or die "parsing of $dscurl failed\n";
233     print DEBUG Dumper($dsc);
234     my $fmt = $dsc->{Format};
235     die "unsupported format $fmt, sorry\n" unless $format_ok{$fmt};
236 }
237
238 sub check_for_git () {
239     # returns 0 or 1
240     my $how = access_cfg('git-check');
241     if ($how eq 'ssh-cmd') {
242         my $r= cmdoutput
243             (access_cfg('ssh'),access_gituserhost(),
244              " set -e; cd ".access_cfg('git-path').";".
245              " if test -d $package.git; then echo 1; else echo 0; fi");
246         print DEBUG ">$r<\n";
247         die "$r $! $?" unless $r =~ m/^[01]$/;
248         return $r+0;
249     } else {
250         die "unknown git-check $how ?";
251     }
252 }
253
254 sub create_remote_git_repo () {
255     my $how = access_cfg('git-create');
256     if ($how eq 'ssh-cmd') {
257         runcmd_ordryrun
258             (access_cfg('ssh'),access_gituserhost(),
259              "set -e; cd ".access_cfg('git-path').";".
260              " mkdir -p $package.git;".
261              " cd $package.git;".
262              " if ! test -d objects; then git init --bare; fi");
263     } else {
264         die "unknown git-create $how ?";
265     }
266 }
267
268 our ($dsc_hash,$upload_hash);
269
270 our $ud = '.git/dgit/unpack';
271
272 sub prep_ud () {
273     rmtree($ud);
274     mkpath '.git/dgit';
275     mkdir $ud or die $!;
276 }
277
278 sub mktree_in_ud_from_only_subdir () {
279     # changes into the subdir
280     my (@dirs) = <*/.>;
281     die unless @dirs==1;
282     $dirs[0] =~ m#^([^/]+)/\.$# or die;
283     my $dir = $1;
284     chdir $dir or die "$dir $!";
285     die if stat '.git';
286     die $! unless $!==&ENOENT;
287     runcmd qw(git init -q);
288     rmtree('.git/objects');
289     symlink '../../../../objects','.git/objects' or die $!;
290     runcmd @git, qw(add -Af);
291     my $tree = cmdoutput @git, qw(write-tree);
292     chomp $tree; $tree =~ m/^\w+$/ or die "$tree ?";
293     return ($tree,$dir);
294 }
295
296 sub dsc_files () {
297     map {
298         m/^\w+ \d+ (\S+)$/ or die "$_ ?";
299         $1;
300     } grep m/\S/, split /\n/, ($dsc->{'Checksums-Sha256'} || $dsc->{Files});
301 }
302
303 sub is_orig_file ($) {
304     local ($_) = @_;
305     m/\.orig(?:-\w+)?\.tar\.\w+$/;
306 }
307
308 sub generate_commit_from_dsc () {
309     prep_ud();
310     chdir $ud or die $!;
311     my @files;
312     foreach my $f (dsc_files()) {
313         die if $f =~ m#/|^\.|\.dsc$|\.tmp$#;
314         push @files, $f;
315         link "../../../$f", $f
316             or $!==&ENOENT
317             or die "$f $!";
318     }
319     runcmd @dget, qw(--), $dscurl;
320     foreach my $f (grep { is_orig_file($_) } @files) {
321         link $f, "../../../../$f"
322             or $!==&EEXIST
323             or die "$f $!";
324     }
325     my ($tree,$dir) = mktree_in_ud_from_only_subdir();
326     runcmd qw(sh -ec), 'dpkg-parsechangelog >../changelog.tmp';
327     my $clogp = parsecontrol('../changelog.tmp','changelog') or die;
328     my $date = cmdoutput qw(date), '+%s %z', qw(-d),$clogp->{Date};
329     my $author = $clogp->{Maintainer};
330     $author =~ s#,.*##ms;
331     my $authline = "$author $date";
332     $authline =~ m/^[^<>]+ \<\S+\> \d+ [-+]\d+$/ or die $authline;
333     open C, ">../commit.tmp" or die $!;
334     print C "tree $tree\n" or die $!;
335     print C "parent $upload_hash\n" or die $! if $upload_hash;
336     print C <<END or die $!;
337 author $authline
338 committer $authline
339
340 $clogp->{Changes}
341
342 # imported by dgit from the archive
343 END
344     close C or die $!;
345     my $commithash = cmdoutput @git, qw(hash-object -w -t commit ../commit.tmp);
346     print "synthesised git commit from .dsc $clogp->{Version}\n";
347     chdir '../../../..' or die $!;
348     cmdoutput @git, qw(update-ref -m),"dgit synthesise $clogp->{Version}",
349               'DGIT_ARCHIVE', $commithash;
350     cmdoutput @git, qw(log -n2), $commithash;
351     # ... gives git a chance to complain if our commit is malformed
352     my $outputhash = $commithash;
353     if ($upload_hash) {
354         chdir "$ud/$dir" or die $!;
355         runcmd @git, qw(reset --hard), $upload_hash;
356         runcmd qw(sh -ec), 'dpkg-parsechangelog >>../changelogold.tmp';
357         my $oldclogp = Dpkg::Control::Hash->new();
358         $oldclogp->parse('../changelogold.tmp','previous changelog') or die;
359         my $vcmp =
360             version_compare_string($oldclogp->{Version}, $clogp->{Version});
361         if ($vcmp < 0) {
362             # git upload/ is earlier vsn than archive, use archive
363         } elsif ($vcmp >= 0) {
364             print STDERR <<END or die $!;
365 Version actually in archive:    $clogp->{Version} (older)
366 Last allegedly pushed/uploaded: $oldclogp->{Version} (newer or same)
367 Perhaps the upload is stuck in incoming.  Using the version from git.
368 END
369         } else {
370             die "version in archive is same as version in git".
371                 " to-be-uploaded (upload/) branch but archive".
372                 " version hash no commit hash?!\n";
373         }
374         chdir '../../../..' or die $!;
375     }
376     rmtree($ud);
377     return $outputhash;
378 }
379
380 sub ensure_we_have_orig () {
381     foreach my $f (dsc_files()) {
382         next unless is_orig_file($f);
383         if (stat "../$f") {
384             die "$f ?" unless -f _;
385         } else {
386             die "$f $!" unless $!==&ENOENT;
387         }
388         my $origurl = $dscurl;
389         $origurl =~ s{/[^/]+$}{};
390         $origurl .= "/$f";
391         die "$f ?" unless $f =~ m/^${package}_/;
392         die "$f ?" if $f =~ m#/#;
393         runcmd_ordryrun qw(sh -ec),'cd ..; exec "$@"','x',
394             @dget,'--',$origurl;
395     }
396 }
397
398 sub rev_parse ($) {
399     return cmdoutput @git, qw(rev-parse), "$_[0]~0";
400 }
401
402 sub is_fast_fwd ($$) {
403     my ($ancestor,$child) = @_;
404     my $mb = cmdoutput @git, qw(merge-base), $dsc_hash, $upload_hash;
405     return rev_parse($mb) eq rev_parse($ancestor);
406 }
407
408 sub git_fetch_us () {
409     die "cannot dry run with fetch" if $dryrun;
410     runcmd @git, qw(fetch),access_giturl(),fetchspec();
411 }
412
413 sub fetch_from_archive () {
414     # ensures that lrref() is what is actually in the archive,
415     #  one way or another
416     get_archive_dsc();
417     $dsc_hash = $dsc->{$ourdscfield};
418     if (defined $dsc_hash) {
419         $dsc_hash =~ m/\w+/ or die "$dsc_hash $?";
420         $dsc_hash = $&;
421         print "last upload to archive specified git hash\n";
422     } else {
423         print "last upload to archive has NO git hash\n";
424     }
425
426     $!=0; $upload_hash =
427         cmdoutput_errok @git, qw(show-ref --heads), lrref();
428     if ($?==0) {
429         die unless chomp $upload_hash;
430     } elsif ($?==256) {
431         $upload_hash = '';
432     } else {
433         die $?;
434     }
435     my $hash;
436     if (defined $dsc_hash) {
437         die "missing git history even though dsc has hash"
438             unless $upload_hash;
439         $hash = $dsc_hash;
440         ensure_we_have_orig();
441     } else {
442         $hash = generate_commit_from_dsc();
443     }
444     if ($upload_hash) {
445         die "not fast forward on last upload branch!".
446             " (archive's version left in DGIT_ARCHIVE)"
447             unless is_fast_fwd($dsc_hash, $upload_hash);
448     }
449     if ($upload_hash ne $hash) {
450         my @upd_cmd = (@git, qw(update-ref -m), 'dgit fetch', lrref(), $hash);
451         if (!$dryrun) {
452             cmdoutput @upd_cmd;
453         } else {
454             dryrun_report @upd_cmd;
455         }
456     }
457 }
458
459 sub clone ($) {
460     my ($dstdir) = @_;
461     die "dry run makes no sense with clone" if $dryrun;
462     mkdir $dstdir or die "$dstdir $!";
463     chdir "$dstdir" or die "$dstdir $!";
464     runcmd @git, qw(init -q);
465     runcmd @git, qw(config), "remote.$remotename.fetch", fetchspec();
466     open H, "> .git/HEAD" or die $!;
467     print H "ref: ".lref()."\n" or die $!;
468     close H or die $!;
469     runcmd @git, qw(remote add), 'origin', access_giturl();
470     if (check_for_git()) {
471         print "fetching existing git history\n";
472         git_fetch_us();
473         runcmd @git, qw(fetch origin);
474     } else {
475         print "starting new git history\n";
476     }
477     fetch_from_archive();
478     runcmd @git, qw(reset --hard), lrref();
479     print "ready for work in $dstdir\n";
480 }
481
482 sub fetch () {
483     if (check_for_git()) {
484         git_fetch_us();
485     }
486     fetch_from_archive();
487 }
488
489 sub pull () {
490     fetch();
491     runcmd_ordryrun @git, qw(merge -m),"Merge from $suite [dgit]",
492         lrref();
493 }
494
495 sub dopush () {
496     runcmd @git, qw(diff --quiet HEAD);
497     my $clogp = parsechangelog();
498     $package = $clogp->{Source};
499     my $dscfn = "${package}_$clogp->{Version}.dsc";
500     stat "../$dscfn" or die "$dscfn $!";
501     $dsc = parsecontrol("../$dscfn");
502     prep_ud();
503     chdir $ud or die $!;
504     print "checking that $dscfn corresponds to HEAD\n";
505     runcmd qw(dpkg-source -x --), "../../../../$dscfn";
506     my ($tree,$dir) = mktree_in_ud_from_only_subdir();
507     chdir '../../../..' or die $!;
508     runcmd @git, qw(diff --exit-code), $tree;
509 #fetch from alioth
510 #do fast forward check and maybe fake merge
511 #    if (!is_fast_fwd(mainbranch
512 #    runcmd @git, qw(fetch -p ), "$alioth_git/$package.git",
513 #        map { lref($_).":".rref($_) }
514 #        (uploadbranch());
515     $dsc->{$ourdscfield} = rev_parse('HEAD');
516     $dsc->save("../$dscfn.tmp") or die $!;
517     if (!$dryrun) {
518         rename "../$dscfn.tmp","../$dscfn" or die "$dscfn $!";
519     } else {
520         print "[new .dsc left in $dscfn.tmp]\n";
521     }
522     if (!$changesfile) {
523         my $pat = "../${package}_$clogp->{Version}_*.changes";
524         my @cs = glob $pat;
525         die "$pat ?" unless @cs==1;
526         ($changesfile) = @cs;
527     }
528     my $tag = debiantag($dsc->{Version});
529     if (!check_for_git()) {
530         create_remote_git_repo();
531     }
532     runcmd_ordryrun @git, qw(push),access_giturl(),"HEAD:".rrref();
533     if ($sign) {
534         my @tag_cmd = (@git, qw(tag -s -m),
535                        "Release $dsc->{Version} for $suite [dgit]");
536         push @tag_cmd, qw(-u),$keyid if defined $keyid;
537         push @tag_cmd, $tag;
538         runcmd_ordryrun @tag_cmd;
539         my @debsign_cmd = @debsign;
540         push @debsign_cmd, "-k$keyid" if defined $keyid;
541         push @debsign_cmd, $changesfile;
542         runcmd_ordryrun @debsign_cmd;
543     }
544     runcmd_ordryrun @git, qw(push),access_giturl(),"refs/tags/$tag";
545     my $host = access_cfg('upload-host');
546     my @hostarg = defined($host) ? ($host,) : ();
547     runcmd_ordryrun @dput, @hostarg, $changesfile;
548 }
549
550 sub cmd_clone {
551     my $dstdir;
552     die if defined $package;
553     if (@ARGV==1) {
554         ($package) = @ARGV;
555     } elsif (@ARGV==2 && $ARGV[1] =~ m#^\w#) {
556         ($package,$suite) = @ARGV;
557     } elsif (@ARGV==2 && $ARGV[1] =~ m#^[./]#) {
558         ($package,$dstdir) = @ARGV;
559     } elsif (@ARGV==3) {
560         ($package,$suite,$dstdir) = @ARGV;
561     } else {
562         die;
563     }
564     $dstdir ||= "$package";
565     clone($dstdir);
566 }
567
568 sub branchsuite () {
569     my $branch = cmdoutput_errok @git, qw(symbolic-ref HEAD);
570     chomp $branch;
571     if ($branch =~ m#$lbranch_re#o) {
572         return $1;
573     } else {
574         return undef;
575     }
576 }
577
578 sub fetchpullargs () {
579     if (!defined $package) {
580         my $sourcep = parsecontrol('debian/control');
581         $package = $sourcep->{Source};
582     }
583     if (@ARGV==0) {
584         $suite = branchsuite();
585         if (!$suite) {
586             my $clogp = parsechangelog();
587             $suite = $clogp->{Distribution};
588         }
589         canonicalise_suite();
590         print "fetching from suite $suite\n";
591     } elsif (@ARGV==1) {
592         ($suite) = @ARGV;
593         canonicalise_suite();
594     } else {
595         die;
596     }
597 }
598
599 sub cmd_fetch {
600     fetchpullargs();
601     fetch();
602 }
603
604 sub cmd_pull {
605     fetchpullargs();
606     pull();
607 }
608
609 sub cmd_push {
610     die if defined $package;
611     my $clogp = parsechangelog();
612     $package = $clogp->{Source};
613     if (@ARGV==0) {
614         $suite = $clogp->{Distribution};
615         canonicalise_suite();
616     } else {
617         die;
618     }
619     dopush();
620 }
621
622 sub cmd_build {
623     die if defined $package;
624     my $clogp = parsechangelog();
625     $suite = $clogp->{Distribution};
626     $package = $clogp->{Source};
627     canonicalise_suite();
628     runcmd_ordryrun
629         qw(git-buildpackage -us -uc --git-no-sign-tags),
630             "--git-debian-branch=".lbranch(),
631             @ARGV;
632 }
633
634 sub parseopts () {
635     my $om;
636     while (@ARGV) {
637         last unless $ARGV[0] =~ m/^-/;
638         $_ = shift @ARGV;
639         last if m/^--?$/;
640         if (m/^--/) {
641             if (m/^--dry-run$/) {
642                 $dryrun=1;
643             } elsif (m/^--no-sign$/) {
644                 $sign=0;
645             } elsif (m/^--(\w+)=(.*)/s && ($om = $opts_opt_map{$1})) {
646                 $om->[0] = $2;
647             } elsif (m/^--(\w+):(.*)/s && ($om = $opts_opt_map{$1})) {
648                 push @$om, $2;
649             } else {
650                 die "$_ ?";
651             }
652         } else {
653             while (m/^-./s) {
654                 if (s/^-n/-/) {
655                     $dryrun=1;
656                 } elsif (s/^-D/-/) {
657                     open DEBUG, ">&STDERR" or die $!;
658                 } elsif (s/^-c(.*=.*)//s) {
659                     push @git, '-c', $1;
660                 } elsif (s/^-C(.*)//s) {
661                     $changesfile = $1;
662                 } elsif (s/^-k(.*)//s) {
663                     $keyid=$1;
664                 } else {
665                     die "$_ ?";
666                 }
667             }
668         }
669     }
670 }
671
672 parseopts();
673 die unless @ARGV;
674 my $cmd = shift @ARGV;
675 parseopts();
676
677 { no strict qw(refs); &{"cmd_$cmd"}(); }