From b6e24c6de5f04c9f69e0a4528653e9d98ca175b9 Mon Sep 17 00:00:00 2001 From: John Cai Date: Sun, 14 Dec 2025 16:52:40 -0500 Subject: [PATCH 1/2] Migration to sync project repository HEAD with default branch heuristics There's logic for deciding the default branch based on a set of heuristics. However, this is confusing and we are moving to just determining the default branch by the HEAD branch in the Git repository. In order to maintain backwards compatibility, add a migration that will set HEAD based on default branch heuristics for project repositories. --- ...ect_head_with_heuristic_default_branch.yml | 6 + ...ject_head_with_heuristic_default_branch.rb | 27 +++ db/schema_migrations/20251118120251 | 1 + ...ject_head_with_heuristic_default_branch.rb | 90 ++++++++ ...head_with_heuristic_default_branch_spec.rb | 217 ++++++++++++++++++ ...head_with_heuristic_default_branch_spec.rb | 26 +++ 6 files changed, 367 insertions(+) create mode 100644 db/docs/batched_background_migrations/sync_project_head_with_heuristic_default_branch.yml create mode 100644 db/post_migrate/20251118120251_queue_sync_project_head_with_heuristic_default_branch.rb create mode 100644 db/schema_migrations/20251118120251 create mode 100644 lib/gitlab/background_migration/sync_project_head_with_heuristic_default_branch.rb create mode 100644 spec/lib/gitlab/background_migration/sync_project_head_with_heuristic_default_branch_spec.rb create mode 100644 spec/migrations/20251118120251_queue_sync_project_head_with_heuristic_default_branch_spec.rb 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 00000000000000..53ca1ba9a24d2f --- /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 00000000000000..ab846f36c38dc1 --- /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 00000000000000..4f4a3e6a37a0f0 --- /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 00000000000000..1e997157073a9e --- /dev/null +++ b/lib/gitlab/background_migration/sync_project_head_with_heuristic_default_branch.rb @@ -0,0 +1,90 @@ +# 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) + 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 + 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 00000000000000..160ee2d2a6bbe5 --- /dev/null +++ b/spec/lib/gitlab/background_migration/sync_project_head_with_heuristic_default_branch_spec.rb @@ -0,0 +1,217 @@ +# 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_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 + + 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_main.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: '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 + 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 00000000000000..a9185c01f05754 --- /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 -- GitLab From 07231a9f2f7afc7a372b3ed08255d2e3684998e3 Mon Sep 17 00:00:00 2001 From: John Cai Date: Sun, 14 Dec 2025 20:53:04 -0500 Subject: [PATCH 2/2] Extend migration to sync wiki repository HEAD with heuristics Extend the migration to also handle wiki repositories. When a project has wiki enabled, the migration will sync the wiki repository's HEAD to match the default branch determined by heuristics. This ensures consistency between project and wiki repositories in terms of default branch handling. --- ...ject_head_with_heuristic_default_branch.rb | 5 + ...head_with_heuristic_default_branch_spec.rb | 97 ++++++++++++++++++- 2 files changed, 101 insertions(+), 1 deletion(-) 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 index 1e997157073a9e..f68ae127cf064d 100644 --- 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 @@ -15,6 +15,9 @@ def perform # 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 @@ -67,6 +70,8 @@ def get_repository(record, type) case type when :project record.repository + when :wiki + record.wiki.repository 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 index 160ee2d2a6bbe5..f9e7ad9f406fda 100644 --- 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 @@ -54,6 +54,16 @@ ) 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', @@ -84,6 +94,25 @@ ) 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), @@ -119,7 +148,7 @@ end it 'does not change HEAD if already pointing to main' do - project = Project.find(project_with_main.id) + project = Project.find(project_with_wiki.id) # Use wiki project instead repository = project.repository test_user = User.find(user.id) @@ -213,5 +242,71 @@ 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 -- GitLab