Gitea-to-Forgejo
03 Dec 2023私はGitLab、GiteaそしてForgejoをそれぞれ自前のインスタンスとしてホスティングしている。なぜ3種類ものGitサーバーをホスティングしているのかは別の機会に書くとして、Giteaに溜まっているリポジトリをForgejoにアーカイブしていくというのが主な目的である。
これ自体はかなり殊勝なのであまり誰の役にも立たない情報だと思うけれども、この2日間で多くの学びが多かったため残しておこうと思う。
- はじめに
- うまくいったこと
- うまくいかなかったこと
はじめに
本来であればGitHubのプライベートリポジトリにどんどんコードをプッシュしていると思う。ただ、私は昨今のGitHubないしクラウドに自分のコードを預けるのはやめたいと思っている。かつてGitHubはプライベートのリポジトリを持つのが有料プランのオプションだった。それがBitBucketやGitLabに倣ったのかプライベートリポジトリは無料のアカウントで誰でもコードをプッシュできるようになった。このことは単純に喜ばしいことなのか、あるいはその逆なのか。私は後者だった。
リポジトリの移行などというが、どちらももとをたどれば同じGitea同士なので本来であればシェルスクリプトないし、JavaScriptやPythonなどで移行のためのスクリプトを書けばものの数分で終わったかもしれない。過去に何度か似たようなスクリプトを書いてはいたのだけれども、そのたびにGiteaのSwaggerのAPIを参照していた。今回はこれをやめたいと思っていた。当初その予定はなかったのだが、いい感じにできればこれをウェブアプリケーションとしてもよいだろう。
あるいはRustとかGoなんかの言語の題材としてもよかったかもしれない。ただしこれを着手するのであればおそらく2日間では終わらなかっただろう。やはりRubyという言語が改めて自分の腕になじむ言語だなぁと再確認するよい機会でもあった。
まずは今回のタスクを実行するために必要なことをかんたんにまとめておこう。
- gitea(移行元)のorganizationに保存してあるリポジトリ一覧を取得
- forgejo(移行先)に同名のorganizationと同名のリポジトリを作成する
- giteaのリポジトリを
git clone --bare <SSH_URL>
する - forgejoのリポジトリへ
git push --mirror <SSH_URL>
する
これだけである。 1と2でHTTP経由でGiteaとForgejoのAPI通信して、3と4ではgitコマンドを使えばよい。移行したいリポジトリもせいぜい30~40個くらいしかないのでシェルスクリプトで十分である。
わざわざこんなことをしなくても、POST /repos/migrate
を使えば済むのでは?と思ったのだけれども、こちらの挙動がそもそも怪しいのが発端だった。別にWikiやIssueは特に必要としていないので単純にgitの操作だけですべて完結する。
そしてこれが今回作ったコードの一部だ:
# 可読性のため改行を余分に加えているが、掲載しているコードそのものは変更していない
module GiteaToForgejo::Commands
class Migrate < Command
desc "Migrated repositories"
argument :org, type: :string, required: true, desc: "Organization"
def call(org:)
# eliminate_duplicatesクラスを初期化する
gitea_repos = gitea.orgs_repos(org)
.map { GiteaRepository.new(_1) }
forgejo_repos = forgejo.orgs_repos(org)
.map { ForgejoRepository.new(_1) }
eliminate_duplicates = EliminateDuplicates.new(
gitea_repos,
forgejo_repos
)
# giteaに存在していてforgejoに存在しないリポジトリを作成する
eliminate_duplicates.select(:full_name)
.each { |repo| forgejo.create_orgs_repos(org, repo.name) }
# giteaのリポジトリをforgejoに移行する
# 409 = EmptyRepository
eliminate_duplicates.combine(:full_name)
.select { |repo|
forgejo.repos_commits(repo.full_name).code == 409
}
.each { GitMigrate.call(_1) }
end
end
end
そしてGitMigrateクラスの中身はこうだ:
module GiteaToForgejo::GitMigrate
class << self
def call(repository)
GiteaToForgejo::Clients::Git.new(repository)
.create_a_bare_clone_of_the_repository
.mirror_push_to_the_new_repository
.remove_the_temporary_local_repository
end
end
end
我ながら今回はよく書けたと思う。
とくにこのGitMigrateというクラスはMirroring a repositoryの説明そのものとして再現できている。
日頃の仕事で触るRailsはもちろんこんな書き方は間違ってもできない。 MVCであるはずなのにView層にロジックが含まれていて、金額の計算などをしている。もとのクラスを私が作ったわけではないのだが、もしかするとそういう諸々のものに対する反発心なのかもしれない。
さながら子供向けのプログラミング言語のようではないだろうか。なぜこのような書き方がよいかと思うと、プログラミングは本来Rubyであろうがなかろうがこう書くべきであろうと思っている。子供でも大人でも誰もが理解できるプログラムにまで落とし込むべきであろう。
究極まで手続き型のプログラミングはやはり理想だけれども、ただただ真っ直ぐにコードを書いてしまうとシェルスクリプトになってしまうし、それならオブジェクトの概念や関数型言語の解釈を自分なりに加えてみたい。この裏に隠れているコードたちのおかげでまっすぐ手続き型で書くよりはコード量は多いかもしれない。しかし手続き型で発生しがちな無意味な重複はおそらく可能な限り排除できたのではないかなと思う。
EliminateDuplicatesというクラス以外にも、GiteaRepositoryとForgejoRepositoryというクラスがある。これらはRepositoryというクラスから継承している。これがもしGiteaベースではなくても個別にクラスを定義すれば済むのでシェルスクリプトよりは複雑だけれども、個々のクラスはそこまで難しくはしていない。
dry-cliを使った
まず今回はRailsではないということを強く意識した。
Ruby ≒ Rails
ではあるものの、Rails以外にも素晴らしいフレームワークは存在している。
例えばまだ実際に使ったことはないものの、Hanamiはまさにその筆頭である。
そのHanamiつながりでdry-rbというライブラリ群があって、今回はそのdry-cliというgemを利用している。
このライブラリのおかげでそれっぽいコマンドにできた:
$ ./bin/gitea-to-forgejo
Commands:
gitea-to-forgejo create-repo ORG NAME # Create repository
gitea-to-forgejo diff # View Organization diffs
gitea-to-forgejo fetch-repo ORG NAME # Fetch repository
gitea-to-forgejo migrate ORG # Migrated repositories
gitea-to-forgejo version # Print version
コマンド化するにあたって本来であればOptionParserやThorなどが候補にあったが、dry-cliはこれらのコマンドをクラスとして定義できるのがよさそうだった。
普段意識しないrequire
の書き方
これもRailsを使えばほとんどのクラスは勝手に定義されているのだけれども、本来であればrequire
を適切に使わないとRubyはかんたんに壊れるのだということを学んだ。
昔はなんとなくRubyのプログラムを書き始めるときはGitHubに公開されているプロジェクトをならって意味もなくlib
ディレクトリなどを作っていたが、ある程度の規模になってくるとこのあたりを手動で定義するのもひとつの学びに近い:
#!/usr/bin/env ruby
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
require "bundler/setup"
require "gitea_to_forgejo"
Dry::CLI.new(GiteaToForgejo::Commands).call
# gitea_to_forgejo.rb
require "dotenv/load"
require "dry/cli"
require "httparty"
require "open3"
require "tmpdir"
require "gitea_to_forgejo/version"
require "gitea_to_forgejo/eliminate_duplicates"
require "gitea_to_forgejo/repository"
require "gitea_to_forgejo/gitea_repository"
require "gitea_to_forgejo/forgejo_repository"
require "gitea_to_forgejo/clients/operations"
require "gitea_to_forgejo/clients/gitea"
require "gitea_to_forgejo/clients/forgejo"
require "gitea_to_forgejo/clients/git"
require "gitea_to_forgejo/git_migrate"
require "gitea_to_forgejo/commands"
このような感じでぱっと見るとよくわかりにくいかもしれないが、このクラスのrequire
する順番が実は重要だったりする。Node.JSのimport
はもっと直感的なのでエディタでOrganize imports
なんてコマンドが使えたりするけれども、ここではcommands.rb
を除いてすべてのrequire
はここで管理するようにしている。
例えば先程のコード:
module GiteaToForgejo::Commands
class Migrate < Command
end
end
この書き方だとRuboCop的にはcompact
あるいはnested
しか受け付けていないためうまくいかないのだが、個人的にはnested
でかつcompact
な書き方は普通にありだと思う。
# インデントがどんどん削れる...
module GiteaToForgejo
module Commands
class Migrate < Command
end
end
end
# 横に長い...
class GiteaToForgejo::Commands::Migrate < GiteaToForgejo::Commands::Command
end
関数型っぽい書き方
RubyはPythonとは違って完全にオブジェクト指向言語なのであるのだが、map
とかfilter
あたりが使える頃から関数型言語のエッセンスを取り入れることができた。
ただ関数の参照渡しができないのでJavaScriptやPythonなどと比べると「っぽい」から脱却できないけれども、個人的にはこの関数型っぽい書き方はRubyが3系になってから最近特に意識しているものかもしれない。
私自身純粋な関数型言語に触れていないのであまり多くは語れないのだけれども、Rubyはもうeach
、map
なんかが死ぬほど出てくる。そしてそのセットとしてブロックの存在は欠かせない。
class GiteaToForgejo::EliminateDuplicates
# giteaに存在していてforgejoに存在しないリポジトリ一覧
def select(key)
@combined_repos
.group_by { _1[key] }
.select { |_, group| group.length == 1 }
.values.flatten
end
# giteaとforgejoのssh_urlを持つリポジトリ一覧
def combine(key)
@combined_repos
.group_by { _1[key] }.values
.select { _1.length == 2 }
.each { _1.combine(_2) }
.map { _1.first }.flatten
end
end
例えばこのコードをもし1つのブロックで表現するとどうだろう:
class GiteaToForgejo::EliminateDuplicates
# giteaに存在していてforgejoに存在しないリポジトリ一覧
def select(key)
grouped = @combined_repos.group_by { |repo| repo[key] }
grouped.filter_map do |_, group|
group if group.length == 1
end.flatten
end
# giteaとforgejoのssh_urlを持つリポジトリ一覧
def combine(key)
grouped = @combined_repos.group_by { |repo| repo[key] }
grouped.filter_map do |_, group|
if group.length == 2
group.first.combine(group.last)
group.first
end
end.flatten
end
end
まずはgroup_by
という若干特殊なループ文でcombined_repos
はハッシュに変換される。
そのため1つのブロックで表現するには.group_by.filter_map
とするか、このように変数に初期化しなければならない。
さて、grouped
のブロックの中身はfilter_map
というメソッドのおかげでかなり簡略化されてはいるものの、ブロックの中にif文が登場したりする。
そしてこの書き方の最も美しくないのはend
に.flatten
というメソッドが続くことだ。
end
というキーワードなのに終わりがないのはいかなるものだろうか。これも変数に初期化すればよいのだが、そうなるとローカル変数がたくさん増えてしまう。
少々大げさに書いてみたが、このようなコードは珍しくない。現実にはもっと巨大なブロックでたった1つのeachに対していろいろ書きすぎるコードをよく目にしてきた。当時の自分も含めてそんなコードばかり書いていた。RuboCopでも複数のループを1つにまとめろという内容の警告があったりするが、私はこのコードを書くくらいであれば前者の書き方のほうが統一感があってよいと思う。Fluent Interfaceを題材にした投稿もかつてあったけれども、個人的にはこの書き方に慣れていくほうがよいのではないかと思っている。
いつかはRubyでもElixirのように|>
の構文が採用されることが今後出てくる可能性もあるけれども、個人的にはこのメソッドチェーンの書き方を今後も取り入れていきたい。
個人的にはRuby 3の省略記法はかなり嫌だったのだが、いざ書いてみれば比較的すんなり受け入れることができた。 ブロックの変数をあえて冗長に書くこともRubyのよさのひとつだと思っていたが、RubyではRubyなりの書き方に馴染んでいきたい。
vitepressを使った
これは前々からもやりたいと思っていたところだったが、オープンソースのライブラリを使いたいきっかけは何だろうか?最近はソースコードを直に見ることも増えつつあるのだが、やはりライブラリとして利用するには優れたドキュメントは欠かせない。
個人的な試みとしてTILというリポジトリで最近MKDocsを使い始めたのだが、このようにmarkdownさえ用意すればどこでもドキュメントができてしまうようなプロジェクトを採用すればドキュメントを残すという文化を取り込むことができるかもしれない。
似たようなプロジェクトはいくつかあって、vitepressではなくてもよかったのだが肝要なのはドキュメントを残すことである。プログラムを全く含まないというわけでもないし、独自の記法もあるのでひょっとするとvitepressは今後移行するのに苦労するかもしれないが、まあそのときに考えようと思う。ただし今回のプロジェクトはそこまで頻繁に触る予定はないのでその時作った記録さえ残せればそれでよいだろう。
しかしこれは他の項目と比べるとまだまだ課題が多かった。 私はプログラムをほぼ手探りの状況から書き進めるので、ドキュメントに何を書こうかということをうまくまとめきれない。 それこそコマンドについてまとめればよいとも思うのだが、そうするとわざわざvitepressとして作るよりも単純にプロジェクトのREADMEでも事足りた気がする。 そのためドキュメントに関してはもう少し練習していく必要があると思う。
幸いドキュメントを残すという意識は以前は全くなかったが、やはりGitのリポジトリとして残していくにはこのようにドキュメントに残していかないと後で見返したときにさっぱり何がしたかったのかわからない。もちろん直近のコードであればだいたいどこに何があるかは把握できるのだけれども、書き捨て続けるよりはもう少し質の高いコードを残していきたい。
今回はまだその始まりにすぎない。
minitestを使った
ドキュメント化に親しい内容といえば単体テストである。
こちらも普段はRSpecばかり書いているところを今回はRubyだからという理由でminitestにしてみた。minitestは昔に比べていつの間にかデフォルトで色がつくようになったし、若干特殊な書き方なのを除けば間違いなくRubyである。
今回書いたテストを一部載せようと思う:
require "minitest/autorun"
require "gitea_to_forgejo"
class TestEliminateDuplicates < Minitest::Test
def test_select
eliminate_duplicates = GiteaToForgejo::EliminateDuplicates.new(repos_a, repos_b)
result = eliminate_duplicates.select(:id)
expected = [{ id: "baz", value: "c" }]
# Expected result
assert_equal expected, result
end
private
def repos_a
[
{ id: "foo", value: "a" },
{ id: "bar", value: "b" },
{ id: "baz", value: "c" },
{ id: "qux", value: "d" },
]
end
def repos_b
[
{ id: "foo", value: 222 },
{ id: "bar", value: 333 },
{ id: "qux", value: 444 },
]
end
end
Railsではおなじみのtest_helper.rb
は出てこない。
単純にRakefileだけでこれを実現できるようにしている。
require "minitest/test_task"
require "json"
Minitest::TestTask.create(:test) do |t|
t.libs << "test"
t.libs << "lib"
t.warning = false
t.test_globs = ["test/test_*.rb"]
end
task default: :test
本当にこれだけである。
少なくとも先程書いたrequire
のクラスが問題ないかは確認できるし、テストの実行も心なしかRSpecに比べると早い。
ただできればテストのメソッドはRailsのようにtest "#select"
みたいな書き方にしたかった。
RSpecのような書き方もできるとはいえ、せっかくminitestなのだから古き良き時代の単体テストにこだわろうと思った。
ただしこれも先程定義したはずのcombine
をテストできていない。
これはRSpecのようにモックをどう書けばいいかまだわかっていないからだ。
このようにまだ実用上は思い通りにテストをかけないとテストにばかり時間をかけて本質的にやりたかったことを達成できないことを回避した。ベストではないが、今回はベターな選択ができた。後追いでいくつかはテストを書いていきたいとは思うが、最も理想なのはテストと実装を両方していくのが好ましい。
ドキュメントやテストに残すという点では及第点だし、こうしてブログにつらつらと書いた時間も考えると実装ほどではないにせよずいぶんとたくさんの文字をタイピングしていることに気づく。 コーディングと同様にテストやドキュメントは大事なのだと実感したのであった。