|
@@ -7,10 +7,12 @@ use warnings;
|
|
|
|
|
|
|
|
use FindBin::libs;
|
|
use FindBin::libs;
|
|
|
|
|
|
|
|
|
|
+use Config::Simple;
|
|
|
use Getopt::Long qw{GetOptionsFromArray};
|
|
use Getopt::Long qw{GetOptionsFromArray};
|
|
|
use Pod::Usage;
|
|
use Pod::Usage;
|
|
|
use Pithub;
|
|
use Pithub;
|
|
|
use Gogs;
|
|
use Gogs;
|
|
|
|
|
+use Git;
|
|
|
|
|
|
|
|
use Term::ReadKey();
|
|
use Term::ReadKey();
|
|
|
use IO::Interactive::Tiny();
|
|
use IO::Interactive::Tiny();
|
|
@@ -30,11 +32,116 @@ It will warn you whenever a repository is missing from either, so you can make i
|
|
|
|
|
|
|
|
Using this you can easily migrate an organization from being entirely on github to using private resources or vice versa.
|
|
Using this you can easily migrate an organization from being entirely on github to using private resources or vice versa.
|
|
|
|
|
|
|
|
-=head1
|
|
|
|
|
|
|
+=head1 IMPORTANT
|
|
|
|
|
+
|
|
|
|
|
+This assumes that the repo names between the base and mirrors is identical.
|
|
|
|
|
+
|
|
|
|
|
+=head1 CONFIG FILE
|
|
|
|
|
+
|
|
|
|
|
+You will notice below that the options of this tool can be quite involved.
|
|
|
|
|
+
|
|
|
|
|
+To simplify deploying this tool across your organization, you can place a configuration file (Config::Simple) in ~/.git/clone-entity.cfg. Example:
|
|
|
|
|
+
|
|
|
|
|
+ baseurl=https://my-gogs-install.test/api/v1
|
|
|
|
|
+ gogs=true
|
|
|
|
|
+ nossh=true
|
|
|
|
|
+ mirrors=https://api.github.com,https://premise-install.github.local/api
|
|
|
|
|
+ me=jane
|
|
|
|
|
+
|
|
|
|
|
+Ideally all your users have to do is specify which users/orgs to clone w/mirroring and you should be off to the races.
|
|
|
|
|
+
|
|
|
|
|
+The name of the setting will be pluralized for any option which may be passed multiple times below.
|
|
|
|
|
|
|
|
=head1 USAGE
|
|
=head1 USAGE
|
|
|
|
|
|
|
|
-git clone-entity --user $user1 --user $user2 --org $org1 --org $org2 --alias $user1:$mirror_domain:$mirrorUser1 --baseurl=https://my.local.install/ [--gogs] [--mirror https://github.com] [--help]
|
|
|
|
|
|
|
+git clone-entity --user $user1 --user $user2 --org $org1 --org $org2 --alias $user1:$mirror_domain:$mirrorUser1 --baseurl=https://my.local.install/ [--gogs] [--mirror https://github.com] [--nossh] [--insecure] [--help]
|
|
|
|
|
+
|
|
|
|
|
+=head1 OPTIONS
|
|
|
|
|
+
|
|
|
|
|
+=head3 me
|
|
|
|
|
+
|
|
|
|
|
+Your username on the baseurl. Relevant to token use, what is visible, etc.
|
|
|
|
|
+
|
|
|
|
|
+ --me tarzan
|
|
|
|
|
+
|
|
|
|
|
+=head3 baseurl
|
|
|
|
|
+
|
|
|
|
|
+URI for your Git management solution. Currently github and gogs are supported.
|
|
|
|
|
+
|
|
|
|
|
+ --baseurl https://api.github.com
|
|
|
|
|
+ --baseurl https://gogs.mydomain.test/api/v1
|
|
|
|
|
+
|
|
|
|
|
+=head3 gogs
|
|
|
|
|
+
|
|
|
|
|
+Whether your baseurl is a gogs installation.
|
|
|
|
|
+
|
|
|
|
|
+While the APIs between gogs and github are compatible there are slight differences which must be accounted for.
|
|
|
|
|
+
|
|
|
|
|
+ --gogs
|
|
|
|
|
+
|
|
|
|
|
+=head3 mirror
|
|
|
|
|
+
|
|
|
|
|
+URI for a git management solution you wish to use for mirroring the repos at the baseurl. May be passed multiple times.
|
|
|
|
|
+
|
|
|
|
|
+ --mirror https://on-prem.github.local/api/
|
|
|
|
|
+
|
|
|
|
|
+=head3 token
|
|
|
|
|
+
|
|
|
|
|
+Token for a particular baseurl or mirror. Of the format domain:token.
|
|
|
|
|
+
|
|
|
|
|
+ --token my.domain.test:DEADBEEF
|
|
|
|
|
+
|
|
|
|
|
+You can omit the auth token on gogs, as we can create them automatically (we will prompt for your password).
|
|
|
|
|
+
|
|
|
|
|
+=head3 user
|
|
|
|
|
+
|
|
|
|
|
+Clone all of this user's repositories. May be passed multiple times.
|
|
|
|
|
+
|
|
|
|
|
+ --user fred
|
|
|
|
|
+
|
|
|
|
|
+=head3 org
|
|
|
|
|
+
|
|
|
|
|
+Clone all of this organization's repositories. May be passed multiple times.
|
|
|
|
|
+
|
|
|
|
|
+ --org 'Granite-Industries'
|
|
|
|
|
+
|
|
|
|
|
+=head3 alias
|
|
|
|
|
+
|
|
|
|
|
+Map a user/org on your baseurl to a mirror. Of the format base_user:mirror_domain:mirror_user.
|
|
|
|
|
+
|
|
|
|
|
+Obviously won't work if the mirror is on the same hostname as the baseurl; use a subdomain at the very least.
|
|
|
|
|
+
|
|
|
|
|
+ --alias george:sprockets.spacely.local:gjetson
|
|
|
|
|
+
|
|
|
|
|
+=head3 nossh
|
|
|
|
|
+
|
|
|
|
|
+Don't use SSH clone URIs. Useful for read-only clones & deployments with no ssh-agent.
|
|
|
|
|
+
|
|
|
|
|
+ --nossh
|
|
|
|
|
+
|
|
|
|
|
+=head1 CONSEQUENTIAL OPTIONS
|
|
|
|
|
+
|
|
|
|
|
+=head3 insecure
|
|
|
|
|
+
|
|
|
|
|
+Allow insecure mirrors or baseurls. This is just to prevent footgunning by passing auth over plaintext.
|
|
|
|
|
+
|
|
|
|
|
+ --insecure
|
|
|
|
|
+
|
|
|
|
|
+=head3 create
|
|
|
|
|
+
|
|
|
|
|
+Automatically create a copy of the repo on the mirror if it doesn't exist.
|
|
|
|
|
+
|
|
|
|
|
+ --create
|
|
|
|
|
+
|
|
|
|
|
+=head3 private
|
|
|
|
|
+
|
|
|
|
|
+If --create is passed, also mirror repositories marked as private, preserving privacy.
|
|
|
|
|
+
|
|
|
|
|
+=head3 sync
|
|
|
|
|
+
|
|
|
|
|
+Force push all refs onto the mirror(s).
|
|
|
|
|
+
|
|
|
|
|
+ --sync
|
|
|
|
|
|
|
|
=cut
|
|
=cut
|
|
|
|
|
|
|
@@ -51,40 +158,75 @@ my $domainRipper = qr{^\w+://([\w|\.]+)};
|
|
|
sub main {
|
|
sub main {
|
|
|
my @args = @_;
|
|
my @args = @_;
|
|
|
|
|
|
|
|
- my $help;
|
|
|
|
|
- my ($users, $orgs, $aliases, $tokens, $mirrors, $baseurl, $gogs, $me, $insecure) = ([],[],[],[],[],"", 0, "", 0);
|
|
|
|
|
|
|
+ my %options = (
|
|
|
|
|
+ help => undef,
|
|
|
|
|
+ users => [],
|
|
|
|
|
+ orgs => [],
|
|
|
|
|
+ aliases => [],
|
|
|
|
|
+ tokens => [],
|
|
|
|
|
+ mirrors => [],
|
|
|
|
|
+ baseurl => "",
|
|
|
|
|
+ me => undef,
|
|
|
|
|
+ create => undef,
|
|
|
|
|
+ sync => undef,
|
|
|
|
|
+ gogs => undef,
|
|
|
|
|
+ insecure => undef,
|
|
|
|
|
+ nossh => undef,
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ # Allow options to override configuration
|
|
|
|
|
+ my $home = $ENV{HOME};
|
|
|
|
|
+ mkdir "$home/.git" unless -d "$home/.git";
|
|
|
|
|
+ my $config_file = "$home/.git/clone-entity.cfg";
|
|
|
|
|
+ if (-f $config_file) {
|
|
|
|
|
+ my $conf = Config::Simple->new($config_file);
|
|
|
|
|
+ my %config;
|
|
|
|
|
+ %config = %{$conf->param(-block => 'default')} if $conf;
|
|
|
|
|
+
|
|
|
|
|
+ # Merge the configuration with the options
|
|
|
|
|
+ foreach my $opt (keys(%options)) {
|
|
|
|
|
+ if ( ref $options{$opt} eq 'ARRAY' ) {
|
|
|
|
|
+ next unless exists $config{$opt};
|
|
|
|
|
+ my @arrayed = ref $config{$opt} eq 'ARRAY' ? @{$config{$opt}} : ($config{$opt});
|
|
|
|
|
+ push(@{$options{$opt}}, @arrayed);
|
|
|
|
|
+ next;
|
|
|
|
|
+ }
|
|
|
|
|
+ $options{$opt} = $config{$opt} if exists $config{$opt};
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
GetOptionsFromArray(\@args,
|
|
GetOptionsFromArray(\@args,
|
|
|
- 'me=s' => \$me,
|
|
|
|
|
- 'user=s@' => \$users,
|
|
|
|
|
- 'alias=s@' => \$aliases,
|
|
|
|
|
- 'token=s@' => \$tokens,
|
|
|
|
|
- 'org=s@' => \$orgs,
|
|
|
|
|
- 'baseurl=s' => \$baseurl,
|
|
|
|
|
- 'mirror=s@' => \$mirrors,
|
|
|
|
|
- 'gogs' => \$gogs,
|
|
|
|
|
- 'insecure' => \$insecure,
|
|
|
|
|
- 'help' => \$help,
|
|
|
|
|
|
|
+ 'me=s' => \$options{me},
|
|
|
|
|
+ 'user=s@' => \$options{users},
|
|
|
|
|
+ 'alias=s@' => \$options{aliases},
|
|
|
|
|
+ 'token=s@' => \$options{tokens},
|
|
|
|
|
+ 'org=s@' => \$options{orgs},
|
|
|
|
|
+ 'baseurl=s' => \$options{baseurl},
|
|
|
|
|
+ 'mirror=s@' => \$options{mirrors},
|
|
|
|
|
+ 'gogs' => \$options{gogs},
|
|
|
|
|
+ 'insecure' => \$options{insecure},
|
|
|
|
|
+ 'nossh' => \$options{nossh},
|
|
|
|
|
+ 'help' => \$options{help},
|
|
|
);
|
|
);
|
|
|
|
|
|
|
|
- return _help() if $help;
|
|
|
|
|
- return _help(1, "Must pass at least one user or organization") unless (@$users + @$orgs);
|
|
|
|
|
- return _help(2, "Must pass baseurl") unless $baseurl;
|
|
|
|
|
- return _help(3, "Must pass your username as --me") unless $me;
|
|
|
|
|
|
|
+ return _help() if $options{help};
|
|
|
|
|
+ return _help(1, "Must pass at least one user or organization") unless (@{$options{users}} + @{$options{orgs}});
|
|
|
|
|
+ return _help(2, "Must pass baseurl") unless $options{baseurl};
|
|
|
|
|
+ return _help(3, "Must pass your username as --me") unless $options{me};
|
|
|
|
|
|
|
|
# Parse Alias mappings
|
|
# Parse Alias mappings
|
|
|
my %alias_map;
|
|
my %alias_map;
|
|
|
- if (@$aliases) {
|
|
|
|
|
- foreach my $arg (@$aliases) {
|
|
|
|
|
|
|
+ if (@{$options{aliases}}) {
|
|
|
|
|
+ foreach my $arg (@{$options{aliases}}) {
|
|
|
my ($actual, $domain, $alias) = split(/:/, $arg);
|
|
my ($actual, $domain, $alias) = split(/:/, $arg);
|
|
|
return _help(3, "aliases must be of the form user:domain:alias") unless $actual && $domain && $alias;
|
|
return _help(3, "aliases must be of the form user:domain:alias") unless $actual && $domain && $alias;
|
|
|
$alias_map{$domain}{$actual} = $alias;
|
|
$alias_map{$domain}{$actual} = $alias;
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- my ($primary_domain) = $baseurl =~ $domainRipper;
|
|
|
|
|
|
|
+ my ($primary_domain) = $options{baseurl} =~ $domainRipper;
|
|
|
my %tokens;
|
|
my %tokens;
|
|
|
- foreach my $tok (@$tokens) {
|
|
|
|
|
|
|
+ foreach my $tok (@{$options{tokens}}) {
|
|
|
my ($domain, $token) = split(/:/, $tok);
|
|
my ($domain, $token) = split(/:/, $tok);
|
|
|
return _help(4, "tokens must be of the form domain:token") unless $domain && $token;
|
|
return _help(4, "tokens must be of the form domain:token") unless $domain && $token;
|
|
|
$tokens{$domain} = $token;
|
|
$tokens{$domain} = $token;
|
|
@@ -92,39 +234,38 @@ sub main {
|
|
|
|
|
|
|
|
my $primary_token = $tokens{$primary_domain};
|
|
my $primary_token = $tokens{$primary_domain};
|
|
|
my %args = (
|
|
my %args = (
|
|
|
- user => $me,
|
|
|
|
|
- api_uri => $baseurl,
|
|
|
|
|
|
|
+ user => $options{me},
|
|
|
|
|
+ api_uri => $options{baseurl},
|
|
|
);
|
|
);
|
|
|
$args{token} = $primary_token if $primary_token;
|
|
$args{token} = $primary_token if $primary_token;
|
|
|
|
|
|
|
|
# It's important which is the primary, because we can have only one pull url, and many push urls.
|
|
# It's important which is the primary, because we can have only one pull url, and many push urls.
|
|
|
- my $local = $gogs ? Gogs->new(%args) : Pithub->new( %args );
|
|
|
|
|
|
|
+ my $local = $options{gogs} ? Gogs->new(%args) : Pithub->new( %args );
|
|
|
|
|
|
|
|
# If the primary is gogs and we have no token passed, let's make one.
|
|
# If the primary is gogs and we have no token passed, let's make one.
|
|
|
my $password;
|
|
my $password;
|
|
|
- if (!$primary_token && $gogs) {
|
|
|
|
|
|
|
+ if (!$primary_token && $options{gogs}) {
|
|
|
_help(5, "Program must be run interactively to auto-create keys on Gogs installs.") unless IO::Interactive::Tiny::is_interactive();
|
|
_help(5, "Program must be run interactively to auto-create keys on Gogs installs.") unless IO::Interactive::Tiny::is_interactive();
|
|
|
# Stash the password in case we gotta clean up
|
|
# Stash the password in case we gotta clean up
|
|
|
$password = _prompt("Please type in the password for ".$local->user.":");
|
|
$password = _prompt("Please type in the password for ".$local->user.":");
|
|
|
$primary_token = $local->get_token(
|
|
$primary_token = $local->get_token(
|
|
|
name => "git-clone-entity",
|
|
name => "git-clone-entity",
|
|
|
password => $password,
|
|
password => $password,
|
|
|
- insecure => $insecure,
|
|
|
|
|
|
|
+ insecure => $options{insecure},
|
|
|
);
|
|
);
|
|
|
_help(6, "Could not fetch token from gogs! Check that you supplied the correct username & password.") unless $primary_token;
|
|
_help(6, "Could not fetch token from gogs! Check that you supplied the correct username & password.") unless $primary_token;
|
|
|
$local->token($primary_token);
|
|
$local->token($primary_token);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- my $cleanup = sub { _cleanup_token( $local, $password, $insecure ) if $password };
|
|
|
|
|
|
|
+ my $cleanup = sub { _cleanup_token( $local, $password, $options{insecure} ) if $password };
|
|
|
|
|
|
|
|
- # TODO XXX this is not appending /api/v1 for some reason
|
|
|
|
|
- my @repos_local = _fetch_all($local, $users, $orgs);
|
|
|
|
|
- _help(7, "Server at $baseurl could not list repos!", $cleanup ) unless @repos_local;
|
|
|
|
|
|
|
+ my @repos_local = _fetch_all($local, $options{users}, $options{orgs});
|
|
|
|
|
+ _help(7, "Server at $options{baseurl} could not list repos!", $cleanup ) unless @repos_local;
|
|
|
|
|
|
|
|
- my %repos_mirror;
|
|
|
|
|
- foreach my $mirror_url (@$mirrors) {
|
|
|
|
|
|
|
+ my @repos_mirror;
|
|
|
|
|
+ foreach my $mirror_url (@{$options{mirrors}}) {
|
|
|
my ($mirror_domain) = $mirror_url =~ $domainRipper;
|
|
my ($mirror_domain) = $mirror_url =~ $domainRipper;
|
|
|
- my $muser = $me;
|
|
|
|
|
|
|
+ my $muser = $options{me};
|
|
|
$muser = $alias_map{$mirror_domain}{$muser} if exists $alias_map{$mirror_domain}{$muser};
|
|
$muser = $alias_map{$mirror_domain}{$muser} if exists $alias_map{$mirror_domain}{$muser};
|
|
|
my %margs = (
|
|
my %margs = (
|
|
|
user => $muser,
|
|
user => $muser,
|
|
@@ -133,26 +274,53 @@ sub main {
|
|
|
$args{token} = $tokens{$mirror_domain} if $tokens{$mirror_domain};
|
|
$args{token} = $tokens{$mirror_domain} if $tokens{$mirror_domain};
|
|
|
|
|
|
|
|
my $mirror = Pithub->new( api_uri => $mirror_url );
|
|
my $mirror = Pithub->new( api_uri => $mirror_url );
|
|
|
- $repos_mirror{$mirror_url} = _fetch_all($mirror, $users, $orgs, \%alias_map);
|
|
|
|
|
- _help(8, "The provided mirror ($mirror_url) could not list repos!", $cleanup ) unless @{$repos_mirror{$mirror_url}};
|
|
|
|
|
|
|
+ my @fetched = _fetch_all($mirror, $options{users}, $options{orgs}, \%alias_map);
|
|
|
|
|
+ _help(8, "The provided mirror ($mirror_url) could not list repos!", $cleanup ) unless @fetched;
|
|
|
|
|
+ push(@repos_mirror, @fetched);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- # Clean up
|
|
|
|
|
|
|
+ # Build a map of names to clone URIs
|
|
|
|
|
+ my $field_name = $options{nossh} ? 'clone_url' : 'ssh_url';
|
|
|
|
|
+ my %names2clone = map { $_->{name} => $_->{$field_name} } @repos_local;
|
|
|
|
|
+ my %mirror_uris;
|
|
|
|
|
+ foreach my $repo (@repos_mirror) {
|
|
|
|
|
+ $mirror_uris{$repo->{name}} //= [];
|
|
|
|
|
+ push( @{$mirror_uris{$repo->{name}}}, $repo->{$field_name});
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
$cleanup->();
|
|
$cleanup->();
|
|
|
|
|
|
|
|
use Data::Dumper;
|
|
use Data::Dumper;
|
|
|
- die Dumper(\%repos_mirror, \@repos_local);
|
|
|
|
|
|
|
+ die Dumper(\%names2clone, \%mirror_uris);
|
|
|
|
|
+
|
|
|
|
|
+ my @to_create;
|
|
|
|
|
+ foreach my $to_clone (keys(%names2clone)) {
|
|
|
|
|
+ my $res = Git::command_oneline([ 'clone', $names2clone{$to_clone} ]);
|
|
|
|
|
+ my $repo = Git->repository(Directory => $to_clone);
|
|
|
|
|
+
|
|
|
|
|
+ #TODO check privacy field
|
|
|
|
|
+
|
|
|
|
|
+ if (!exists $mirror_uris{$to_clone}) {
|
|
|
|
|
+ #TODO push into @to_create if $create
|
|
|
|
|
+ next;
|
|
|
|
|
+ }
|
|
|
|
|
+ # TODO check if the repo is missing on just *some* of the mirrors and add to @to_create if $create
|
|
|
|
|
+
|
|
|
|
|
+ foreach my $mirror (@{$mirror_uris{$to_clone}}) {
|
|
|
|
|
+ # The real "magic" here -- you can have many push urls.
|
|
|
|
|
+ $repo->command(qw{git remote set-url --add --push}, 'origin', $mirror);
|
|
|
|
|
+ #TODO make sure they are all synced up if --sync
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ # Clean up
|
|
|
|
|
+ $cleanup->();
|
|
|
|
|
+ return 0;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
sub _cleanup_token {
|
|
sub _cleanup_token {
|
|
|
my ( $api, $password, $insecure ) = @_;
|
|
my ( $api, $password, $insecure ) = @_;
|
|
|
-
|
|
|
|
|
- my $tok = $api->token();
|
|
|
|
|
- # unset the token, so that we use simple auth once more
|
|
|
|
|
- $api->token("");
|
|
|
|
|
-
|
|
|
|
|
- my $result = $api->delete_token( sha1 => $tok, password => $password, insecure => $insecure );
|
|
|
|
|
-
|
|
|
|
|
|
|
+ my $result = $api->delete_token( sha1 => $api->token, password => $password, insecure => $insecure );
|
|
|
die "Could not clean up token" unless $result && $result->response->is_success;
|
|
die "Could not clean up token" unless $result && $result->response->is_success;
|
|
|
}
|
|
}
|
|
|
|
|
|