# frozen_string_literal: true class Site # Acciones para el repositorio Git de un sitio. Por ahora hacemos un # uso muy básico de Git, con lo que asumimos varias cosas, por ejemplo # que un sitio tiene un solo origen, que siempre se trabaja con la # rama master, etc. class Repository attr_reader :rugged, :path # @param [String] la ruta del repositorio def initialize(path) @path = path @rugged = Rugged::Repository.new(path) end # Obtiene la rama por defecto a partir de la referencia actual # # Por ejemplo "refs/heads/no-master" => "no-master" # # XXX: No memoizamos para obtener siempre el nombre de la rama # actual, aunque por ahora asumimos que siempre estamos en la misma, # internamente (ej. vía shell) podríamos cambiarla. # # @return [String] def default_branch rugged.head.canonical_name.split('/', 3).last end # Obtiene el origin # # @return [Rugged::Remote, nil] def origin @origin ||= rugged.remotes.find do |remote| remote.name == 'origin' end end # Trae los cambios del repositorio de origen sin aplicarlos y # devuelve la cantidad de commits pendientes. # # XXX: Prestar atención a la velocidad de respuesta cuando tengamos # repositorios remotos. # # @return [Integer] def fetch if origin.check_connection(:fetch, credentials: credentials) rugged.fetch(origin, credentials: credentials)[:received_objects] else 0 end end # Incorpora los cambios en el repositorio actual # # @return [Rugged::Commit] def merge(usuarie, message = I18n.t('sites.fetch.merge.message')) merge = rugged.merge_commits(head_commit, remote_head_commit) # No hacemos nada si hay conflictos, pero notificarnos begin raise MergeConflictsException if merge.conflicts? rescue MergeConflictsException => e ExceptionNotifier.notify_exception(e, data: { path: path, merge: merge }) return # No hacer nada end commit = Rugged::Commit .create(rugged, update_ref: 'HEAD', parents: [head_commit, remote_head_commit], tree: merge.write_tree(rugged), message: message, author: author(usuarie), committer: committer) # Forzamos el checkout para mover el HEAD al último commit y # escribir los cambios rugged.checkout 'HEAD', strategy: :force git_sh("git", "lfs", "fetch", "origin", default_branch) # reemplaza los pointers por los archivos correspondientes git_sh("git", "lfs", "checkout") commit end # El último commit # # @return [Rugged::Commit] def head_commit rugged.branches[default_branch].target end # El último commit del repositorio remoto # # XXX: Realmente no recuerdo por qué esto era necesario ~f # # @return [Rugged::Commit] def remote_head_commit rugged.branches["origin/#{default_branch}"].target end # Compara los commits entre el repositorio remoto y el actual para # que luego los podamos mostrar. def commits walker = Rugged::Walker.new rugged # Obtenemos todos los commits que existen en origin/master que no # están en la rama master local walker.push "refs/remotes/origin/#{default_branch}" walker.hide "refs/heads/#{default_branch}" walker.each.to_a end # Hay commits sin aplicar? def needs_pull? fetch !commits.empty? end # Guarda los cambios en git # # @param :add [Array] Archivos a agregar # @param :rm [Array] Archivos a eliminar # @param :usuarie [Usuarie] Quién hace el commit # @param :message [String] Mensaje def commit(add: [], rm: [], usuarie:, message:) # Cargar el árbol actual rugged.index.read_tree rugged.head.target.tree add.each do |file| rugged.index.add(relativize(file)) end rm.each do |file| rugged.index.remove(relativize(file)) end # Escribir los cambios para que el repositorio se vea tal cual rugged.index.write Rugged::Commit.create(rugged, message: message, update_ref: 'HEAD', parents: [rugged.head.target], tree: rugged.index.write_tree, author: author(usuarie), committer: committer) end def author(author) { name: author.name, email: author.email, time: Time.now } end def committer { name: 'Sutty', email: "sutty@#{Site.domain}", time: Time.now } end # Garbage collection # # @return [Boolean] def gc git_sh("git", "gc") end # Pushea cambios al repositorio remoto # # @param :remote [Rugged::Remote] # @return [Boolean, nil] def push(remote = origin) remote.push(rugged.head.canonical_name, credentials: credentials_for(remote)) git_sh('git', 'lfs', 'push', remote.name, default_branch) end # Hace limpieza de LFS def lfs_cleanup git_sh("git", "lfs", "prune") git_sh("git", "lfs", "dedup") end private # @deprecated def credentials @credentials ||= credentials_for(origin) end # Si Sutty tiene una llave privada de tipo ED25519, devuelve las # credenciales necesarias para trabajar con repositorios remotos. # # @param :remote [Rugged::Remote] # @return [Nil, Rugged::Credentials::SshKey] def credentials_for(remote) return unless File.exist? private_key Rugged::Credentials::SshKey.new username: username_for(remote), publickey: public_key, privatekey: private_key end # Obtiene el nombre de usuario para el repositorio remoto, por # defecto git # # @param :remote [Rugged::Remote] # @return [String] def username_for(remote) username = parse_url(remote.url)&.user if remote.respond_to? :url username || 'git' end # @param :url [String] # @return [URI, nil] def parse_url(url) GitCloneUrl.parse(url) rescue URI::Error => e ExceptionNotifier.notify_exception(e, data: { path: path, url: url }) nil end # @return [String] def public_key @public_key ||= Rails.root.join('.ssh', 'id_ed25519.pub').to_s end # @return [String] def private_key @private_key ||= Rails.root.join('.ssh', 'id_ed25519').to_s end def relativize(file) Pathname.new(file).relative_path_from(Pathname.new(path)).to_s end # Ejecuta un comando de git # # @param :args [Array] # @return [Boolean] def git_sh(*args) env = { 'PATH' => '/usr/bin', 'LANG' => ENV['LANG'], 'HOME' => path } r = nil Open3.popen2e(env, *args, unsetenv_others: true, chdir: path) do |_, _, t| r = t.value end r&.success? end end end