| 1 | # frozen_string_literal: true |
| 2 | |
| 3 | require "rails_helper" |
| 4 | require "tmpdir" |
| 5 | require "fileutils" |
| 6 | require "open3" |
| 7 | |
| 8 | # Exercises the git mechanics against a small local fixture repo (no network): |
| 9 | # a mirror clone + push must carry every branch, every tag, and the full |
| 10 | # history, and the guardrails (size cap, timeout) must fire. |
| 11 | RSpec.describe RepositoryImportService do |
| 12 | # Builds a bare source repo with two branches (main, feature) and a tag (v1), |
| 13 | # returning its path. Yields nothing; caller cleans up the tmpdir. |
| 14 | def build_source(dir) |
| 15 | work = File.join(dir, "work") |
| 16 | FileUtils.mkdir_p(work) |
| 17 | run = ->(*a) { system(*a, exception: true) } |
| 18 | run.call("git", "init", "-q", "-b", "main", work) |
| 19 | run.call("git", "-C", work, "config", "user.email", "t@e.st") |
| 20 | run.call("git", "-C", work, "config", "user.name", "Test") |
| 21 | File.write(File.join(work, "README.md"), "# Fixture\n") |
| 22 | run.call("git", "-C", work, "add", ".") |
| 23 | run.call("git", "-C", work, "commit", "-qm", "initial") |
| 24 | run.call("git", "-C", work, "tag", "v1") |
| 25 | run.call("git", "-C", work, "checkout", "-q", "-b", "feature") |
| 26 | File.write(File.join(work, "f.txt"), "x\n") |
| 27 | run.call("git", "-C", work, "add", ".") |
| 28 | run.call("git", "-C", work, "commit", "-qm", "feature work") |
| 29 | run.call("git", "-C", work, "checkout", "-q", "main") |
| 30 | |
| 31 | bare = File.join(dir, "source.git") |
| 32 | run.call("git", "clone", "-q", "--bare", work, bare) |
| 33 | bare |
| 34 | end |
| 35 | |
| 36 | def empty_bare(dir) |
| 37 | dest = File.join(dir, "dest.git") |
| 38 | system("git", "init", "--bare", "-q", dest, exception: true) |
| 39 | dest |
| 40 | end |
| 41 | |
| 42 | around do |example| |
| 43 | Dir.mktmpdir("import-svc-spec") do |tmp| |
| 44 | @tmp = tmp |
| 45 | example.run |
| 46 | end |
| 47 | end |
| 48 | |
| 49 | it "imports every branch, tag, and the full history, reporting progress" do |
| 50 | source = build_source(@tmp) |
| 51 | dest = empty_bare(@tmp) |
| 52 | phases = [] |
| 53 | |
| 54 | result = described_class.clone_and_push( |
| 55 | source_url: source, dest_path: dest, |
| 56 | progress: ->(p) { phases << p } |
| 57 | ) |
| 58 | |
| 59 | branches = GitRepositoryService.branches(dest).sort |
| 60 | tags = Open3.capture3("git", "--git-dir", dest, "tag").first.split |
| 61 | |
| 62 | expect(branches).to eq(%w[feature main]) |
| 63 | expect(tags).to eq(%w[v1]) |
| 64 | expect(GitRepositoryService.commit_count(dest, "main")).to eq(1) |
| 65 | expect(GitRepositoryService.commit_count(dest, "feature")).to eq(2) |
| 66 | expect(phases).to eq(%i[cloning pushing fetching_lfs]) |
| 67 | expect(result.lfs_detected).to be(false) |
| 68 | expect(result.size_kb).to be_a(Integer) |
| 69 | end |
| 70 | |
| 71 | it "rejects a repo larger than the size cap" do |
| 72 | source = build_source(@tmp) |
| 73 | dest = empty_bare(@tmp) |
| 74 | |
| 75 | expect { |
| 76 | described_class.clone_and_push(source_url: source, dest_path: dest, max_size_kb: 0) |
| 77 | }.to raise_error(RepositoryImportService::SizeExceeded) |
| 78 | end |
| 79 | |
| 80 | it "fails cleanly on an unreachable source instead of hanging" do |
| 81 | dest = empty_bare(@tmp) |
| 82 | expect { |
| 83 | described_class.clone_and_push(source_url: "https://192.0.2.1/nope.git", |
| 84 | dest_path: dest, timeout: 8) |
| 85 | }.to raise_error(RepositoryImportService::ImportError) |
| 86 | end |
| 87 | |
| 88 | it "terminates a git call that overruns its timeout" do |
| 89 | source = build_source(@tmp) |
| 90 | dest = empty_bare(@tmp) |
| 91 | expect { |
| 92 | described_class.clone_and_push(source_url: source, dest_path: dest, timeout: 0.001) |
| 93 | }.to raise_error(RepositoryImportService::CloneTimeout) |
| 94 | end |
| 95 | end |