1# frozen_string_literal: true 2 3require 'spec_helper' 4 5RSpec.describe Gitlab::GitalyClient::CommitService do 6 let(:project) { create(:project, :repository) } 7 let(:storage_name) { project.repository_storage } 8 let(:relative_path) { project.disk_path + '.git' } 9 let(:repository) { project.repository } 10 let(:repository_message) { repository.gitaly_repository } 11 let(:revision) { '913c66a37b4a45b9769037c55c2d238bd0942d2e' } 12 let(:commit) { project.commit(revision) } 13 let(:client) { described_class.new(repository) } 14 15 describe '#diff_from_parent' do 16 context 'when a commit has a parent' do 17 it 'sends an RPC request with the parent ID as left commit' do 18 request = Gitaly::CommitDiffRequest.new( 19 repository: repository_message, 20 left_commit_id: 'cfe32cf61b73a0d5e9f13e774abde7ff789b1660', 21 right_commit_id: commit.id, 22 collapse_diffs: false, 23 enforce_limits: true, 24 # Tests limitation parameters explicitly 25 max_files: 100, 26 max_lines: 5000, 27 max_bytes: 512000, 28 safe_max_files: 100, 29 safe_max_lines: 5000, 30 safe_max_bytes: 512000, 31 max_patch_bytes: 204800 32 ) 33 34 expect_any_instance_of(Gitaly::DiffService::Stub).to receive(:commit_diff).with(request, kind_of(Hash)) 35 36 client.diff_from_parent(commit) 37 end 38 end 39 40 context 'when a commit does not have a parent' do 41 it 'sends an RPC request with empty tree ref as left commit' do 42 initial_commit = project.commit('1a0b36b3cdad1d2ee32457c102a8c0b7056fa863').raw 43 request = Gitaly::CommitDiffRequest.new( 44 repository: repository_message, 45 left_commit_id: Gitlab::Git::EMPTY_TREE_ID, 46 right_commit_id: initial_commit.id, 47 collapse_diffs: false, 48 enforce_limits: true, 49 # Tests limitation parameters explicitly 50 max_files: 100, 51 max_lines: 5000, 52 max_bytes: 512000, 53 safe_max_files: 100, 54 safe_max_lines: 5000, 55 safe_max_bytes: 512000, 56 max_patch_bytes: 204800 57 ) 58 59 expect_any_instance_of(Gitaly::DiffService::Stub).to receive(:commit_diff).with(request, kind_of(Hash)) 60 61 client.diff_from_parent(initial_commit) 62 end 63 end 64 65 it 'returns a Gitlab::GitalyClient::DiffStitcher' do 66 ret = client.diff_from_parent(commit) 67 68 expect(ret).to be_kind_of(Gitlab::GitalyClient::DiffStitcher) 69 end 70 71 it 'encodes paths correctly' do 72 expect { client.diff_from_parent(commit, paths: ['encoding/test.txt', 'encoding/テスト.txt', nil]) }.not_to raise_error 73 end 74 end 75 76 describe '#commit_deltas' do 77 context 'when a commit has a parent' do 78 it 'sends an RPC request with the parent ID as left commit' do 79 request = Gitaly::CommitDeltaRequest.new( 80 repository: repository_message, 81 left_commit_id: 'cfe32cf61b73a0d5e9f13e774abde7ff789b1660', 82 right_commit_id: commit.id 83 ) 84 85 expect_any_instance_of(Gitaly::DiffService::Stub).to receive(:commit_delta).with(request, kind_of(Hash)).and_return([]) 86 87 client.commit_deltas(commit) 88 end 89 end 90 91 context 'when a commit does not have a parent' do 92 it 'sends an RPC request with empty tree ref as left commit' do 93 initial_commit = project.commit('1a0b36b3cdad1d2ee32457c102a8c0b7056fa863') 94 request = Gitaly::CommitDeltaRequest.new( 95 repository: repository_message, 96 left_commit_id: Gitlab::Git::EMPTY_TREE_ID, 97 right_commit_id: initial_commit.id 98 ) 99 100 expect_any_instance_of(Gitaly::DiffService::Stub).to receive(:commit_delta).with(request, kind_of(Hash)).and_return([]) 101 102 client.commit_deltas(initial_commit) 103 end 104 end 105 end 106 107 describe '#diff_stats' do 108 let(:left_commit_id) { 'master' } 109 let(:right_commit_id) { 'cfe32cf61b73a0d5e9f13e774abde7ff789b1660' } 110 111 it 'sends an RPC request and returns the stats' do 112 request = Gitaly::DiffStatsRequest.new(repository: repository_message, 113 left_commit_id: left_commit_id, 114 right_commit_id: right_commit_id) 115 116 diff_stat_response = Gitaly::DiffStatsResponse.new( 117 stats: [{ additions: 1, deletions: 2, path: 'test' }]) 118 119 expect_any_instance_of(Gitaly::DiffService::Stub).to receive(:diff_stats) 120 .with(request, kind_of(Hash)).and_return([diff_stat_response]) 121 122 returned_value = described_class.new(repository).diff_stats(left_commit_id, right_commit_id) 123 124 expect(returned_value).to eq(diff_stat_response.stats) 125 end 126 end 127 128 describe '#find_changed_paths' do 129 let(:commits) { %w[1a0b36b3cdad1d2ee32457c102a8c0b7056fa863 cfe32cf61b73a0d5e9f13e774abde7ff789b1660] } 130 131 it 'sends an RPC request and returns the stats' do 132 request = Gitaly::FindChangedPathsRequest.new(repository: repository_message, 133 commits: commits) 134 135 changed_paths_response = Gitaly::FindChangedPathsResponse.new( 136 paths: [{ 137 path: "app/assets/javascripts/boards/components/project_select.vue", 138 status: :MODIFIED 139 }]) 140 141 expect_any_instance_of(Gitaly::DiffService::Stub).to receive(:find_changed_paths) 142 .with(request, kind_of(Hash)).and_return([changed_paths_response]) 143 144 returned_value = described_class.new(repository).find_changed_paths(commits) 145 mapped_expected_value = changed_paths_response.paths.map { |path| Gitlab::Git::ChangedPath.new(status: path.status, path: path.path) } 146 147 expect(returned_value.as_json).to eq(mapped_expected_value.as_json) 148 end 149 end 150 151 describe '#tree_entries' do 152 subject { client.tree_entries(repository, revision, path, recursive, pagination_params) } 153 154 let(:path) { '/' } 155 let(:recursive) { false } 156 let(:pagination_params) { nil } 157 158 it 'sends a get_tree_entries message' do 159 expect_any_instance_of(Gitaly::CommitService::Stub) 160 .to receive(:get_tree_entries) 161 .with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash)) 162 .and_return([]) 163 164 is_expected.to eq([[], nil]) 165 end 166 167 context 'with UTF-8 params strings' do 168 let(:revision) { "branch\u011F" } 169 let(:path) { "foo/\u011F.txt" } 170 171 it 'handles string encodings correctly' do 172 expect_any_instance_of(Gitaly::CommitService::Stub) 173 .to receive(:get_tree_entries) 174 .with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash)) 175 .and_return([]) 176 177 is_expected.to eq([[], nil]) 178 end 179 end 180 181 context 'with pagination parameters' do 182 let(:pagination_params) { { limit: 3, page_token: nil } } 183 184 it 'responds with a pagination cursor' do 185 pagination_cursor = Gitaly::PaginationCursor.new(next_cursor: 'aabbccdd') 186 response = Gitaly::GetTreeEntriesResponse.new( 187 entries: [], 188 pagination_cursor: pagination_cursor 189 ) 190 191 expect_any_instance_of(Gitaly::CommitService::Stub) 192 .to receive(:get_tree_entries) 193 .with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash)) 194 .and_return([response]) 195 196 is_expected.to eq([[], pagination_cursor]) 197 end 198 end 199 end 200 201 describe '#commit_count' do 202 before do 203 expect_any_instance_of(Gitaly::CommitService::Stub) 204 .to receive(:count_commits) 205 .with(gitaly_request_with_path(storage_name, relative_path), 206 kind_of(Hash)) 207 .and_return([]) 208 end 209 210 it 'sends a commit_count message' do 211 client.commit_count(revision) 212 end 213 214 context 'with UTF-8 params strings' do 215 let(:revision) { "branch\u011F" } 216 let(:path) { "foo/\u011F.txt" } 217 218 it 'handles string encodings correctly' do 219 client.commit_count(revision, path: path) 220 end 221 end 222 end 223 224 describe '#find_commit' do 225 let(:revision) { Gitlab::Git::EMPTY_TREE_ID } 226 227 it 'sends an RPC request' do 228 request = Gitaly::FindCommitRequest.new( 229 repository: repository_message, revision: revision 230 ) 231 232 expect_any_instance_of(Gitaly::CommitService::Stub).to receive(:find_commit) 233 .with(request, kind_of(Hash)).and_return(double(commit: nil)) 234 235 described_class.new(repository).find_commit(revision) 236 end 237 238 describe 'caching', :request_store do 239 let(:commit_dbl) { double(id: 'f01b' * 10) } 240 241 context 'when passed revision is a branch name' do 242 it 'calls Gitaly' do 243 expect_any_instance_of(Gitaly::CommitService::Stub).to receive(:find_commit).twice.and_return(double(commit: commit_dbl)) 244 245 commit = nil 246 2.times { commit = described_class.new(repository).find_commit('master') } 247 248 expect(commit).to eq(commit_dbl) 249 end 250 end 251 252 context 'when passed revision is a commit ID' do 253 it 'returns a cached commit' do 254 expect_any_instance_of(Gitaly::CommitService::Stub).to receive(:find_commit).once.and_return(double(commit: commit_dbl)) 255 256 commit = nil 257 2.times { commit = described_class.new(repository).find_commit('f01b' * 10) } 258 259 expect(commit).to eq(commit_dbl) 260 end 261 end 262 263 context 'when caching of the ref name is enabled' do 264 it 'caches negative entries' do 265 expect_any_instance_of(Gitaly::CommitService::Stub).to receive(:find_commit).once.and_return(double(commit: nil)) 266 267 commit = nil 268 2.times do 269 ::Gitlab::GitalyClient.allow_ref_name_caching do 270 commit = described_class.new(repository).find_commit('master') 271 end 272 end 273 274 expect(commit).to eq(nil) 275 end 276 277 it 'returns a cached commit' do 278 expect_any_instance_of(Gitaly::CommitService::Stub).to receive(:find_commit).once.and_return(double(commit: commit_dbl)) 279 280 commit = nil 281 2.times do 282 ::Gitlab::GitalyClient.allow_ref_name_caching do 283 commit = described_class.new(repository).find_commit('master') 284 end 285 end 286 287 expect(commit).to eq(commit_dbl) 288 end 289 end 290 end 291 end 292 293 describe '#list_commits' do 294 let(:revisions) { 'master' } 295 let(:reverse) { false } 296 let(:pagination_params) { nil } 297 298 shared_examples 'a ListCommits request' do 299 before do 300 ::Gitlab::GitalyClient.clear_stubs! 301 end 302 303 it 'sends a list_commits message' do 304 expect_next_instance_of(Gitaly::CommitService::Stub) do |service| 305 expected_request = gitaly_request_with_params( 306 Array.wrap(revisions), 307 reverse: reverse, 308 pagination_params: pagination_params 309 ) 310 311 expect(service).to receive(:list_commits).with(expected_request, kind_of(Hash)).and_return([]) 312 end 313 314 client.list_commits(revisions, reverse: reverse, pagination_params: pagination_params) 315 end 316 end 317 318 it_behaves_like 'a ListCommits request' 319 320 context 'with multiple revisions' do 321 let(:revisions) { %w[master --not --all] } 322 323 it_behaves_like 'a ListCommits request' 324 end 325 326 context 'with reverse: true' do 327 let(:reverse) { true } 328 329 it_behaves_like 'a ListCommits request' 330 end 331 332 context 'with pagination params' do 333 let(:pagination_params) { { limit: 1, page_token: 'foo' } } 334 335 it_behaves_like 'a ListCommits request' 336 end 337 end 338 339 describe '#list_new_commits' do 340 let(:revisions) { [revision] } 341 let(:gitaly_commits) { create_list(:gitaly_commit, 3) } 342 let(:commits) { gitaly_commits.map { |c| Gitlab::Git::Commit.new(repository, c) }} 343 344 subject { client.list_new_commits(revisions, allow_quarantine: allow_quarantine) } 345 346 shared_examples 'a #list_all_commits message' do 347 it 'sends a list_all_commits message' do 348 expected_repository = repository.gitaly_repository.dup 349 expected_repository.git_alternate_object_directories = Google::Protobuf::RepeatedField.new(:string) 350 351 expect_next_instance_of(Gitaly::CommitService::Stub) do |service| 352 expect(service).to receive(:list_all_commits) 353 .with(gitaly_request_with_params(repository: expected_repository), kind_of(Hash)) 354 .and_return([Gitaly::ListAllCommitsResponse.new(commits: gitaly_commits)]) 355 end 356 357 expect(subject).to eq(commits) 358 end 359 end 360 361 shared_examples 'a #list_commits message' do 362 it 'sends a list_commits message' do 363 expect_next_instance_of(Gitaly::CommitService::Stub) do |service| 364 expect(service).to receive(:list_commits) 365 .with(gitaly_request_with_params(revisions: revisions + %w[--not --all]), kind_of(Hash)) 366 .and_return([Gitaly::ListCommitsResponse.new(commits: gitaly_commits)]) 367 end 368 369 expect(subject).to eq(commits) 370 end 371 end 372 373 before do 374 ::Gitlab::GitalyClient.clear_stubs! 375 376 allow(Gitlab::Git::HookEnv) 377 .to receive(:all) 378 .with(repository.gl_repository) 379 .and_return(git_env) 380 end 381 382 context 'with hook environment' do 383 let(:git_env) do 384 { 385 'GIT_OBJECT_DIRECTORY_RELATIVE' => '.git/objects', 386 'GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE' => ['/dir/one', '/dir/two'] 387 } 388 end 389 390 context 'with allowed quarantine' do 391 let(:allow_quarantine) { true } 392 393 it_behaves_like 'a #list_all_commits message' 394 end 395 396 context 'with disallowed quarantine' do 397 let(:allow_quarantine) { false } 398 399 it_behaves_like 'a #list_commits message' 400 end 401 end 402 403 context 'without hook environment' do 404 let(:git_env) do 405 { 406 'GIT_OBJECT_DIRECTORY_RELATIVE' => '', 407 'GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE' => [] 408 } 409 end 410 411 context 'with allowed quarantine' do 412 let(:allow_quarantine) { true } 413 414 it_behaves_like 'a #list_commits message' 415 end 416 417 context 'with disallowed quarantine' do 418 let(:allow_quarantine) { false } 419 420 it_behaves_like 'a #list_commits message' 421 end 422 end 423 end 424 425 describe '#commit_stats' do 426 let(:request) do 427 Gitaly::CommitStatsRequest.new( 428 repository: repository_message, revision: revision 429 ) 430 end 431 432 let(:response) do 433 Gitaly::CommitStatsResponse.new( 434 oid: revision, 435 additions: 11, 436 deletions: 15 437 ) 438 end 439 440 subject { described_class.new(repository).commit_stats(revision) } 441 442 it 'sends an RPC request' do 443 expect_any_instance_of(Gitaly::CommitService::Stub).to receive(:commit_stats) 444 .with(request, kind_of(Hash)).and_return(response) 445 446 expect(subject.additions).to eq(11) 447 expect(subject.deletions).to eq(15) 448 end 449 end 450 451 describe '#find_commits' do 452 it 'sends an RPC request with NONE when default' do 453 request = Gitaly::FindCommitsRequest.new( 454 repository: repository_message, 455 disable_walk: true, 456 order: 'NONE', 457 global_options: Gitaly::GlobalOptions.new(literal_pathspecs: false) 458 ) 459 460 expect_any_instance_of(Gitaly::CommitService::Stub).to receive(:find_commits) 461 .with(request, kind_of(Hash)).and_return([]) 462 463 client.find_commits(order: 'default') 464 end 465 466 it 'sends an RPC request' do 467 request = Gitaly::FindCommitsRequest.new( 468 repository: repository_message, 469 disable_walk: true, 470 order: 'TOPO', 471 global_options: Gitaly::GlobalOptions.new(literal_pathspecs: false) 472 ) 473 474 expect_any_instance_of(Gitaly::CommitService::Stub).to receive(:find_commits) 475 .with(request, kind_of(Hash)).and_return([]) 476 477 client.find_commits(order: 'topo') 478 end 479 480 it 'sends an RPC request with an author' do 481 request = Gitaly::FindCommitsRequest.new( 482 repository: repository_message, 483 disable_walk: true, 484 order: 'NONE', 485 author: "Billy Baggins <bilbo@shire.com>", 486 global_options: Gitaly::GlobalOptions.new(literal_pathspecs: false) 487 ) 488 489 expect_any_instance_of(Gitaly::CommitService::Stub).to receive(:find_commits) 490 .with(request, kind_of(Hash)).and_return([]) 491 492 client.find_commits(order: 'default', author: "Billy Baggins <bilbo@shire.com>") 493 end 494 end 495 496 describe '#commits_by_message' do 497 shared_examples 'a CommitsByMessageRequest' do 498 let(:commits) { create_list(:gitaly_commit, 2) } 499 500 before do 501 request = Gitaly::CommitsByMessageRequest.new( 502 repository: repository_message, 503 query: query, 504 revision: (options[:revision] || '').dup.force_encoding(Encoding::ASCII_8BIT), 505 path: (options[:path] || '').dup.force_encoding(Encoding::ASCII_8BIT), 506 limit: (options[:limit] || 1000).to_i, 507 offset: (options[:offset] || 0).to_i, 508 global_options: Gitaly::GlobalOptions.new(literal_pathspecs: true) 509 ) 510 511 allow_any_instance_of(Gitaly::CommitService::Stub) 512 .to receive(:commits_by_message) 513 .with(request, kind_of(Hash)) 514 .and_return([Gitaly::CommitsByMessageResponse.new(commits: commits)]) 515 end 516 517 it 'sends an RPC request with the correct payload' do 518 expect(client.commits_by_message(query, **options)).to match_array(wrap_commits(commits)) 519 end 520 end 521 522 let(:query) { 'Add a feature' } 523 let(:options) { {} } 524 525 context 'when only the query is provided' do 526 include_examples 'a CommitsByMessageRequest' 527 end 528 529 context 'when all arguments are provided' do 530 let(:options) { { revision: 'feature-branch', path: 'foo.txt', limit: 10, offset: 20 } } 531 532 include_examples 'a CommitsByMessageRequest' 533 end 534 535 context 'when limit and offset are not integers' do 536 let(:options) { { limit: '10', offset: '60' } } 537 538 include_examples 'a CommitsByMessageRequest' 539 end 540 541 context 'when revision and path contain non-ASCII characters' do 542 let(:options) { { revision: "branch\u011F", path: "foo/\u011F.txt" } } 543 544 include_examples 'a CommitsByMessageRequest' 545 end 546 547 def wrap_commits(commits) 548 commits.map { |commit| Gitlab::Git::Commit.new(repository, commit) } 549 end 550 end 551 552 describe '#list_commits_by_ref_name' do 553 let(:project) { create(:project, :repository, create_branch: 'ü/unicode/multi-byte') } 554 555 it 'lists latest commits grouped by a ref name' do 556 response = client.list_commits_by_ref_name(%w[master feature v1.0.0 nonexistent ü/unicode/multi-byte]) 557 558 expect(response.keys.count).to eq 4 559 expect(response.fetch('master').id).to eq 'b83d6e391c22777fca1ed3012fce84f633d7fed0' 560 expect(response.fetch('feature').id).to eq '0b4bc9a49b562e85de7cc9e834518ea6828729b9' 561 expect(response.fetch('v1.0.0').id).to eq '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9' 562 expect(response.fetch('ü/unicode/multi-byte')).to be_present 563 expect(response).not_to have_key 'nonexistent' 564 end 565 end 566end 567