main
rb 143 lines 5 KB
Raw
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