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