| 1 | # frozen_string_literal: true |
| 2 | |
| 3 | # Runs a repository import off the request thread: creates the destination repo, |
| 4 | # mirror-clones the source and pushes its history in, records LFS/metadata, and |
| 5 | # either finishes cleanly or rolls back so no half-created repo is left behind. |
| 6 | # |
| 7 | # Idempotency & retries: the job is safe to re-run for a failed import. It first |
| 8 | # clears any partial destination from a previous attempt, so a retry starts from |
| 9 | # a clean slate and can't duplicate or corrupt an existing repo. A name that |
| 10 | # collides with a *different* existing repo fails explicitly rather than |
| 11 | # clobbering it. |
| 12 | class RepositoryImportJob < ApplicationJob |
| 13 | queue_as :default |
| 14 | |
| 15 | # Raised when the target name collides with a different existing repo. Its |
| 16 | # message is user-facing, so it's surfaced verbatim on the import. |
| 17 | class NameCollisionError < StandardError; end |
| 18 | |
| 19 | # Don't auto-retry: an import failure is surfaced to the user as a retryable |
| 20 | # state with a message, and retrying blindly could hammer a bad remote. |
| 21 | def perform(import_id) |
| 22 | import = RepositoryImport.find_by(id: import_id) |
| 23 | return if import.nil? |
| 24 | # Guard against double-delivery: only queued or (re-queued) failed imports |
| 25 | # should run. An already-active/done import is left alone. |
| 26 | return unless %w[queued failed].include?(import.status) |
| 27 | |
| 28 | reset_for_retry(import) |
| 29 | |
| 30 | user = import.user |
| 31 | repo = create_destination!(import) |
| 32 | import.update!(repository: repo) |
| 33 | |
| 34 | result = RepositoryImportService.clone_and_push( |
| 35 | source_url: import.source_url, |
| 36 | dest_path: repo.disk_path, |
| 37 | credential: import_credential(import), |
| 38 | progress: ->(phase) { import.transition_to(phase.to_s) } |
| 39 | ) |
| 40 | |
| 41 | import.transition_to("finalizing") |
| 42 | finalize_repo!(repo, import, result) |
| 43 | |
| 44 | import.update!(lfs_detected: result.lfs_detected, lfs_skipped: result.lfs_skipped, |
| 45 | source_size_kb: result.size_kb || import.source_size_kb) |
| 46 | import.transition_to("done") |
| 47 | rescue StandardError => e |
| 48 | handle_failure(import, e) |
| 49 | end |
| 50 | |
| 51 | private |
| 52 | |
| 53 | # Clear any leftover from a previous failed attempt so a retry is clean. |
| 54 | def reset_for_retry(import) |
| 55 | return if import.repository.nil? |
| 56 | |
| 57 | destroy_repo(import.repository) |
| 58 | import.update!(repository: nil) |
| 59 | end |
| 60 | |
| 61 | # Creates the Repository row and the bare repo on disk. Name collisions with a |
| 62 | # different existing repo are rejected here, explicitly. |
| 63 | def create_destination!(import) |
| 64 | user = import.user |
| 65 | existing = user.repositories.find_by("LOWER(name) = ?", import.target_name.downcase) |
| 66 | if existing |
| 67 | raise NameCollisionError, |
| 68 | "A repository named \"#{import.target_name}\" already exists. Rename it or delete it, then retry." |
| 69 | end |
| 70 | |
| 71 | repo = user.repositories.new( |
| 72 | name: import.target_name, |
| 73 | description: import.description, |
| 74 | is_private: import.target_private, |
| 75 | default_branch: import.default_branch.presence || "main", |
| 76 | kind: "code" |
| 77 | ) |
| 78 | repo.disk_path = GitRepositoryService.repo_path(user.username, repo.name) |
| 79 | repo.save! |
| 80 | |
| 81 | GitRepositoryService.create_bare_repo(user.username, repo.name) |
| 82 | repo |
| 83 | end |
| 84 | |
| 85 | # After the push: point HEAD at the imported default branch (so the README |
| 86 | # renders on the landing page) and, for mirror mode, wire up the read-only |
| 87 | # upstream link and stamp the first successful sync. |
| 88 | def finalize_repo!(repo, import, _result) |
| 89 | head = GitRepositoryService.set_head(repo.disk_path, repo.default_branch) |
| 90 | repo.update!(default_branch: head) if head.present? && head != repo.default_branch |
| 91 | |
| 92 | if import.mirror? |
| 93 | repo.update!( |
| 94 | mirror: true, |
| 95 | upstream_url: import.source_url, |
| 96 | upstream_token: import_credential(import), |
| 97 | mirror_status: "ok", |
| 98 | mirror_synced_at: Time.current |
| 99 | ) |
| 100 | end |
| 101 | end |
| 102 | |
| 103 | # The credential to clone/fetch the source with. GitHub private sources use the |
| 104 | # user's connection token; URL imports are unauthenticated (public only), so a |
| 105 | # private URL without credentials fails fast in the clone. |
| 106 | def import_credential(import) |
| 107 | return nil unless import.source_host == "github.com" |
| 108 | |
| 109 | import.user.github_connection&.access_token |
| 110 | end |
| 111 | |
| 112 | def handle_failure(import, error) |
| 113 | return if import.nil? |
| 114 | |
| 115 | # Leave no half-created repo behind. |
| 116 | if import.repository |
| 117 | destroy_repo(import.repository) |
| 118 | import.update!(repository: nil) |
| 119 | end |
| 120 | |
| 121 | message = |
| 122 | case error |
| 123 | when NameCollisionError, |
| 124 | RepositoryImportService::SizeExceeded, |
| 125 | RepositoryImportService::CloneTimeout, |
| 126 | RepositoryImportService::ImportError |
| 127 | error.message |
| 128 | else |
| 129 | Rails.logger.error("Import ##{import.id} failed: #{error.class}: #{error.message}") |
| 130 | "The import failed unexpectedly. You can retry it." |
| 131 | end |
| 132 | |
| 133 | import.transition_to("failed", error: message) |
| 134 | end |
| 135 | |
| 136 | def destroy_repo(repo) |
| 137 | path = repo.disk_path |
| 138 | repo.destroy |
| 139 | FileUtils.rm_rf(path) if path.present? |
| 140 | rescue StandardError => e |
| 141 | Rails.logger.error("Failed to clean up repo #{repo&.id}: #{e.message}") |
| 142 | end |
| 143 | end |