summaryrefslogtreecommitdiff
path: root/app/models/snippet_repository.rb
blob: 54dbc579d546296a19a2c0de619d0b93139d402b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
# frozen_string_literal: true

class SnippetRepository < ApplicationRecord
  include EachBatch
  include Shardable

  DEFAULT_EMPTY_FILE_NAME = 'snippetfile'
  EMPTY_FILE_PATTERN = /^#{DEFAULT_EMPTY_FILE_NAME}(\d+)\.txt$/.freeze

  CommitError = Class.new(StandardError)
  InvalidPathError = Class.new(CommitError)
  InvalidSignatureError = Class.new(CommitError)

  belongs_to :snippet, inverse_of: :snippet_repository

  delegate :repository, :repository_storage, to: :snippet

  class << self
    def find_snippet(disk_path)
      find_by(disk_path: disk_path)&.snippet
    end
  end

  def multi_files_action(user, files = [], **options)
    return if files.nil? || files.empty?

    lease_key = "multi_files_action:#{snippet_id}"

    lease = Gitlab::ExclusiveLease.new(lease_key, timeout: 120)
    raise CommitError, 'Snippet is already being updated' unless uuid = lease.try_obtain

    options[:actions] = transform_file_entries(files)

    capture_git_error { repository.multi_action(user, **options) }
  ensure
    Gitlab::ExclusiveLease.cancel(lease_key, uuid)
  end

  private

  def capture_git_error(&block)
    yield block
  rescue Gitlab::Git::Index::IndexError,
         Gitlab::Git::CommitError,
         Gitlab::Git::PreReceiveError,
         Gitlab::Git::CommandError,
         ArgumentError => error

    logger.error(message: "Snippet git error. Reason: #{error.message}", snippet: snippet.id)

    raise commit_error_exception(error)
  end

  def transform_file_entries(files)
    next_index = get_last_empty_file_index + 1

    files.map do |file_entry|
      file_entry[:file_path] = file_path_for(file_entry, next_index) { next_index += 1 }
      file_entry[:action] = infer_action(file_entry) unless file_entry[:action]
      file_entry[:action] = file_entry[:action].to_sym

      if only_rename_action?(file_entry)
        file_entry[:infer_content] = true
      elsif empty_update_action?(file_entry)
        # There is no need to perform a repository operation
        # When the update action has no content
        file_entry = nil
      end

      file_entry
    end.compact
  end

  def file_path_for(file_entry, next_index)
    return file_entry[:file_path] if file_entry[:file_path].present?
    return file_entry[:previous_path] if reuse_previous_path?(file_entry)

    build_empty_file_name(next_index).tap { yield }
  end

  # If the user removed the file_path and the previous_path
  # matches the EMPTY_FILE_PATTERN, we don't need to
  # rename the file and build a new empty file name,
  # we can just reuse the existing file name
  def reuse_previous_path?(file_entry)
    file_entry[:file_path].blank? &&
      EMPTY_FILE_PATTERN.match?(file_entry[:previous_path])
  end

  def infer_action(file_entry)
    return :create if file_entry[:previous_path].blank?

    file_entry[:previous_path] != file_entry[:file_path] ? :move : :update
  end

  def get_last_empty_file_index
    repository.ls_files(snippet.default_branch).inject(0) do |max, file|
      idx = file[EMPTY_FILE_PATTERN, 1].to_i
      [idx, max].max
    end
  end

  def build_empty_file_name(index)
    "#{DEFAULT_EMPTY_FILE_NAME}#{index}.txt"
  end

  def commit_error_exception(err)
    if invalid_path_error?(err)
      InvalidPathError.new('Invalid file name') # To avoid returning the message with the path included
    elsif invalid_signature_error?(err)
      InvalidSignatureError.new(err.message)
    else
      CommitError.new(err.message)
    end
  end

  def invalid_path_error?(err)
    err.is_a?(Gitlab::Git::Index::IndexError) &&
      err.message.downcase.start_with?('invalid path', 'path cannot include directory traversal')
  end

  def invalid_signature_error?(err)
    err.is_a?(ArgumentError) &&
      err.message.downcase.match?(/failed to parse signature/)
  end

  def only_rename_action?(action)
    action[:action] == :move && action[:content].nil?
  end

  def empty_update_action?(action)
    action[:action] == :update && action[:content].nil?
  end
end

SnippetRepository.prepend_if_ee('EE::SnippetRepository')