git-clone-entity 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. #!/usr/bin/env perl
  2. package Git::CloneEntity;
  3. use strict;
  4. use warnings;
  5. use FindBin::libs;
  6. use Config::Simple;
  7. use Getopt::Long qw{GetOptionsFromArray};
  8. use Pod::Usage;
  9. use Pithub;
  10. use Gogs;
  11. use Git;
  12. use Term::ReadKey();
  13. use IO::Interactive::Tiny();
  14. =head1 DESCRIPTION
  15. It is a common pattern in organizations to have their own git resources, but mirror everything public on one of the big platforms with network effect.
  16. Currently (AD 2024), and for the forseeable future, github.com will be such a platform.
  17. It is also a common pattern to need to clone basically everything for a given user/org when new development environments are instantiated.
  18. Alternatively, you may just want to keep your local development environment up to date for said users/projects.
  19. This program facilitiates cloning your (public) repositories for given users/orgs from either a local gogs/github instance and configuring pushurls for both it and github, or any other github-api compatible mirror(s).
  20. It will warn you whenever a repository is missing from either, so you can make it go whirr appropriately.
  21. Using this you can easily migrate an organization from being entirely on github to using private resources or vice versa.
  22. =head1 IMPORTANT
  23. This assumes that the repo names between the base and mirrors is identical.
  24. =head1 CONFIG FILE
  25. You will notice below that the options of this tool can be quite involved.
  26. To simplify deploying this tool across your organization, you can place a configuration file (Config::Simple) in ~/.git/clone-entity.cfg. Example:
  27. baseurl=https://my-gogs-install.test/api/v1
  28. gogs=true
  29. nossh=true
  30. mirrors=https://api.github.com,https://premise-install.github.local/api
  31. me=jane
  32. Ideally all your users have to do is specify which users/orgs to clone w/mirroring and you should be off to the races.
  33. The name of the setting will be pluralized for any option which may be passed multiple times below.
  34. =head1 USAGE
  35. 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]
  36. =head1 OPTIONS
  37. =head3 me
  38. Your username on the baseurl. Relevant to token use, what is visible, etc.
  39. --me tarzan
  40. =head3 baseurl
  41. URI for your Git management solution. Currently github and gogs are supported.
  42. --baseurl https://api.github.com
  43. --baseurl https://gogs.mydomain.test/api/v1
  44. =head3 gogs
  45. Whether your baseurl is a gogs installation.
  46. While the APIs between gogs and github are compatible there are slight differences which must be accounted for.
  47. --gogs
  48. =head3 mirror
  49. URI for a git management solution you wish to use for mirroring the repos at the baseurl. May be passed multiple times.
  50. --mirror https://on-prem.github.local/api/
  51. =head3 token
  52. Token for a particular baseurl or mirror. Of the format domain:token.
  53. --token my.domain.test:DEADBEEF
  54. You can omit the auth token on gogs, as we can create them automatically (we will prompt for your password).
  55. =head3 user
  56. Clone all of this user's repositories. May be passed multiple times.
  57. --user fred
  58. =head3 org
  59. Clone all of this organization's repositories. May be passed multiple times.
  60. --org 'Granite-Industries'
  61. =head3 alias
  62. Map a user/org on your baseurl to a mirror. Of the format base_user:mirror_domain:mirror_user.
  63. Obviously won't work if the mirror is on the same hostname as the baseurl; use a subdomain at the very least.
  64. --alias george:sprockets.spacely.local:gjetson
  65. =head3 nossh
  66. Don't use SSH clone URIs. Useful for read-only clones & deployments with no ssh-agent.
  67. --nossh
  68. =head1 CONSEQUENTIAL OPTIONS
  69. =head3 insecure
  70. Allow insecure mirrors or baseurls. This is just to prevent footgunning by passing auth over plaintext.
  71. --insecure
  72. =head3 create
  73. Automatically create a copy of the repo on the mirror if it doesn't exist.
  74. --create
  75. =head3 private
  76. If --create is passed, also mirror repositories marked as private, preserving privacy.
  77. =head3 sync
  78. Force push all refs onto the mirror(s).
  79. --sync
  80. =cut
  81. sub _help {
  82. my ($code, $msg, $cb) = @_;
  83. $code //= 0;
  84. $msg //= "";
  85. $cb->() if ref $cb eq 'CODE';
  86. return Pod::Usage::pod2usage( -message => $msg, -exitval => $code);
  87. }
  88. my $domainRipper = qr{^\w+://([\w|\.]+)};
  89. sub main {
  90. my @args = @_;
  91. my %options = (
  92. help => undef,
  93. users => [],
  94. orgs => [],
  95. aliases => [],
  96. tokens => [],
  97. mirrors => [],
  98. baseurl => "",
  99. me => undef,
  100. create => undef,
  101. sync => undef,
  102. gogs => undef,
  103. insecure => undef,
  104. nossh => undef,
  105. );
  106. # Allow options to override configuration
  107. my $home = $ENV{HOME};
  108. mkdir "$home/.git" unless -d "$home/.git";
  109. my $config_file = "$home/.git/clone-entity.cfg";
  110. if (-f $config_file) {
  111. my $conf = Config::Simple->new($config_file);
  112. my %config;
  113. %config = %{$conf->param(-block => 'default')} if $conf;
  114. # Merge the configuration with the options
  115. foreach my $opt (keys(%options)) {
  116. if ( ref $options{$opt} eq 'ARRAY' ) {
  117. next unless exists $config{$opt};
  118. my @arrayed = ref $config{$opt} eq 'ARRAY' ? @{$config{$opt}} : ($config{$opt});
  119. push(@{$options{$opt}}, @arrayed);
  120. next;
  121. }
  122. $options{$opt} = $config{$opt} if exists $config{$opt};
  123. }
  124. }
  125. GetOptionsFromArray(\@args,
  126. 'me=s' => \$options{me},
  127. 'user=s@' => \$options{users},
  128. 'alias=s@' => \$options{aliases},
  129. 'token=s@' => \$options{tokens},
  130. 'org=s@' => \$options{orgs},
  131. 'baseurl=s' => \$options{baseurl},
  132. 'mirror=s@' => \$options{mirrors},
  133. 'gogs' => \$options{gogs},
  134. 'insecure' => \$options{insecure},
  135. 'nossh' => \$options{nossh},
  136. 'help' => \$options{help},
  137. );
  138. return _help() if $options{help};
  139. return _help(1, "Must pass at least one user or organization") unless (@{$options{users}} + @{$options{orgs}});
  140. return _help(2, "Must pass baseurl") unless $options{baseurl};
  141. return _help(3, "Must pass your username as --me") unless $options{me};
  142. # Parse Alias mappings
  143. my %alias_map;
  144. if (@{$options{aliases}}) {
  145. foreach my $arg (@{$options{aliases}}) {
  146. my ($actual, $domain, $alias) = split(/:/, $arg);
  147. return _help(3, "aliases must be of the form user:domain:alias") unless $actual && $domain && $alias;
  148. $alias_map{$domain}{$actual} = $alias;
  149. }
  150. }
  151. my ($primary_domain) = $options{baseurl} =~ $domainRipper;
  152. my %tokens;
  153. foreach my $tok (@{$options{tokens}}) {
  154. my ($domain, $token) = split(/:/, $tok);
  155. return _help(4, "tokens must be of the form domain:token") unless $domain && $token;
  156. $tokens{$domain} = $token;
  157. }
  158. my $primary_token = $tokens{$primary_domain};
  159. my %args = (
  160. user => $options{me},
  161. api_uri => $options{baseurl},
  162. );
  163. $args{token} = $primary_token if $primary_token;
  164. # It's important which is the primary, because we can have only one pull url, and many push urls.
  165. my $local = $options{gogs} ? Gogs->new(%args) : Pithub->new( %args );
  166. # If the primary is gogs and we have no token passed, let's make one.
  167. my $password;
  168. if (!$primary_token && $options{gogs}) {
  169. _help(5, "Program must be run interactively to auto-create keys on Gogs installs.") unless IO::Interactive::Tiny::is_interactive();
  170. # Stash the password in case we gotta clean up
  171. $password = _prompt("Please type in the password for ".$local->user.":");
  172. $primary_token = $local->get_token(
  173. name => "git-clone-entity",
  174. password => $password,
  175. insecure => $options{insecure},
  176. );
  177. _help(6, "Could not fetch token from gogs! Check that you supplied the correct username & password.") unless $primary_token;
  178. $local->token($primary_token);
  179. }
  180. my $cleanup = sub { _cleanup_token( $local, $password, $options{insecure} ) if $password };
  181. my @repos_local = _fetch_all($local, $options{users}, $options{orgs});
  182. _help(7, "Server at $options{baseurl} could not list repos!", $cleanup ) unless @repos_local;
  183. my @repos_mirror;
  184. foreach my $mirror_url (@{$options{mirrors}}) {
  185. my ($mirror_domain) = $mirror_url =~ $domainRipper;
  186. my $muser = $options{me};
  187. $muser = $alias_map{$mirror_domain}{$muser} if exists $alias_map{$mirror_domain}{$muser};
  188. my %margs = (
  189. user => $muser,
  190. api_uri => $mirror_url,
  191. );
  192. $args{token} = $tokens{$mirror_domain} if $tokens{$mirror_domain};
  193. my $mirror = Pithub->new( api_uri => $mirror_url );
  194. my @fetched = _fetch_all($mirror, $options{users}, $options{orgs}, \%alias_map);
  195. _help(8, "The provided mirror ($mirror_url) could not list repos!", $cleanup ) unless @fetched;
  196. push(@repos_mirror, @fetched);
  197. }
  198. # Build a map of names to clone URIs
  199. my $field_name = $options{nossh} ? 'clone_url' : 'ssh_url';
  200. my %names2clone = map { $_->{name} => $_->{$field_name} } @repos_local;
  201. my %mirror_uris;
  202. foreach my $repo (@repos_mirror) {
  203. $mirror_uris{$repo->{name}} //= [];
  204. push( @{$mirror_uris{$repo->{name}}}, $repo->{$field_name});
  205. }
  206. $cleanup->();
  207. use Data::Dumper;
  208. die Dumper(\%names2clone, \%mirror_uris);
  209. my @to_create;
  210. foreach my $to_clone (keys(%names2clone)) {
  211. my $res = Git::command_oneline([ 'clone', $names2clone{$to_clone} ]);
  212. my $repo = Git->repository(Directory => $to_clone);
  213. #TODO check privacy field
  214. if (!exists $mirror_uris{$to_clone}) {
  215. #TODO push into @to_create if $create
  216. next;
  217. }
  218. # TODO check if the repo is missing on just *some* of the mirrors and add to @to_create if $create
  219. foreach my $mirror (@{$mirror_uris{$to_clone}}) {
  220. # The real "magic" here -- you can have many push urls.
  221. $repo->command(qw{git remote set-url --add --push}, 'origin', $mirror);
  222. #TODO make sure they are all synced up if --sync
  223. }
  224. }
  225. # Clean up
  226. $cleanup->();
  227. return 0;
  228. }
  229. sub _cleanup_token {
  230. my ( $api, $password, $insecure ) = @_;
  231. my $result = $api->delete_token( sha1 => $api->token, password => $password, insecure => $insecure );
  232. die "Could not clean up token" unless $result && $result->response->is_success;
  233. }
  234. sub _prompt {
  235. my ( $prompt ) = @_;
  236. $prompt ||= "";
  237. my $input = "";
  238. print $prompt;
  239. # We are readin a password
  240. Term::ReadKey::ReadMode('noecho');
  241. {
  242. local $SIG{'INT'} = sub { Term::ReadKey::ReadMode(0); exit 130; };
  243. $input = <STDIN>;
  244. chomp($input) if $input;
  245. }
  246. Term::ReadKey::ReadMode(0);
  247. print "\n";
  248. return $input;
  249. }
  250. sub _fetch_all {
  251. my ($api, $users, $orgs, $alias_map) = @_;
  252. my ($domain) = $api->api_uri =~ $domainRipper;
  253. my @repos;
  254. foreach my $user (@$users) {
  255. $user = $alias_map->{$domain}{$user} if exists $alias_map->{$domain}{$user};
  256. my $result = $api->repos->list( user => $user );
  257. push(@repos, _array_content($result));
  258. }
  259. foreach my $org (@$orgs) {
  260. $org = $alias_map->{$domain}{$org} if exists $alias_map->{$domain}{$org};
  261. my $result = $api->repos->list( org => $org );
  262. push(@repos, _array_content($result));
  263. }
  264. return @repos;
  265. }
  266. sub _array_content {
  267. my ($result) = @_;
  268. return () unless $result && $result->response->is_success;
  269. return @{$result->content()} if ref $result->content() eq 'ARRAY';
  270. return ();
  271. }
  272. exit main(@ARGV) unless caller;
  273. 1;