Gitea-to-Forgejo

私はGitLab、GiteaそしてForgejoをそれぞれ自前のインスタンスとしてホスティングしている。なぜ3種類ものGitサーバーをホスティングしているのかは別の機会に書くとして、Giteaに溜まっているリポジトリをForgejoにアーカイブしていくというのが主な目的である。

これ自体はかなり殊勝なのであまり誰の役にも立たない情報だと思うけれども、この2日間で多くの学びが多かったため残しておこうと思う。

  1. はじめに
  2. うまくいったこと
    1. dry-cliを使った
    2. 普段意識しないrequireの書き方
    3. 関数型っぽい書き方
  3. うまくいかなかったこと
    1. vitepressを使った
    2. minitestを使った

はじめに

本来であればGitHubのプライベートリポジトリにどんどんコードをプッシュしていると思う。ただ、私は昨今のGitHubないしクラウドに自分のコードを預けるのはやめたいと思っている。かつてGitHubはプライベートのリポジトリを持つのが有料プランのオプションだった。それがBitBucketやGitLabに倣ったのかプライベートリポジトリは無料のアカウントで誰でもコードをプッシュできるようになった。このことは単純に喜ばしいことなのか、あるいはその逆なのか。私は後者だった。

リポジトリの移行などというが、どちらももとをたどれば同じGitea同士なので本来であればシェルスクリプトないし、JavaScriptやPythonなどで移行のためのスクリプトを書けばものの数分で終わったかもしれない。過去に何度か似たようなスクリプトを書いてはいたのだけれども、そのたびにGiteaのSwaggerのAPIを参照していた。今回はこれをやめたいと思っていた。当初その予定はなかったのだが、いい感じにできればこれをウェブアプリケーションとしてもよいだろう。

あるいはRustとかGoなんかの言語の題材としてもよかったかもしれない。ただしこれを着手するのであればおそらく2日間では終わらなかっただろう。やはりRubyという言語が改めて自分の腕になじむ言語だなぁと再確認するよい機会でもあった。

まずは今回のタスクを実行するために必要なことをかんたんにまとめておこう。

  1. gitea(移行元)のorganizationに保存してあるリポジトリ一覧を取得
  2. forgejo(移行先)に同名のorganizationと同名のリポジトリを作成する
  3. giteaのリポジトリをgit clone --bare <SSH_URL>する
  4. 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はもうeachmapなんかが死ぬほど出てくる。そしてそのセットとしてブロックの存在は欠かせない。

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のようにモックをどう書けばいいかまだわかっていないからだ。

このようにまだ実用上は思い通りにテストをかけないとテストにばかり時間をかけて本質的にやりたかったことを達成できないことを回避した。ベストではないが、今回はベターな選択ができた。後追いでいくつかはテストを書いていきたいとは思うが、最も理想なのはテストと実装を両方していくのが好ましい。

ドキュメントやテストに残すという点では及第点だし、こうしてブログにつらつらと書いた時間も考えると実装ほどではないにせよずいぶんとたくさんの文字をタイピングしていることに気づく。 コーディングと同様にテストやドキュメントは大事なのだと実感したのであった。