Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@
/.import
/principal/saves/save.tres
/addons
**/__pycache__/
*.pyc
1 change: 1 addition & 0 deletions implementation_scripts/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Implementation scripts for GamutoWare contribution merging."""
210 changes: 210 additions & 0 deletions implementation_scripts/merge_fork_game_folder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""Merge a single game folder from a student fork in a safe way.

This script:
- Requires a clean working tree.
- Starts from a base branch (default: master).
- Fetches a branch from a student's fork.
- Creates a temporary local branch at that fork branch tip.
- Creates a new implementation branch from the base branch.
- Copies only the requested game folder from the temp branch
into the implementation branch.
- Commits those folder changes as a single commit.

It never rewrites existing history; it only creates new branches
and commits on top of your current repository.

Typical usage::

python implementation_scripts/merge_fork_game_folder.py \
git@github.com:StudentUser/GamutoWare.git \
Branch_StudentGame \
microjogos/2025S1/StudentGame
"""

import sys
from pathlib import Path
from typing import Optional
import os

# Add parent directory to path so we can import from repo root
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from implementation_scripts.merge_game_contribution import GitMergeManager # type: ignore


def merge_fork_game_folder(
repo_path: str,
contributor_url: str,
contributor_branch: str,
game_folder: str,
base_branch: str = "master",
target_branch_name: Optional[str] = None,
temp_branch_prefix: str = "fork_temp",
impl_branch_prefix: str = "impl_from_fork",
) -> bool:
"""Create a branch that imports only one game folder from a fork.

High-level steps:
1. Ensure the working tree has no uncommitted changes.
2. Switch to ``base_branch`` and (best effort) fast-forward from origin.
3. Fetch ``contributor_branch`` from ``contributor_url`` into ``FETCH_HEAD``.
4. Create a temporary branch pointing at ``FETCH_HEAD``.
5. Create a new implementation branch from ``base_branch``.
6. Restore only ``game_folder`` from the temporary branch into
the implementation branch.
7. Commit those changes.
"""

git = GitMergeManager(repo_path)

print("\n=== MERGE FORK GAME FOLDER ===")
print(f"Repository : {git.repo_path}")
print(f"Base branch : {base_branch}")
print(f"Contributor URL : {contributor_url}")
print(f"Contributor branch: {contributor_branch}")
print(f"Game folder : {game_folder}")
print()

# 1. Ensure working tree is clean
if git.has_uncommitted_changes():
print("ERROR: You have uncommitted changes. Commit or stash them first.")
return False

original_branch = git.get_current_branch()

# Normalize folder path to use forward slashes
game_folder_norm = game_folder.replace("\\", "/").strip("/")

# Derive target branch name if not provided
if target_branch_name is None:
folder_name = Path(game_folder_norm).name or "game"
target_branch_name = f"{impl_branch_prefix}_{folder_name}"

temp_branch = f"{temp_branch_prefix}_{contributor_branch}"

try:
# 2. Switch to base branch and try to update it from origin
print(f"Switching to base branch '{base_branch}'...")
git.switch_branch(base_branch)
print("On base branch.")

print("Trying to fast-forward base branch from origin (if configured)...")
git.run_git(["pull", "--ff-only", "origin", base_branch], check=False)
print("Base branch ready.\n")

# 3. Fetch the contributor's branch
print(f"Fetching contributor branch '{contributor_branch}' from fork...")
git.fetch_remote(contributor_url, contributor_branch)
print("Contributor branch fetched into FETCH_HEAD.\n")

# 4. Create a temporary branch at FETCH_HEAD
print(f"Creating temporary branch '{temp_branch}' from FETCH_HEAD...")
git.create_branch(temp_branch, "FETCH_HEAD", force=True)
print("Temporary branch created.\n")

# 5. Create the implementation branch from the base branch
print(
f"Creating implementation branch '{target_branch_name}' "
f"from '{base_branch}'..."
)
git.create_branch(target_branch_name, base_branch, force=True)
git.switch_branch(target_branch_name)
print("Implementation branch created and checked out.\n")

# 6. Restore only the game folder from the temp branch
print(f"Importing game folder from '{temp_branch}': {game_folder_norm}")
try:
git.run_git_check(["restore", "-s", temp_branch, "--", game_folder_norm])
except RuntimeError as err:
print(f"ERROR: Could not restore game folder: {err}")
return False
print("Game folder restored into implementation branch.")

# Check if anything actually changed
status_code, status_out, _ = git.run_git(["status", "--porcelain"], check=False)
if status_code != 0:
print("ERROR: Could not read git status.")
return False

if not status_out.strip():
print("WARNING: No changes detected after restoring the game folder.")
print(" Nothing to commit; leaving implementation branch as-is.")
return False

# 7. Stage and commit only that folder
print(f"Staging game folder '{game_folder_norm}'...")
git.run_git_check(["add", game_folder_norm])

commit_message = (
f"Import game from fork {contributor_branch}: {game_folder_norm}"
)
print("Committing changes with message:")
print(f" {commit_message}")
git.run_git_check(["commit", "-m", commit_message])

print("\n=== SUCCESS ===")
print(f"Implementation branch : {target_branch_name}")
print(f"Imported game folder : {game_folder_norm}")
print()
print("Next steps:")
print(f" 1. Review the branch: git switch {target_branch_name}")
print(" 2. Test the game in Godot.")
print(f" 3. Merge into {base_branch} when ready:")
print(f" git switch {base_branch}")
print(f" git merge --no-ff {target_branch_name}")
print(" 4. Push to origin and clean up temp branches if desired.")

return True

finally:
# Best-effort: go back to whatever branch the user was on
try:
git.switch_branch(original_branch)
except Exception:
pass

# Best-effort: delete the temporary branch
try:
git.delete_branch(temp_branch, force=True)
except Exception:
pass


def main() -> None:
"""Parse command-line arguments and run the merge."""

if len(sys.argv) < 4:
print(
"Usage: python implementation_scripts/merge_fork_game_folder.py "
"<contributor_url> <branch_name> <game_folder> [base_branch] "
"[target_branch_name]"
)
print()
print("Example:")
print(" python implementation_scripts/merge_fork_game_folder.py ")
print(" git@github.com:StudentUser/GamutoWare.git")
print(" Branch_StudentGame")
print(" microjogos/2025S1/StudentGame")
sys.exit(1)

contributor_url = sys.argv[1]
contributor_branch = sys.argv[2]
game_folder = sys.argv[3]
base_branch = sys.argv[4] if len(sys.argv) > 4 else "master"
target_branch_name = sys.argv[5] if len(sys.argv) > 5 else None

success = merge_fork_game_folder(
repo_path=".",
contributor_url=contributor_url,
contributor_branch=contributor_branch,
game_folder=game_folder,
base_branch=base_branch,
target_branch_name=target_branch_name,
)

sys.exit(0 if success else 1)


if __name__ == "__main__":
main()
160 changes: 160 additions & 0 deletions implementation_scripts/merge_game_contribution.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""Game Contribution Merge Automation Script.

Merges game contributions while filtering changes to only the game folder.
Works across Windows, macOS, and Linux.
"""

import os
import sys
import subprocess
from pathlib import Path
from typing import List, Tuple, Optional


class GitMergeManager:
"""Manages git operations for merging game contributions."""

def __init__(self, repo_path: str = "."):
"""Initialize the git manager."""
self.repo_path = Path(repo_path).resolve()
if not (self.repo_path / ".git").exists():
raise ValueError(f"Not a git repository: {self.repo_path}")
os.chdir(self.repo_path)

def run_git(self, cmd: List[str], check: bool = True) -> Tuple[int, str, str]:
"""Run a git command and return (returncode, stdout, stderr)."""
full_cmd = ["git"] + cmd
try:
result = subprocess.run(
full_cmd,
capture_output=True,
text=True,
check=False,
)
return result.returncode, result.stdout.strip(), result.stderr.strip()
except Exception as e:
if check:
raise
return 1, "", str(e)

def run_git_check(self, cmd: List[str]) -> str:
"""Run a git command and return stdout, raising on error."""
code, stdout, stderr = self.run_git(cmd, check=False)
if code != 0:
raise RuntimeError(f"Git error: {stderr or stdout}")
return stdout

def has_uncommitted_changes(self) -> bool:
"""Check if there are uncommitted changes."""
code, stdout, _ = self.run_git(["status", "--porcelain"], check=False)
return code == 0 and bool(stdout.strip())

def get_current_branch(self) -> str:
"""Get the current branch name."""
return self.run_git_check(["rev-parse", "--abbrev-ref", "HEAD"])

def branch_exists(self, branch: str) -> bool:
"""Check if a branch exists."""
code, _, _ = self.run_git(["rev-parse", "--verify", branch], check=False)
return code == 0

def stash_push(self, message: str = "auto-stash") -> bool:
"""Stash current changes. Returns True if something was stashed."""
code, stdout, _ = self.run_git(["stash", "push", "-m", message], check=False)
return code == 0 and "No local changes" not in stdout

def stash_pop(self) -> None:
"""Pop the most recent stash."""
self.run_git_check(["stash", "pop"])

def create_branch(self, branch: str, start_point: str = "master", force: bool = False) -> None:
"""Create a new branch.

Args:
branch: Name of the branch to create
start_point: Starting point for the new branch
force: If True, discard local changes when switching
"""
cmd = ["switch", "-c", branch, start_point]
if force:
cmd.insert(1, "--force")
self.run_git_check(cmd)

def switch_branch(self, branch: str, force: bool = False) -> None:
"""Switch to a branch.

Args:
branch: Name of the branch to switch to
force: If True, discard local changes when switching
"""
cmd = ["switch", branch]
if force:
cmd.insert(1, "--force")
self.run_git_check(cmd)

def delete_branch(self, branch: str, force: bool = False) -> None:
"""Delete a branch."""
cmd = ["branch", "-D" if force else "-d", branch]
self.run_git(cmd, check=False)

def fetch_remote(self, remote_url: str, branch: str) -> None:
"""Fetch a specific branch from remote."""
self.run_git_check(["fetch", remote_url, branch])

def get_commits_in_folder(self, branch: str, folder: str, base: str = "master") -> List[str]:
"""Get commits that modify files in a specific folder."""
try:
output = self.run_git_check(
[
"log",
f"{base}..{branch}",
"--format=%H",
"--",
folder,
]
)
return [line for line in output.split("\n") if line.strip()]
except RuntimeError:
return []

def get_modified_paths(self, commit: str) -> List[str]:
"""Get all files modified in a commit."""
output = self.run_git_check(
["diff-tree", "--no-commit-id", "--name-only", "-r", commit]
)
return [line for line in output.split("\n") if line.strip()]

def cherry_pick(self, commit: str) -> bool:
"""Cherry-pick a commit. Returns False if conflicts."""
code, _, stderr = self.run_git(["cherry-pick", commit], check=False)
if code != 0:
if "CONFLICT" in stderr or "conflict" in stderr.lower():
return False
raise RuntimeError(f"Cherry-pick failed: {stderr}")
return True

def abort_cherry_pick(self) -> None:
"""Abort an ongoing cherry-pick."""
self.run_git(["cherry-pick", "--abort"], check=False)

def get_files_in_commit(self, commit: str, folder: str) -> List[str]:
"""Get files in a specific folder that were modified in a commit."""
try:
output = self.run_git_check(
["diff-tree", "--no-commit-id", "--name-only", "-r", commit]
)
files = [line for line in output.split("\n") if line.strip()]
folder_normalized = folder.replace("\\", "/").strip("/")
return [
f
for f in files
if f.replace("\\", "/").startswith(folder_normalized + "/")
or f.replace("\\", "/") == folder_normalized
]
except RuntimeError:
return []

def reset_hard(self, ref: str = "HEAD") -> None:
"""Hard reset to a reference."""
self.run_git_check(["reset", "--hard", ref])
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading