diff --git a/db/docs/batched_background_migrations/sync_project_head_with_heuristic_default_branch.yml b/db/docs/batched_background_migrations/sync_project_head_with_heuristic_default_branch.yml new file mode 100644 index 0000000000000000000000000000000000000000..53ca1ba9a24d2ff5198546fd8d063f1f82059320 --- /dev/null +++ b/db/docs/batched_background_migrations/sync_project_head_with_heuristic_default_branch.yml @@ -0,0 +1,6 @@ +--- +feature_name: sync_project_head_with_heuristic_default_branch +description: Synchronizes Git repository HEAD references with heuristic-based default branch logic for both project and wiki repositories +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/000000 +milestone: '18.7' +queued_migration_version: 20251118120251 diff --git a/db/post_migrate/20251118120251_queue_sync_project_head_with_heuristic_default_branch.rb b/db/post_migrate/20251118120251_queue_sync_project_head_with_heuristic_default_branch.rb new file mode 100644 index 0000000000000000000000000000000000000000..ab846f36c38dc11c3bc4e8b1ed540ba0adede25c --- /dev/null +++ b/db/post_migrate/20251118120251_queue_sync_project_head_with_heuristic_default_branch.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class QueueSyncProjectHeadWithHeuristicDefaultBranch < Gitlab::Database::Migration[2.3] + milestone '18.7' + + restrict_gitlab_migration gitlab_schema: :gitlab_main + + MIGRATION = 'SyncProjectHeadWithHeuristicDefaultBranch' + DELAY_INTERVAL = 2.minutes + BATCH_SIZE = 100000 + SUB_BATCH_SIZE = 100 + + def up + queue_batched_background_migration( + MIGRATION, + :projects, + :id, + job_interval: DELAY_INTERVAL, + batch_size: BATCH_SIZE, + sub_batch_size: SUB_BATCH_SIZE + ) + end + + def down + delete_batched_background_migration(MIGRATION, :projects, :id, []) + end +end diff --git a/db/schema_migrations/20251118120251 b/db/schema_migrations/20251118120251 new file mode 100644 index 0000000000000000000000000000000000000000..4f4a3e6a37a0f041c93f3dc85cb4106d83007521 --- /dev/null +++ b/db/schema_migrations/20251118120251 @@ -0,0 +1 @@ +9521828a4d0c16c20995c550d9977d8ad56748bb848aa5ad5f0a2a561f191a12 \ No newline at end of file diff --git a/lib/gitlab/background_migration/sync_project_head_with_heuristic_default_branch.rb b/lib/gitlab/background_migration/sync_project_head_with_heuristic_default_branch.rb new file mode 100644 index 0000000000000000000000000000000000000000..f68ae127cf064df35fae6b5de1671e50251046fb --- /dev/null +++ b/lib/gitlab/background_migration/sync_project_head_with_heuristic_default_branch.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + class SyncProjectHeadWithHeuristicDefaultBranch < BatchedMigrationJob + operation_name :sync_project_head_with_heuristic_default_branch + feature_category :source_code_management + + def perform + each_sub_batch do |sub_batch| + sub_batch.each do |project_record| + # Load the actual Project model to access repository methods + project = ::Project.find_by(id: project_record.id) + next unless project + + # Sync project repository + sync_repository_head_safe(project, :project) + + # Sync wiki repository if wiki is enabled + sync_repository_head_safe(project, :wiki) if project.wiki_enabled? + end + end + end + + private + + # rubocop:disable BackgroundMigration/AvoidSilentRescueExceptions -- Using track_and_raise_for_dev_exception + def sync_repository_head_safe(record, type) + sync_repository_head(record, type) + rescue StandardError => e + Gitlab::ErrorTracking.track_and_raise_for_dev_exception( + e, + message: 'Failed to sync HEAD with heuristic default branch', + repository_type: type, + project_id: record.id + ) + end + # rubocop:enable BackgroundMigration/AvoidSilentRescueExceptions + + def sync_repository_head(record, type) + repository = get_repository(record, type) + return unless repository&.exists? + + # Get what heuristic would return + heuristic_branch = determine_heuristic_default_branch(repository) + return unless heuristic_branch + + # Expire cache to get fresh HEAD value + repository.expire_root_ref_cache + + # Get current default branch (which follows HEAD) + current_head = repository.root_ref + + # Only update if HEAD differs from heuristic and the branch exists + return unless current_head != heuristic_branch && repository.branch_exists?(heuristic_branch) + + repository.write_ref('HEAD', "refs/heads/#{heuristic_branch}") + repository.expire_root_ref_cache + + Gitlab::AppLogger.info( + message: 'Synced HEAD with heuristic default branch', + repository_type: type, + project_id: record.id, + old_head: current_head, + new_head: heuristic_branch + ) + end + + def get_repository(record, type) + case type + when :project + record.repository + when :wiki + record.wiki.repository + end + end + + def determine_heuristic_default_branch(repository) + # Implement heuristic logic that matches the old behavior: + # 1. Check for 'main' branch (modern default) + # 2. Check for 'master' branch (legacy default) + # 3. Fall back to first branch alphabetically + + return 'main' if repository.branch_exists?('main') + return 'master' if repository.branch_exists?('master') + + # Get first branch alphabetically as fallback + branch_names = repository.branch_names + return if branch_names.empty? + + branch_names.min + end + end + end +end diff --git a/spec/lib/gitlab/background_migration/sync_project_head_with_heuristic_default_branch_spec.rb b/spec/lib/gitlab/background_migration/sync_project_head_with_heuristic_default_branch_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..f9e7ad9f406fda79b885df924d39c5bf46701343 --- /dev/null +++ b/spec/lib/gitlab/background_migration/sync_project_head_with_heuristic_default_branch_spec.rb @@ -0,0 +1,312 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::SyncProjectHeadWithHeuristicDefaultBranch, feature_category: :source_code_management do + let(:projects_table) { table(:projects) } + let(:namespaces_table) { table(:namespaces) } + let(:project_repositories_table) { table(:project_repositories) } + let(:users_table) { table(:users) } + let(:project_features_table) { table(:project_features) } + let(:organization) { table(:organizations).create!(name: 'organization', path: 'organization') } + + let!(:user) do + users_table.create!( + username: 'testuser', + email: 'test@example.com', + name: 'Test User', + projects_limit: 10, + organization_id: organization.id + ) + end + + let!(:namespace) do + namespaces_table.create!(name: 'test', path: 'test', type: 'Group', organization_id: organization.id) + end + + let!(:project_namespace_main) do + namespaces_table.create!( + name: 'project-main', + path: 'project-main', + type: 'Project', + parent_id: namespace.id, + organization_id: organization.id + ) + end + + let!(:project_namespace_master) do + namespaces_table.create!( + name: 'project-master', + path: 'project-master', + type: 'Project', + parent_id: namespace.id, + organization_id: organization.id + ) + end + + let!(:project_namespace_custom) do + namespaces_table.create!( + name: 'project-custom', + path: 'project-custom', + type: 'Project', + parent_id: namespace.id, + organization_id: organization.id + ) + end + + let!(:project_namespace_wiki) do + namespaces_table.create!( + name: 'project-wiki', + path: 'project-wiki', + type: 'Project', + parent_id: namespace.id, + organization_id: organization.id + ) + end + + let!(:project_with_main) do + projects_table.create!( + name: 'project-main', + path: 'project-main', + namespace_id: namespace.id, + project_namespace_id: project_namespace_main.id, + organization_id: organization.id + ) + end + + let!(:project_with_master) do + projects_table.create!( + name: 'project-master', + path: 'project-master', + namespace_id: namespace.id, + project_namespace_id: project_namespace_master.id, + organization_id: organization.id + ) + end + + let!(:project_with_custom) do + projects_table.create!( + name: 'project-custom', + path: 'project-custom', + namespace_id: namespace.id, + project_namespace_id: project_namespace_custom.id, + organization_id: organization.id + ) + end + + let!(:project_with_wiki) do + project = projects_table.create!( + name: 'project-wiki', + path: 'project-wiki', + namespace_id: namespace.id, + project_namespace_id: project_namespace_wiki.id, + organization_id: organization.id + ) + + # Enable wiki for this project + project_features_table.create!( + project_id: project.id, + wiki_access_level: 20, # ENABLED + pages_access_level: 20 # ENABLED + ) + + project + end + + subject(:perform_migration) do + described_class.new( + start_id: projects_table.minimum(:id), + end_id: projects_table.maximum(:id), + batch_table: :projects, + batch_column: :id, + sub_batch_size: 100, + pause_ms: 0, + connection: ActiveRecord::Base.connection + ).perform + end + + describe '#perform' do + context 'when repository has main branch' do + it 'sets HEAD to main if not already set' do + project = Project.find(project_with_main.id) + repository = project.repository + test_user = User.find(user.id) + + # Setup: create repository with initial commit + repository.create_if_not_exists + repository.create_file(test_user, 'README.md', 'Initial commit', message: 'Initial commit', branch_name: 'main') + repository.create_branch('develop', 'main') + repository.write_ref('HEAD', 'refs/heads/develop') + repository.expire_root_ref_cache + + expect(repository.root_ref).to eq('develop') + + perform_migration + + repository.expire_root_ref_cache + expect(repository.root_ref).to eq('main') + end + + it 'does not change HEAD if already pointing to main' do + project = Project.find(project_with_wiki.id) # Use wiki project instead + repository = project.repository + test_user = User.find(user.id) + + repository.create_if_not_exists + repository.create_file(test_user, 'README.md', 'Initial commit', message: 'Initial commit', branch_name: 'main') + repository.write_ref('HEAD', 'refs/heads/main') + repository.expire_root_ref_cache + + expect { perform_migration }.not_to change { + repository.expire_root_ref_cache + repository.root_ref + } + end + end + + context 'when repository has master branch but no main' do + it 'sets HEAD to master' do + project = Project.find(project_with_master.id) + repository = project.repository + test_user = User.find(user.id) + + repository.create_if_not_exists + repository.create_file(test_user, 'README.md', 'Initial commit', message: 'Initial commit', + branch_name: 'master') + repository.create_branch('develop', 'master') + repository.write_ref('HEAD', 'refs/heads/develop') + repository.expire_root_ref_cache + + expect(repository.root_ref).to eq('develop') + + perform_migration + + repository.expire_root_ref_cache + expect(repository.root_ref).to eq('master') + end + end + + context 'when repository has custom branches' do + it 'sets HEAD to first branch alphabetically' do + project = Project.find(project_with_custom.id) + repository = project.repository + test_user = User.find(user.id) + + repository.create_if_not_exists + repository.create_file(test_user, 'README.md', 'Initial commit', message: 'Initial commit', + branch_name: 'zebra') + repository.create_branch('apple', 'zebra') + repository.create_branch('banana', 'zebra') + repository.write_ref('HEAD', 'refs/heads/zebra') + repository.expire_root_ref_cache + + expect(repository.root_ref).to eq('zebra') + + perform_migration + + repository.expire_root_ref_cache + expect(repository.root_ref).to eq('apple') + end + end + + context 'when repository does not exist' do + it 'skips the project without error' do + expect { perform_migration }.not_to raise_error + end + end + + context 'when repository has no branches' do + it 'skips the project without error' do + project = Project.find(project_with_main.id) + project.repository.create_if_not_exists + + expect { perform_migration }.not_to raise_error + end + end + + context 'when an error occurs' do + it 'logs the error and continues' do + allow_next_instance_of(described_class) do |migration| + allow(migration).to receive(:sync_repository_head) + .and_raise(StandardError, 'Test error') + end + + expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception) + .with( + instance_of(StandardError), + hash_including( + message: 'Failed to sync HEAD with heuristic default branch' + ) + ).at_least(:once) + + expect { perform_migration }.not_to raise_error + end + end + + context 'with wiki repositories' do + it 'syncs wiki HEAD when wiki is enabled' do + project = Project.find(project_with_wiki.id) + wiki = project.wiki + repository = wiki.repository + test_user = User.find(user.id) + + # Setup: create wiki repository with initial commit + repository.create_if_not_exists + repository.create_file(test_user, 'Home.md', 'Initial commit', message: 'Initial commit', branch_name: 'main') + repository.create_branch('develop', 'main') + repository.write_ref('HEAD', 'refs/heads/develop') + repository.expire_root_ref_cache + + expect(repository.root_ref).to eq('develop') + + perform_migration + + repository.expire_root_ref_cache + expect(repository.root_ref).to eq('main') + end + + it 'skips wiki when wiki is disabled' do + Project.find(project_with_main.id) + + expect { perform_migration }.not_to raise_error + end + + it 'handles errors in wiki sync without stopping project sync' do + project = Project.find(project_with_wiki.id) + project_repo = project.repository + test_user = User.find(user.id) + + # Setup project repository with initial commit (use different filename to avoid conflict) + project_repo.create_if_not_exists + project_repo.create_file(test_user, 'CHANGELOG.md', 'Initial commit', message: 'Initial commit', + branch_name: 'main') + project_repo.create_branch('develop', 'main') + project_repo.write_ref('HEAD', 'refs/heads/develop') + project_repo.expire_root_ref_cache + + # Mock wiki to raise error + allow_next_instance_of(described_class) do |migration| + allow(migration).to receive(:sync_repository_head).and_call_original + allow(migration).to receive(:sync_repository_head) + .with(anything, :wiki) + .and_raise(StandardError, 'Wiki error') + end + + expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception) + .with( + instance_of(StandardError), + hash_including( + message: 'Failed to sync HEAD with heuristic default branch', + repository_type: :wiki + ) + ).at_least(:once) + + perform_migration + + # Project repository should still be synced + project_repo.expire_root_ref_cache + expect(project_repo.root_ref).to eq('main') + end + end + end +end diff --git a/spec/migrations/20251118120251_queue_sync_project_head_with_heuristic_default_branch_spec.rb b/spec/migrations/20251118120251_queue_sync_project_head_with_heuristic_default_branch_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..a9185c01f0575419188e1f2ee33fc514f04b3ed6 --- /dev/null +++ b/spec/migrations/20251118120251_queue_sync_project_head_with_heuristic_default_branch_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe QueueSyncProjectHeadWithHeuristicDefaultBranch, feature_category: :source_code_management do + let!(:batched_migration) { described_class::MIGRATION } + + it 'schedules a new batched background migration' do + reversible_migration do |migration| + migration.before -> { + expect(batched_migration).not_to have_scheduled_batched_migration + } + + migration.after -> { + expect(batched_migration).to have_scheduled_batched_migration( + table_name: :projects, + column_name: :id, + interval: described_class::DELAY_INTERVAL, + batch_size: described_class::BATCH_SIZE, + sub_batch_size: described_class::SUB_BATCH_SIZE + ) + } + end + end +end