見出し画像

Unity開発でのブランチ衝突を未然に防ぐ : コンフリクト早期発見の試み

こんにちは! fondi DEVチームのmuoです。

みなさん、Unityでチーム開発してますか? fondiはUnityで構築されており、複数人での開発においてprefabやsceneファイル、metaファイル類の衝突問題にたびたび悩まされてきました。特に大規模あるいは地理的時間帯的に離れた体制のチームだと、誰がどのファイルを編集しているのか/そろそろ編集しそうかといった状況の把握が難しくなってきます

今回は、この「後になって気づくと厄介な衝突問題」に対して、「早期発見・早期対応」を目指してツール運用をしてみた話をご紹介します

なぜコンフリクトの早期発見が必要なのか?

fondi DEVチームではUnityでのチーム開発において、以下のような課題に直面していました:

  1. prefabやsceneファイル類の衝突はC#ソースコードと比較して解消が困難

    1. UnityYAMLMergeでの処理が可能な場合でも、同一ファイルへ同時に複数の意図で加えられた変更を整理する難易度が高い(prefabネストを含めると尚更)

  2. 衝突に気づくタイミングが遅すぎる(mergeしようとした時に初めて気付く場合が多い)

  3. 開発者間のコミュニケーションコストが高い

特に問題だったのは、それぞれの機能開発者の視野設計です。自分の作業が他の人の作業と衝突する可能性があることに気づくのが、pull requestを作成する段階になってからでは遅すぎるのですが、かといって全開発者が他の全員の開発スコープを常に詳しく把握し続けるというのも現実的ではありません。チーム内で「他のプロジェクトではどうやっていましたか?」と聞き回ったところ「スプシで誰がどのファイルを触っているか管理していました」という回答もあって驚きましたが、つまりチームごとにうまい方法を模索するしかないということでしょう

コンフリクト検出の2つのアプローチ

この課題に対して、2つの異なる視点からアプローチするツールを用意しました:

  1. ローカルでの作業確認(check_possible_conflicts_local.py)

    • 開発者が自分の作業中のブランチと他のリモートブランチとの潜在的な衝突を確認

      • コミットしていない変更も含めて検証可能。自分の手元での作業途中でも潜在的な衝突を検証できるため、より細かな粒度でコンフリクト回避の機会を生み出せる

    • `--detect-essential-unity-changes` オプションでUnity関連ファイルに絞った検出が可能

  2. リモートブランチ間の確認(check_possible_conflicts_remote.py)

    • 既にプッシュされているブランチ間の潜在的な衝突を網羅的にチェック

      • 指定リビジョンをベースとして差分を持つブランチのみを抽出することで、うっかりremoteに残っている古い不要ブランチ類を除外して処理実施(デフォルトで `origin/develop` をベースとする)

    • `--detect-changes-by-different-contributors` オプションで異なる開発者間の衝突のみを抽出可能

    • merge担当者が全体像を把握する際に有用

さらに、これらのツールをGitHub Actionsと連携させることで、pull request作成時に自動でコンフリクトチェックを行い、結果をPRコメントとして投稿する仕組みも整備しました

実装のポイント

コンフリクト検出の核となる部分は、以下のようなロジックで実装しています:

  1. 共通の祖先コミットを特定

  2. そこからの各ブランチでの変更を抽出

  3. 変更されたファイルパスの重複をチェック

  4. Unity特有のファイル(.prefab、.unity、.meta等)へ特に注目

今回重要なのは、抽出されるファイルリストが多くなりすぎると結果的にリストを見なくなってしまうことが考えられるため、特にUnity関連の「嫌なコンフリクト」の原因になるファイル形式に絞り込んだ検出オプションを用意した点です

実際の運用と今後の課題

試験運用中のPRフックの様子。塗りつぶしている箇所にはコンフリクト先ブランチの最終更新者名が表示されていて、gitコマンドやGitHubのUIから詳細を調べるまでもなくコミュニケーションを取れるようになっています

運用を始めてみて、以下のような課題も見えてきました:

誤検出への対応

  • 実際にはコンフリクトしない変更も検出してしまう

    • 例:cherry-pickで意図的に同じ変更を複数ブランチに取り込んだ場合

使い勝手の向上

  • 開発者が定期的にチェックする習慣づけが必要

    • git rebaseのタイミングでの自動チェックの検討(fondiチームでは各担当者の作業中ブランチを基本的に毎日1回は最新のdevelopブランチへとrebaseする運用ルールを置いているので、このタイミングでチェックが走るようにすると便利そう)

リポジトリ運用ルールとの整合

  • ファイルのリネームや移動への対応

  • metaファイルの変更ルールの整備

まとめ

まだ完璧な解決策とは言えませんが、このツールの導入により:

  • 衝突の早期発見が可能に

  • 開発者間のコミュニケーションのきっかけを提供

  • merge作業の効率化を実現

できました

あなたのUnity開発プロジェクトでも似た課題へ取り組まれたことがあるのではないでしょうか?もし良い解決方法をお持ちでしたら、ぜひコメント欄で教えていただけると嬉しいです!

最後にコードと設定ファイルの全文を掲載します

check_possible_conflicts_local.py

import subprocess
import itertools
from collections import defaultdict
import argparse
import os
import re

UNITY_ESSENTIAL_FILES = r'\.(prefab|asset|unity|meta)$'
EXCLUDED_FILES = ['I2Languages.asset']

def run_command(command):
    """Run a shell command and return the output."""
    try:
        result = subprocess.run(command, shell=True, check=True, capture_output=True, text=True)
        return result.stdout.strip()
    except subprocess.CalledProcessError as e:
        print(f"Error executing command: {command}")
        print(f"Exit code: {e.returncode}")
        print(f"stdout: {e.stdout}")
        print(f"stderr: {e.stderr}")
        raise

def fetch_and_prune():
    """Fetch the latest changes and prune obsolete remote branches."""
    print("Fetching latest changes and pruning obsolete remote branches...")
    run_command("git fetch --prune origin")

def get_base_hash(base_revision):
    """Get the commit hash of the specified base revision."""
    return run_command(f"git rev-parse {base_revision}")

def get_current_branch():
    """Get the name of the current branch."""
    return run_command("git rev-parse --abbrev-ref HEAD")

def get_newer_branches(base_hash):
    """Get all remote branches that are newer than the base revision."""
    all_branches = run_command("git for-each-ref --format='%(refname:short)' refs/remotes/").split('\n')
    newer_branches = []
    for branch in all_branches:
        if branch == 'origin/HEAD' or branch.startswith('pull/'):
            print('Skipping ' + branch)
            continue
        merge_base = run_command(f"git merge-base {base_hash} {branch}")
        if merge_base == base_hash:
            newer_branches.append(branch)
    return newer_branches

def get_common_ancestor(branch1, branch2):
    """Get the common ancestor commit of two branches."""
    return run_command(f"git merge-base {branch1} {branch2}")

def is_unity_essential_file(file):
    """Check if the file is a Unity essential file and not in the excluded list."""
    return re.search(UNITY_ESSENTIAL_FILES, file) and os.path.basename(file) not in EXCLUDED_FILES

def get_changed_files(from_commit, to_commit, unity_only=False):
    """Get the list of files changed between two commits."""
    files = set(file for file in run_command(f"git diff --name-only {from_commit}..{to_commit}").split('\n') if file)
    if unity_only:
        return set(file for file in files if is_unity_essential_file(file))
    return files

def get_uncommitted_changes(unity_only=False):
    """Get the list of uncommitted changes, including unstaged files, across the entire repository."""
    # Get the root directory of the Git repository
    repo_root = run_command("git rev-parse --show-toplevel")
    
    # Change to the repository root directory
    original_dir = os.getcwd()
    os.chdir(repo_root)
    
    try:
        # Get staged changes
        staged = set(file for file in run_command("git diff --cached --name-only").split('\n') if file)
        # Get unstaged changes
        unstaged = set(file for file in run_command("git ls-files --modified --others --exclude-standard").split('\n') if file)
        
        all_changes = staged.union(unstaged)
        if unity_only:
            return set(file for file in all_changes if is_unity_essential_file(file))
        return all_changes
    finally:
        # Change back to the original directory
        os.chdir(original_dir)

def get_last_contributor(branch):
    """Get the contributor (committer or author) of the last commit on a branch."""
    committer = run_command(f"git log -1 --pretty=format:'%cn' {branch}")
    if committer.lower() == "github":
        return run_command(f"git log -1 --pretty=format:'%an' {branch}")
    return committer

def analyze_conflicts_with_current_branch(base_revision, unity_only=False):
    base_hash = get_base_hash(base_revision)
    current_branch = get_current_branch()
    remote_branches = get_newer_branches(base_hash)
    
    conflicts = defaultdict(list)
    
    # Get uncommitted changes
    uncommitted_changes = get_uncommitted_changes(unity_only)
    
    for remote_branch in remote_branches:
        common_ancestor = get_common_ancestor(current_branch, remote_branch)
        
        remote_contributor = get_last_contributor(remote_branch)

        changes_current = get_changed_files(common_ancestor, current_branch, unity_only)
        changes_current.update(uncommitted_changes)  # Add uncommitted changes
        changes_remote = get_changed_files(common_ancestor, remote_branch, unity_only)
        
        common_changes = changes_current.intersection(changes_remote)
        if common_changes:
            branch_pair = f"{current_branch} (including uncommitted changes) <-> [{remote_contributor}] {remote_branch}"
            conflicts[branch_pair] = list(common_changes)
    
    return conflicts

def main():
    parser = argparse.ArgumentParser(description="Analyze potential conflicts between the current branch (including uncommitted changes) and remote branches.")
    parser.add_argument("base_revision", nargs="?", default="origin/develop", 
                        help="Base revision to compare against (default: origin/develop)")
    parser.add_argument("--detect-essential-unity-changes", action="store_true",
                        help="Only detect changes in essential Unity files (.prefab, .asset, .unity, .meta)")
    args = parser.parse_args()

    fetch_and_prune()

    current_branch = get_current_branch()
    print(f"Analyzing potential conflicts between the current branch '{current_branch}' (including uncommitted changes) and remote branches.")
    print(f"Using base revision: {args.base_revision}")
    if args.detect_essential_unity_changes:
        print("Focusing on essential Unity files (.prefab, .asset, .unity, .meta)")
    
    conflicts = analyze_conflicts_with_current_branch(args.base_revision, args.detect_essential_unity_changes)
    
    if conflicts:
        print("\nPotential conflicts detected:")
        for branch_pair, files in conflicts.items():
            print(f"\n{branch_pair}:")
            for file in files:
                print(f"  - {file}")
    else:
        print("\nNo potential conflicts detected between the current branch (including uncommitted changes) and active remote branches.")

if __name__ == "__main__":
    main()

check_possible_conflicts_remote.py

import subprocess
import itertools
from collections import defaultdict
import argparse
import re
import os
import sys

UNITY_ESSENTIAL_FILES = r'\.(prefab|asset|unity|meta)$'
EXCLUDED_FILES = ['I2Languages.asset']

def run_command(command):
    """Run a shell command and return the output."""
    try:
        result = subprocess.run(command, shell=True, check=True, capture_output=True, text=True)
        return result.stdout.strip()
    except subprocess.CalledProcessError as e:
        print(f"Error executing command: {command}")
        print(f"Exit code: {e.returncode}")
        print(f"stdout: {e.stdout}")
        print(f"stderr: {e.stderr}")
        raise

def fetch_and_prune():
    """Fetch the latest changes and prune obsolete remote branches."""
    print("Fetching latest changes and pruning obsolete remote branches...")
    run_command("git fetch --prune origin")

def get_base_hash(base_revision):
    """Get the commit hash of the specified base revision."""
    return run_command(f"git rev-parse {base_revision}")

def get_newer_branches(base_hash):
    """Get all remote branches that are newer than the base revision."""
    all_branches = run_command("git for-each-ref --format='%(refname:short)' refs/remotes/").split('\n')
    newer_branches = []
    for branch in all_branches:
        if branch == 'origin/HEAD':
            continue
        merge_base = run_command(f"git merge-base {base_hash} {branch}")
        if merge_base == base_hash:
            newer_branches.append(branch)
    return newer_branches

def get_common_ancestor(branch1, branch2):
    """Get the common ancestor commit of two branches."""
    return run_command(f"git merge-base {branch1} {branch2}")

def is_unity_essential_file(file):
    """Check if the file is a Unity essential file and not in the excluded list."""
    return re.search(UNITY_ESSENTIAL_FILES, file) and os.path.basename(file) not in EXCLUDED_FILES

def get_changed_files(from_commit, to_commit, unity_only=False):
    """Get the list of files changed between two commits."""
    files = set(file for file in run_command(f"git diff --name-only {from_commit}..{to_commit}").split('\n') if file)
    if unity_only:
        return set(file for file in files if is_unity_essential_file(file))
    return files

def get_last_contributor(branch):
    """Get the contributor (committer or author) of the last commit on a branch."""
    committer = run_command(f"git log -1 --pretty=format:'%cn' {branch}")
    if committer.lower() == "github":
        return run_command(f"git log -1 --pretty=format:'%an' {branch}")
    return committer

def analyze_conflicts(base_revision, unity_only=False, different_contributors_only=False):
    base_hash = get_base_hash(base_revision)
    branches = get_newer_branches(base_hash)
    
    conflicts = defaultdict(list)
    
    for branch1, branch2 in itertools.combinations(branches, 2):
        common_ancestor = get_common_ancestor(branch1, branch2)
        
        contributor1 = get_last_contributor(branch1)
        contributor2 = get_last_contributor(branch2)
        
        if different_contributors_only and contributor1 == contributor2:
            continue

        changes1 = get_changed_files(common_ancestor, branch1, unity_only)
        changes2 = get_changed_files(common_ancestor, branch2, unity_only)
        
        common_changes = changes1.intersection(changes2)
        if common_changes:
            branch_pair = f"[{contributor1}] {branch1} <-> [{contributor2}] {branch2}"
            conflicts[branch_pair] = list(common_changes)
    
    return conflicts

def main():
    parser = argparse.ArgumentParser(description="Analyze potential conflicts between Git branches.")
    parser.add_argument("base_revision", nargs="?", default="origin/develop", 
                        help="Base revision to compare against (default: origin/develop)")
    parser.add_argument("--detect-essential-unity-changes", action="store_true",
                        help="Only detect changes in essential Unity files (.prefab, .asset, .unity, .meta)")
    parser.add_argument("--detect-changes-by-different-contributors", action="store_true",
                        help="Only detect conflicts between branches with different last contributors")
    args = parser.parse_args()

    fetch_and_prune()

    print(f"Analyzing potential conflicts between branches using base revision: {args.base_revision}")
    if args.detect_essential_unity_changes:
        print("Focusing on essential Unity files (.prefab, .asset, .unity, .meta)")
    if args.detect_changes_by_different_contributors:
        print("Only detecting conflicts between branches with different last contributors")
    
    conflicts = analyze_conflicts(args.base_revision, args.detect_essential_unity_changes, args.detect_changes_by_different_contributors)
    
    if conflicts:
        print("\nPotential conflicts detected:")
        for branch_pair, files in conflicts.items():
            print(f"\n{branch_pair}:")
            for file in files:
                print(f"  - {file}")
    else:
        print("\nNo potential conflicts detected between active branches.")

if __name__ == "__main__":
    main()

check-conflicts.yml (GitHub Actions)

name: Check potential conflicts for pull-requests

on:
  pull_request:
    types: [opened, synchronize]

jobs:
  pr_check:
    name: Pull Request Check
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v3
        with:
          fetch-depth: 0

      - name: List branches
        run: git branch -r

      - name: Run checker
        id: run_checker
        run: |
          python3 tools/check_possible_conflicts_local.py --detect-essential-unity-changes | tee check_result.txt
          if tail -n1 check_result.txt | grep -q "No potential conflicts detected between the current branch (including uncommitted changes) and active remote branches."; then
            echo "skip_comment=true" >> $GITHUB_OUTPUT
          else
            echo "skip_comment=false" >> $GITHUB_OUTPUT
          fi

      - name: Post a comment
        if: steps.run_checker.outputs.skip_comment != 'true'
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          URL: ${{ github.event.pull_request.html_url }}
        run:
          gh pr comment -F ./check_result.txt "${URL}"

GitHub Actionsでの実行時には `check_possible_conflicts_local.py` のほうを使うのがポイントです


いいなと思ったら応援しよう!