diff --git a/.codespellrc b/.codespellrc
index 3c19ea9..3fb8749 100644
--- a/.codespellrc
+++ b/.codespellrc
@@ -1,3 +1,2 @@
[codespell]
-skip = assets/**
-ignore-words-list = lod,LOD
+skip = addons/gd-plug/**,addons/gut/**
diff --git a/.env b/.env
index 96c5b29..b75b6f6 100644
--- a/.env
+++ b/.env
@@ -2,7 +2,7 @@
# Godot
-GODOT_VERSION=4.2
+GODOT_VERSION=4.2.1
# Addon
diff --git a/.gitattributes b/.gitattributes
index 1bf6fb1..1ece952 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,12 +1,18 @@
+# Properly detect languages on Github.
+*.gd linguist-language=GDScript
+
# Normalize EOL for all files that Git considers text files.
* text=auto eol=lf
-*.gd linguist-language=GDScript
+
+# The above only works properly for Git 2.10+, so for older versions
+# we need to manually list the binary files we don't want modified.
+*.mp3 binary
+*.png binary
*.hdr binary
# Exclude all top-level files and directories (except addons) from zip downloads.
# This makes installing through the AssetLib easier, because no files and folders
# need to be unchecked.
-
-/** export-ignore
+/** export-ignore
/addons/greeter !export-ignore
/addons/greeter/** !export-ignore
diff --git a/.github/actions/setup-godot/action.yml b/.github/actions/setup-godot/action.yml
new file mode 100644
index 0000000..6d8b7bf
--- /dev/null
+++ b/.github/actions/setup-godot/action.yml
@@ -0,0 +1,64 @@
+name: Setup Godot
+description: Setup Godot dependencies.
+runs:
+ using: "composite"
+ steps:
+
+ ############
+ # Windows #
+ ############
+
+ - name: Installing Mesa3D
+ if: ${{ runner.os == 'Windows' }}
+ uses: ssciwr/setup-mesa-dist-win@v1
+
+ - name: Installing Scream, a virtual sound card
+ if: ${{ runner.os == 'Windows' }}
+ shell: powershell
+ run: |
+ <# Script to install Scream, a dummy sound card for Windows: https://github.com/duncanthrax/scream
+ Taken from comment by Aleksandr Chebotov (al-cheb) at: https://github.com/actions/virtual-environments/issues/2528#issuecomment-766883233 #>
+ Start-Service audio*
+ Invoke-WebRequest https://github.com/duncanthrax/scream/releases/download/3.6/Scream3.6.zip -OutFile C:\Scream3.6.zip
+ Expand-Archive C:\Scream3.6.zip -DestinationPath C:\Scream
+ $cert = (Get-AuthenticodeSignature C:\Scream\Install\driver\Scream.sys).SignerCertificate
+ $store = [System.Security.Cryptography.X509Certificates.X509Store]::new("TrustedPublisher", "LocalMachine")
+ $store.Open("ReadWrite")
+ $store.Add($cert)
+ $store.Close()
+ cd C:\Scream\Install\driver
+ C:\Scream\Install\helpers\devcon install Scream.inf *Scream
+
+ ############
+ # Linux #
+ ############
+
+ - name: Installing Linux dependencies
+ if: ${{ runner.os == 'Linux' }}
+ shell: bash
+ run: sudo apt-get install -y pulseaudio xvfb x11-xserver-utils mesa-vulkan-drivers
+
+ - name: Starting X11 server on :0
+ if: ${{ runner.os == 'Linux' }}
+ shell: bash
+ run: xset -q || /bin/bash -c "sudo Xvfb -ac :0 -screen 0 1920x1080x24 > /dev/null 2>&1 &"
+ env:
+ DISPLAY: ":0"
+
+ - name: Check that X11 server is running
+ if: ${{ runner.os == 'Linux' }}
+ uses: nick-fields/retry@v2
+ with:
+ timeout_minutes: 1
+ max_attempts: 5
+ command: /bin/bash -c "xset -q > /dev/null 2>&1"
+ env:
+ DISPLAY: ":0"
+
+ - name: Starting dummy sound device
+ if: ${{ runner.os == 'Linux' }}
+ uses: nick-fields/retry@v2
+ with:
+ timeout_minutes: 1
+ max_attempts: 3
+ command: pulseaudio --check || pulseaudio -D
diff --git a/.github/workflows/release-packaging.yml b/.github/workflows/release-packaging.yml
index 7cff776..73667ad 100644
--- a/.github/workflows/release-packaging.yml
+++ b/.github/workflows/release-packaging.yml
@@ -19,18 +19,55 @@ jobs:
- name: Load dotenv
run: just ci-load-dotenv
+ # Retry multiple times, sometimes in CI, gdlint raise "file exists"
- name: Check
- run: just fmt
+ uses: nick-fields/retry@v2
+ with:
+ timeout_minutes: 1
+ max_attempts: 3
+ command: just fmt
- name: Ensure version is equal to tag
if: startsWith(github.ref, 'refs/tags/')
run: |
- [ "${{ env.game_version }}" == "${{ env.BRANCH_NAME }}" ] || exit 2
+ [ "${{ env.addon_version }}" == "${{ env.BRANCH_NAME }}" ] || exit 2
+
+ test:
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 30
+
+ strategy:
+ fail-fast: true
+ matrix:
+ os: [ ubuntu-22.04, windows-2022, macos-12 ]
+ godot_version: [ '4.0.4', '4.1.3', '4.2.1' ]
+
+ steps:
+ - uses: actions/checkout@v4
+ - uses: extractions/setup-just@v1
+
+ - name: Setup Godot dependencies
+ uses: ./.github/actions/setup-godot
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.10'
+
+ - name: Run unit tests
+ run: just unit
+ env:
+ GODOT_VERSION: ${{ matrix.godot_version }}
+
+ - name: Run integration tests
+ if: ${{ runner.os == 'Linux' }}
+ run: just integration
+ env:
+ GODOT_VERSION: ${{ matrix.godot_version }}
publish:
runs-on: ubuntu-22.04
timeout-minutes: 30
- needs: [check]
+ needs: [check, test]
if: startsWith(github.ref, 'refs/tags/')
diff --git a/.gitignore b/.gitignore
index 97b3542..7f775a3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,14 +10,15 @@ gfxrecon_capture_*
.mono/
data_*/
+# gd-plug
+.plugged/
+addons/*
+!addons/gd-plug/
+!addons/greeter/
+
# Python-specific ignores
venv/
-# Export output
-dist/
-build/
-override.cfg
-
# Docs output
reference.json
site/
diff --git a/.gutconfig.json b/.gutconfig.json
new file mode 100644
index 0000000..6f58ed1
--- /dev/null
+++ b/.gutconfig.json
@@ -0,0 +1,10 @@
+{
+ "dirs": [
+ "res://test"
+ ],
+ "prefix": "",
+ "suffix": ".test.gd",
+ "include_subdirs": true,
+ "log_level": 1,
+ "should_exit": true
+}
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index f9553f7..cd9d97c 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -46,6 +46,11 @@ repos:
entry: gdformat
language: system
files: \.gd$
+ exclude: |
+ (?x)^(
+ addons/gd-plug/|
+ plug.gd
+ )
- id: check-shaders
name: check shaders
entry: clang-format
@@ -55,9 +60,13 @@ repos:
- -i
language: system
files: \.gdshader$
- exclude: ^addons/
- id: lint-gdscript
name: lint gdscript
entry: gdlint
language: system
files: \.gd$
+ exclude: |
+ (?x)^(
+ addons/gd-plug/|
+ plug.gd
+ )
diff --git a/.reuse/dep5 b/.reuse/dep5
index 78e5ba6..ddd5fd2 100644
--- a/.reuse/dep5
+++ b/.reuse/dep5
@@ -6,3 +6,15 @@ Source: https://github.com/MechanicalFlower/godot-addons-template
Files: *
Copyright: 2023-present Florian Vazelle
License: MIT
+
+# Addons
+
+Files: addons/gd-plug/*
+Copyright: 2021 Tan Jian Ping
+License: MIT
+Source: https://github.com/imjp94/gd-plug
+
+Files: addons/gut/*
+Copyright: 2018 Tom "Butch" Wesley
+License: MIT
+Source: https://github.com/bitwes/Gut
diff --git a/Justfile b/Justfile
index ea1a36f..2f7233f 100644
--- a/Justfile
+++ b/Justfile
@@ -23,9 +23,34 @@ bin_dir := main_dir / "bin"
# Godot variables
godot_version := env_var('GODOT_VERSION')
-godot_platform := if arch() == "x86" { "linux.x86_32" } else { if arch() == "x86_64" { "linux.x86_64" } else { "" } }
+godot_platform := if os() == "windows" {
+ if arch() == "x86" {
+ "win32.exe"
+ } else if arch() == "x86_64" {
+ "win64.exe"
+ } else {
+ error("Architecture not supported")
+ }
+} else if os() == "macos" {
+ "macos.universal"
+} else if os() == "linux" {
+ if arch() == "x86" {
+ "linux.x86_32"
+ } else if arch() == "x86_64" {
+ "linux.x86_64"
+ } else if arch() == "arm" {
+ "linux.arm32"
+ } else if arch() == "aarch64" {
+ "linux.arm64"
+ } else {
+ error("Architecture not supported")
+ }
+} else {
+ error("OS not supported")
+}
godot_filename := "Godot_v" + godot_version + "-stable_" + godot_platform
godot_bin := bin_dir / godot_filename
+use_x11_wrapper := if godot_platform =~ "x11*" { env("CI", "false") } else { "false" }
# Addon variables
addon_name := env_var('ADDON_NAME')
@@ -38,7 +63,7 @@ venv_dir := justfile_directory() / "venv"
# Display all commands
@default:
- echo "OS: {{ os() }} - ARCH: {{ arch() }}\n"
+ echo "OS: {{ os() }} - ARCH: {{ arch() }}"
just --list
# Create directories
@@ -56,10 +81,9 @@ venv_dir := justfile_directory() / "venv"
[private]
install-godot:
#!/usr/bin/env sh
- if [ ! -e {{ godot_bin }} ]
- then
+ if [ ! -e {{ godot_bin }} ]; then
curl -X GET "https://downloads.tuxfamily.org/godotengine/{{ godot_version }}/{{ godot_filename }}.zip" --output {{ cache_dir }}/{{ godot_filename }}.zip
- unzip {{ cache_dir }}/{{ godot_filename }}.zip -d {{ cache_dir }}
+ unzip -o {{ cache_dir }}/{{ godot_filename }}.zip -d {{ cache_dir }}
cp {{ cache_dir }}/{{ godot_filename }} {{ godot_bin }}
fi
@@ -67,14 +91,25 @@ install-godot:
install-addons:
[ -f plug.gd ] && just godot --headless --script plug.gd install || true
+# Import game resources
+import-resources:
+ just godot --headless --editor --quit
+
# Updates the addon version
@bump-version:
echo "Update version in the plugin.cfg"
sed -i "s,version=.*$,version=\"{{ addon_version }}\",g" ./addons/{{ addon_name }}/plugin.cfg
+ echo "Update version in the README.md"
+ sed -i "s,tag = ".*"$,tag = \"{{ addon_version }}\"\, include = [\"addons/{{ addon_name }}\"]}),g" ./README.md
# Godot binary wrapper
@godot *ARGS: makedirs install-godot
- {{ godot_bin }} {{ ARGS }}
+ #!/usr/bin/env sh
+ if [ "{{ use_x11_wrapper }}" = "true" ]; then
+ just ci-godot-x11 {{ ARGS }}
+ else
+ {{ godot_bin }} {{ ARGS }}
+ fi
# Open the Godot editor
editor:
@@ -82,19 +117,19 @@ editor:
# Run files formatters
fmt:
- just venv pip install pre-commit==3.3.3 reuse==2.1.0 gdtoolkit==4.*
+ just venv pip install pre-commit==3.5.0 reuse==2.1.0 gdtoolkit==4.*
just venv pre-commit run -a
# Remove cache and binaries created by this Justfile
[private]
-clean-mkflower:
+clean-glam-cache:
rm -rf {{ main_dir }}
rm -rf {{ venv_dir }}
# Remove plugins
clean-addons:
rm -rf .plugged
- [ -f plug.gd ] && find addons/ -type d -not -name 'addons' -not -name 'gd-plug' -not -name '{{ addon_name }}' -exec rm -rf {} \; || true
+ [ -f plug.gd ] && find addons/ -maxdepth 1 -type d -not -name 'addons' -not -name 'gd-plug' -not -name '{{ addon_name }}' -exec rm -rf {} \; || true
# Remove any unnecessary files
clean: clean-addons
@@ -105,7 +140,42 @@ ci-load-dotenv:
echo "addon_name={{ addon_name }}" >> $GITHUB_ENV
echo "addon_version={{ addon_version }}" >> $GITHUB_ENV
+# Starts godot using Xvfb and pulseaudio
+ci-godot-x11 *ARGS:
+ #!/bin/bash
+ set -e
+ # Set locale to 'en' if locale is not already set.
+ # Godot will fallback to this locale anyway and it
+ # prevents an error message being printed to console.
+ [ "$LANG" == "C.UTF-8" ] && LANG=en || true
+
+ # Start dummy sound device.
+ pulseaudio --check || pulseaudio -D
+
+ # Running godot with X11 Display.
+ xvfb-run --auto-servernum {{ godot_bin }} {{ ARGS }}
+
+ # Cleanup (allowed to fail).
+ pulseaudio -k || true
+
# Upload the addon on Github
publish:
gh release create "{{ addon_version }}" --title="v{{ addon_version }}" --generate-notes
- # TODO: Add a asset-lib publish step
+ # TODO: Add an asset-lib publish step
+
+# Generate documentation
+doc:
+ sed -i "s,res://src,res://addons/{{ addon_name }},g" ./addons/godot-autogen-docs/reference_collector.gd
+ just godot --editor --headless --quit --script addons/godot-autogen-docs/reference_collector_cli.gd
+ just godot --headless --quit --script addons/godot-autogen-docs/markdown.gd
+ just venv pip install mkdocs==1.5.3 mkdocs-literate-nav==0.6.1
+ just venv mkdocs build
+
+# Start serving the documentation
+serve:
+ just venv pip install mkdocs==1.5.3 mkdocs-literate-nav==0.6.1
+ just venv mkdocs serve
+
+# Run unit tests
+unit: install-addons import-resources
+ just godot --headless --script addons/gut/gut_cmdln.gd -gconfig=.gutconfig.json
diff --git a/README.md b/README.md
index 57c2cb1..f248daa 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
-# Greeter
+# 🔌 Greeter
![Godot Badge](https://img.shields.io/badge/godot-4.2-blue?logo=Godot-Engine&logoColor=white)
![license](https://img.shields.io/badge/license-MIT-green?logo=open-source-initiative&logoColor=white)
diff --git a/addons/gd-plug/plug.gd b/addons/gd-plug/plug.gd
new file mode 100644
index 0000000..c565a0e
--- /dev/null
+++ b/addons/gd-plug/plug.gd
@@ -0,0 +1,1015 @@
+@tool
+extends SceneTree
+
+signal updated(plugin)
+
+const VERSION = "0.2.5"
+const DEFAULT_PLUGIN_URL = "https://git::@github.com/%s.git"
+const DEFAULT_PLUG_DIR = "res://.plugged"
+const DEFAULT_CONFIG_PATH = DEFAULT_PLUG_DIR + "/index.cfg"
+const DEFAULT_USER_PLUG_SCRIPT_PATH = "res://plug.gd"
+const DEFAULT_BASE_PLUG_SCRIPT_PATH = "res://addons/gd-plug/plug.gd"
+
+const ENV_PRODUCTION = "production"
+const ENV_TEST = "test"
+const ENV_FORCE = "force"
+const ENV_KEEP_IMPORT_FILE = "keep_import_file"
+const ENV_KEEP_IMPORT_RESOURCE_FILE = "keep_import_resource_file"
+
+const MSG_PLUG_START_ASSERTION = "_plug_start() must be called first"
+
+var project_dir
+var installation_config = ConfigFile.new()
+var logger = _Logger.new()
+
+var _installed_plugins
+var _plugged_plugins = {}
+
+var _threads = []
+var _mutex = Mutex.new()
+var _start_time = 0
+var threadpool = _ThreadPool.new(logger)
+
+
+func _init():
+ threadpool.connect("all_thread_finished", request_quit)
+ project_dir = DirAccess.open("res://")
+
+func _initialize():
+ var args = OS.get_cmdline_args()
+ # Trim unwanted args passed to godot executable
+ for arg in Array(args):
+ args.remove_at(0)
+ if "plug.gd" in arg:
+ break
+
+ for arg in args:
+ # NOTE: "--key" or "-key" will always be consumed by godot executable, see https://github.com/godotengine/godot/issues/8721
+ var key = arg.to_lower()
+ match key:
+ "detail":
+ logger.log_format = _Logger.DEFAULT_LOG_FORMAT_DETAIL
+ "debug", "d":
+ logger.log_level = _Logger.LogLevel.DEBUG
+ "quiet", "q", "silent":
+ logger.log_level = _Logger.LogLevel.NONE
+ "production":
+ OS.set_environment(ENV_PRODUCTION, "true")
+ "test":
+ OS.set_environment(ENV_TEST, "true")
+ "force":
+ OS.set_environment(ENV_FORCE, "true")
+ "keep-import-file":
+ OS.set_environment(ENV_KEEP_IMPORT_FILE, "true")
+ "keep-import-resource-file":
+ OS.set_environment(ENV_KEEP_IMPORT_RESOURCE_FILE, "true")
+
+ logger.debug("cmdline_args: %s" % args)
+ _start_time = Time.get_ticks_msec()
+ _plug_start()
+ if args.size() > 0:
+ _plugging()
+ match args[0]:
+ "init":
+ _plug_init()
+ "install", "update":
+ _plug_install()
+ "uninstall":
+ _plug_uninstall()
+ "clean":
+ _plug_clean()
+ "upgrade":
+ _plug_upgrade()
+ "status":
+ _plug_status()
+ "version":
+ logger.info(VERSION)
+ _:
+ logger.error("Unknown command %s" % args[0])
+ # NOTE: Do no put anything after this line except request_quit(), as _plug_*() may call request_quit()
+ request_quit()
+
+func _process(delta):
+ threadpool.process(delta)
+
+func _finalize():
+ _plug_end()
+ threadpool.stop()
+ logger.info("Finished, elapsed %.3fs" % ((Time.get_ticks_msec() - _start_time) / 1000.0))
+
+func _on_updated(plugin):
+ pass
+
+func _plugging():
+ pass
+
+func request_quit(exit_code=-1):
+ if threadpool.is_all_thread_finished() and threadpool.is_all_task_finished():
+ quit(exit_code)
+ return true
+ logger.debug("Request quit declined, threadpool is still running")
+ return false
+
+# Index installed plugins, or create directory "plugged" if not exists
+func _plug_start():
+ logger.debug("Plug start")
+ if not project_dir.dir_exists_absolute(DEFAULT_PLUG_DIR):
+ if project_dir.make_dir(ProjectSettings.globalize_path(DEFAULT_PLUG_DIR)) == OK:
+ logger.debug("Make dir %s for plugin installation")
+ if installation_config.load(DEFAULT_CONFIG_PATH) == OK:
+ logger.debug("Installation config loaded")
+ else:
+ logger.debug("Installation config not found")
+ _installed_plugins = installation_config.get_value("plugin", "installed", {})
+
+# Install plugin or uninstall plugin if unlisted
+func _plug_end():
+ assert(_installed_plugins != null, MSG_PLUG_START_ASSERTION)
+ var test = !OS.get_environment(ENV_TEST).is_empty()
+ if not test:
+ installation_config.set_value("plugin", "installed", _installed_plugins)
+ if installation_config.save(DEFAULT_CONFIG_PATH) == OK:
+ logger.debug("Plugged config saved")
+ else:
+ logger.error("Failed to save plugged config")
+ else:
+ logger.warn("Skipped saving of plugged config in test mode")
+ _installed_plugins = null
+
+func _plug_init():
+ assert(_installed_plugins != null, MSG_PLUG_START_ASSERTION)
+ logger.info("Init gd-plug...")
+ if FileAccess.file_exists(DEFAULT_USER_PLUG_SCRIPT_PATH):
+ logger.warn("%s already exists!" % DEFAULT_USER_PLUG_SCRIPT_PATH)
+ else:
+ var file = FileAccess.open(DEFAULT_USER_PLUG_SCRIPT_PATH, FileAccess.WRITE)
+ file.store_string(INIT_PLUG_SCRIPT)
+ file.close()
+ logger.info("Created %s" % DEFAULT_USER_PLUG_SCRIPT_PATH)
+
+func _plug_install():
+ assert(_installed_plugins != null, MSG_PLUG_START_ASSERTION)
+ threadpool.active = false
+ logger.info("Installing...")
+ for plugin in _plugged_plugins.values():
+ var installed = plugin.name in _installed_plugins
+ if installed:
+ var installed_plugin = get_installed_plugin(plugin.name)
+ if (installed_plugin.dev or plugin.dev) and OS.get_environment(ENV_PRODUCTION):
+ logger.info("Remove dev plugin for production: %s" % plugin.name)
+ threadpool.enqueue_task(uninstall_plugin.bind(installed_plugin))
+ else:
+ threadpool.enqueue_task(update_plugin.bind(plugin))
+ else:
+ threadpool.enqueue_task(install_plugin.bind(plugin))
+
+ var removed_plugins = []
+ for plugin in _installed_plugins.values():
+ var removed = not (plugin.name in _plugged_plugins)
+ if removed:
+ removed_plugins.append(plugin)
+ if removed_plugins:
+ threadpool.disconnect("all_thread_finished", request_quit)
+ if not threadpool.is_all_thread_finished():
+ threadpool.active = true
+ await threadpool.all_thread_finished
+ threadpool.active = false
+ logger.debug("All installation finished! Ready to uninstall removed plugins...")
+ threadpool.connect("all_thread_finished", request_quit)
+ for plugin in removed_plugins:
+ threadpool.enqueue_task(uninstall_plugin.bind(plugin), Thread.PRIORITY_LOW)
+ threadpool.active = true
+
+func _plug_uninstall():
+ assert(_installed_plugins != null, MSG_PLUG_START_ASSERTION)
+ threadpool.active = false
+ logger.info("Uninstalling...")
+ for plugin in _installed_plugins.values():
+ var installed_plugin = get_installed_plugin(plugin.name)
+ threadpool.enqueue_task(uninstall_plugin.bind(installed_plugin), Thread.PRIORITY_LOW)
+ threadpool.active = true
+
+func _plug_clean():
+ assert(_installed_plugins != null, MSG_PLUG_START_ASSERTION)
+ threadpool.active = false
+ logger.info("Cleaning...")
+ var plugged_dir = DirAccess.open(DEFAULT_PLUG_DIR)
+ plugged_dir.include_hidden = true
+ plugged_dir.list_dir_begin() # TODOGODOT4 fill missing arguments https://github.com/godotengine/godot/pull/40547
+ var file = plugged_dir.get_next()
+ while not file.is_empty():
+ if plugged_dir.current_is_dir():
+ if not (file in _installed_plugins):
+ logger.info("Remove %s" % file)
+ threadpool.enqueue_task(directory_delete_recursively.bind(plugged_dir.get_current_dir() + "/" + file))
+ file = plugged_dir.get_next()
+ plugged_dir.list_dir_end()
+ threadpool.active = true
+
+func _plug_upgrade():
+ assert(_installed_plugins != null, MSG_PLUG_START_ASSERTION)
+ threadpool.active = false
+ logger.info("Upgrading gd-plug...")
+ plug("imjp94/gd-plug")
+ var gd_plug = _plugged_plugins["gd-plug"]
+ OS.set_environment(ENV_FORCE, "true") # Required to overwrite res://addons/gd-plug/plug.gd
+ threadpool.enqueue_task(install_plugin.bind(gd_plug))
+ threadpool.disconnect("all_thread_finished", request_quit)
+ if not threadpool.is_all_thread_finished():
+ threadpool.active = true
+ await threadpool.all_thread_finished
+ threadpool.active = false
+ logger.debug("All installation finished! Ready to uninstall removed plugins...")
+ threadpool.connect("all_thread_finished", request_quit)
+ threadpool.enqueue_task(directory_delete_recursively.bind(gd_plug.plug_dir))
+ threadpool.active = true
+
+func _plug_status():
+ assert(_installed_plugins != null, MSG_PLUG_START_ASSERTION)
+ threadpool.active = false
+ logger.info("Installed %d plugin%s" % [_installed_plugins.size(), "s" if _installed_plugins.size() > 1 else ""])
+ var new_plugins = _plugged_plugins.duplicate()
+ var has_checking_plugin = false
+ var removed_plugins = []
+ for plugin in _installed_plugins.values():
+ logger.info("- {name} - {url}".format(plugin))
+ new_plugins.erase(plugin.name)
+ var removed = not (plugin.name in _plugged_plugins)
+ if removed:
+ removed_plugins.append(plugin)
+ else:
+ threadpool.enqueue_task(check_plugin.bind(_plugged_plugins[plugin.name]))
+ has_checking_plugin = true
+ if has_checking_plugin:
+ logger.info("\n", true)
+ threadpool.disconnect("all_thread_finished", request_quit)
+ threadpool.active = true
+ await threadpool.all_thread_finished
+ threadpool.active = false
+ threadpool.connect("all_thread_finished", request_quit)
+ logger.debug("Finished checking plugins, ready to proceed")
+ if new_plugins:
+ logger.info("\nPlugged %d plugin%s" % [new_plugins.size(), "s" if new_plugins.size() > 1 else ""])
+ for plugin in new_plugins.values():
+ var is_new = not (plugin.name in _installed_plugins)
+ if is_new:
+ logger.info("- {name} - {url}".format(plugin))
+ if removed_plugins:
+ logger.info("\nUnplugged %d plugin%s" % [removed_plugins.size(), "s" if removed_plugins.size() > 1 else ""])
+ for plugin in removed_plugins:
+ logger.info("- %s removed" % plugin.name)
+ var plug_directory = DirAccess.open(DEFAULT_PLUG_DIR)
+ var orphan_dirs = []
+ if plug_directory.get_open_error() == OK:
+ plug_directory.list_dir_begin() # TODOGODOT4 fill missing arguments https://github.com/godotengine/godot/pull/40547
+ var file = plug_directory.get_next()
+ while not file.is_empty():
+ if plug_directory.current_is_dir():
+ if not (file in _installed_plugins):
+ orphan_dirs.append(file)
+ file = plug_directory.get_next()
+ plug_directory.list_dir_end()
+ if orphan_dirs:
+ logger.info("\nOrphan directory, %d found in %s, execute \"clean\" command to remove" % [orphan_dirs.size(), DEFAULT_PLUG_DIR])
+ for dir in orphan_dirs:
+ logger.info("- %s" % dir)
+ threadpool.active = true
+
+ if has_checking_plugin:
+ request_quit()
+
+# Index & validate plugin
+func plug(repo, args={}):
+ assert(_installed_plugins != null, MSG_PLUG_START_ASSERTION)
+ repo = repo.strip_edges()
+ var plugin_name = get_plugin_name_from_repo(repo)
+ if plugin_name in _plugged_plugins:
+ logger.info("Plugin already plugged: %s" % plugin_name)
+ return
+ var plugin = {}
+ plugin.name = plugin_name
+ plugin.url = ""
+ if ":" in repo:
+ plugin.url = repo
+ elif repo.find("/") == repo.rfind("/"):
+ plugin.url = DEFAULT_PLUGIN_URL % repo
+ else:
+ logger.error("Invalid repo: %s" % repo)
+ plugin.plug_dir = DEFAULT_PLUG_DIR + "/" + plugin.name
+
+ var is_valid = true
+ plugin.include = args.get("include", [])
+ is_valid = is_valid and validate_var_type(plugin, "include", TYPE_ARRAY, "Array")
+ plugin.exclude = args.get("exclude", [])
+ is_valid = is_valid and validate_var_type(plugin, "exclude", TYPE_ARRAY, "Array")
+ plugin.branch = args.get("branch", "")
+ is_valid = is_valid and validate_var_type(plugin, "branch", TYPE_STRING, "String")
+ plugin.tag = args.get("tag", "")
+ is_valid = is_valid and validate_var_type(plugin, "tag", TYPE_STRING, "String")
+ plugin.commit = args.get("commit", "")
+ is_valid = is_valid and validate_var_type(plugin, "commit", TYPE_STRING, "String")
+ if not plugin.commit.is_empty():
+ var is_valid_commit = plugin.commit.length() == 40
+ if not is_valid_commit:
+ logger.error("Expected full length 40 digits commit-hash string, given %s" % plugin.commit)
+ is_valid = is_valid and is_valid_commit
+ plugin.dev = args.get("dev", false)
+ is_valid = is_valid and validate_var_type(plugin, "dev", TYPE_BOOL, "Boolean")
+ plugin.on_updated = args.get("on_updated", "")
+ is_valid = is_valid and validate_var_type(plugin, "on_updated", TYPE_STRING, "String")
+ plugin.install_root = args.get("install_root", "")
+ is_valid = is_valid and validate_var_type(plugin, "install_root", TYPE_STRING, "String")
+
+ if is_valid:
+ _plugged_plugins[plugin.name] = plugin
+ logger.debug("Plug: %s" % plugin)
+ else:
+ logger.error("Failed to plug %s, validation error" % plugin.name)
+
+func install_plugin(plugin):
+ var test = !OS.get_environment(ENV_TEST).is_empty()
+ var can_install = OS.get_environment(ENV_PRODUCTION).is_empty() if plugin.dev else true
+ if can_install:
+ logger.info("Installing plugin %s..." % plugin.name)
+ var result = is_plugin_downloaded(plugin)
+ if result != OK:
+ result = download(plugin)
+ else:
+ logger.info("Plugin already downloaded")
+
+ if result == OK:
+ install(plugin)
+ else:
+ logger.error("Failed to install plugin %s with error code %d" % [plugin.name, result])
+
+func uninstall_plugin(plugin):
+ var test = !OS.get_environment(ENV_TEST).is_empty()
+ logger.info("Uninstalling plugin %s..." % plugin.name)
+ uninstall(plugin)
+ directory_delete_recursively(plugin.plug_dir, {"exclude": [DEFAULT_CONFIG_PATH], "test": test})
+
+func update_plugin(plugin, checking=false):
+ if not (plugin.name in _installed_plugins):
+ logger.info("%s new plugin" % plugin.name)
+ return true
+
+ var git = _GitExecutable.new(ProjectSettings.globalize_path(plugin.plug_dir), logger)
+ var installed_plugin = get_installed_plugin(plugin.name)
+ var changes = compare_plugins(plugin, installed_plugin)
+ var should_clone = false
+ var should_pull = false
+ var should_reinstall = false
+
+ if plugin.tag or plugin.commit:
+ for rev in ["tag", "commit"]:
+ var freeze_at = plugin[rev]
+ if freeze_at:
+ logger.info("%s frozen at %s \"%s\"" % [plugin.name, rev, freeze_at])
+ break
+ else:
+ var ahead_behind = []
+ if git.fetch("origin " + plugin.branch if plugin.branch else "origin").exit == OK:
+ ahead_behind = git.get_commit_comparison("HEAD", "origin/" + plugin.branch if plugin.branch else "origin")
+ var is_commit_behind = !!ahead_behind[1] if ahead_behind.size() == 2 else false
+ if is_commit_behind:
+ logger.info("%s %d commits behind, update required" % [plugin.name, ahead_behind[1]])
+ should_pull = true
+ else:
+ logger.info("%s up to date" % plugin.name)
+
+ if changes:
+ logger.info("%s changed %s" % [plugin.name, changes])
+ should_reinstall = true
+ if "url" in changes or "branch" in changes or "tag" in changes or "commit" in changes:
+ logger.info("%s repository setting changed, update required" % plugin.name)
+ should_clone = true
+
+ if not checking:
+ if should_clone:
+ logger.info("%s cloning from %s..." % [plugin.name, plugin.url])
+ var test = !OS.get_environment(ENV_TEST).is_empty()
+ uninstall(get_installed_plugin(plugin.name))
+ directory_delete_recursively(plugin.plug_dir, {"exclude": [DEFAULT_CONFIG_PATH], "test": test})
+ if download(plugin) == OK:
+ install(plugin)
+ elif should_pull:
+ logger.info("%s pulling updates from %s..." % [plugin.name, plugin.url])
+ uninstall(get_installed_plugin(plugin.name))
+ if git.pull().exit == OK:
+ install(plugin)
+ elif should_reinstall:
+ logger.info("%s reinstalling..." % plugin.name)
+ uninstall(get_installed_plugin(plugin.name))
+ install(plugin)
+
+func check_plugin(plugin):
+ update_plugin(plugin, true)
+
+func download(plugin):
+ logger.info("Downloading %s from %s..." % [plugin.name, plugin.url])
+ var test = !OS.get_environment(ENV_TEST).is_empty()
+ var global_dest_dir = ProjectSettings.globalize_path(plugin.plug_dir)
+ if project_dir.dir_exists_absolute(plugin.plug_dir):
+ directory_delete_recursively(plugin.plug_dir)
+ project_dir.make_dir(plugin.plug_dir)
+ var result = _GitExecutable.new(global_dest_dir, logger).clone(plugin.url, global_dest_dir, {"branch": plugin.branch, "tag": plugin.tag, "commit": plugin.commit})
+ if result.exit == OK:
+ logger.info("Successfully download %s" % [plugin.name])
+ else:
+ logger.info("Failed to download %s" % plugin.name)
+ # Make sure plug_dir is clean when failed
+ directory_delete_recursively(plugin.plug_dir, {"exclude": [DEFAULT_CONFIG_PATH], "test": test})
+ project_dir.remove(plugin.plug_dir) # Remove empty directory
+ return result.exit
+
+func install(plugin):
+ var include = plugin.get("include", [])
+ if include.is_empty(): # Auto include "addons/" folder if not explicitly specified
+ include = ["addons/"]
+ if OS.get_environment(ENV_FORCE).is_empty() and OS.get_environment(ENV_TEST).is_empty():
+ var is_exists = false
+ var dest_files = directory_copy_recursively(plugin.plug_dir, "res://" + plugin.install_root, {"include": include, "exclude": plugin.exclude, "test": true, "silent_test": true})
+ for dest_file in dest_files:
+ if project_dir.file_exists(dest_file):
+ logger.warn("%s attempting to overwrite file %s" % [plugin.name, dest_file])
+ is_exists = true
+ if is_exists:
+ logger.warn("Installation of %s terminated to avoid overwriting user files, you may disable safe mode with command \"force\"" % plugin.name)
+ return ERR_ALREADY_EXISTS
+
+ logger.info("Installing files for %s..." % plugin.name)
+ var test = !OS.get_environment(ENV_TEST).is_empty()
+ var dest_files = directory_copy_recursively(plugin.plug_dir, "res://" + plugin.install_root, {"include": include, "exclude": plugin.exclude, "test": test})
+ plugin.dest_files = dest_files
+ logger.info("Installed %d file%s for %s" % [dest_files.size(), "s" if dest_files.size() > 1 else "", plugin.name])
+ if plugin.name != "gd-plug":
+ set_installed_plugin(plugin)
+ if plugin.on_updated:
+ if has_method(plugin.on_updated):
+ logger.info("Execute post-update function for %s" % plugin.name)
+ _on_updated(plugin)
+ call(plugin.on_updated, plugin.duplicate())
+ emit_signal("updated", plugin)
+ return OK
+
+func uninstall(plugin):
+ var test = !OS.get_environment(ENV_TEST).is_empty()
+ var keep_import_file = !OS.get_environment(ENV_KEEP_IMPORT_FILE).is_empty()
+ var keep_import_resource_file = !OS.get_environment(ENV_KEEP_IMPORT_RESOURCE_FILE).is_empty()
+ var dest_files = plugin.get("dest_files", [])
+ logger.info("Uninstalling %d file%s for %s..." % [dest_files.size(), "s" if dest_files.size() > 1 else "",plugin.name])
+ directory_remove_batch(dest_files, {"test": test, "keep_import_file": keep_import_file, "keep_import_resource_file": keep_import_resource_file})
+ logger.info("Uninstalled %d file%s for %s" % [dest_files.size(), "s" if dest_files.size() > 1 else "",plugin.name])
+ remove_installed_plugin(plugin.name)
+
+func is_plugin_downloaded(plugin):
+ if not project_dir.dir_exists_absolute(plugin.plug_dir + "/.git"):
+ return
+
+ var git = _GitExecutable.new(ProjectSettings.globalize_path(plugin.plug_dir), logger)
+ return git.is_up_to_date(plugin)
+
+# Get installed plugin, thread safe
+func get_installed_plugin(plugin_name):
+ assert(_installed_plugins != null, MSG_PLUG_START_ASSERTION)
+ _mutex.lock()
+ var installed_plugin = _installed_plugins[plugin_name]
+ _mutex.unlock()
+ return installed_plugin
+
+# Set installed plugin, thread safe
+func set_installed_plugin(plugin):
+ assert(_installed_plugins != null, MSG_PLUG_START_ASSERTION)
+ _mutex.lock()
+ _installed_plugins[plugin.name] = plugin
+ _mutex.unlock()
+
+# Remove installed plugin, thread safe
+func remove_installed_plugin(plugin_name):
+ assert(_installed_plugins != null, MSG_PLUG_START_ASSERTION)
+ _mutex.lock()
+ var result = _installed_plugins.erase(plugin_name)
+ _mutex.unlock()
+ return result
+
+func directory_copy_recursively(from, to, args={}):
+ var include = args.get("include", [])
+ var exclude = args.get("exclude", [])
+ var test = args.get("test", false)
+ var silent_test = args.get("silent_test", false)
+ var dir = DirAccess.open(from)
+ dir.include_hidden = true
+ var dest_files = []
+ if dir.get_open_error() == OK:
+ dir.list_dir_begin() # TODOGODOT4 fill missing arguments https://github.com/godotengine/godot/pull/40547
+ var file_name = dir.get_next()
+ while not file_name.is_empty():
+ var source = dir.get_current_dir() + ("/" if dir.get_current_dir() != "res://" else "") + file_name
+ var dest = to + ("/" if to != "res://" else "") + file_name
+
+ if dir.current_is_dir():
+ dest_files += directory_copy_recursively(source, dest, args)
+ else:
+ for include_key in include:
+ if include_key in source:
+ var is_excluded = false
+ for exclude_key in exclude:
+ if exclude_key in source:
+ is_excluded = true
+ break
+ if not is_excluded:
+ if test:
+ if not silent_test: logger.warn("[TEST] Writing to %s" % dest)
+ else:
+ dir.make_dir_recursive_absolute(to)
+ if dir.copy(source, dest) == OK:
+ logger.debug("Copy from %s to %s" % [source, dest])
+ dest_files.append(dest)
+ break
+ file_name = dir.get_next()
+ dir.list_dir_end()
+ else:
+ logger.error("Failed to access path: %s" % from)
+
+ return dest_files
+
+func directory_delete_recursively(dir_path, args={}):
+ var remove_empty_directory = args.get("remove_empty_directory", true)
+ var exclude = args.get("exclude", [])
+ var test = args.get("test", false)
+ var silent_test = args.get("silent_test", false)
+ var dir = DirAccess.open(dir_path)
+ dir.include_hidden = true
+ if dir.get_open_error() == OK:
+ dir.list_dir_begin() # TODOGODOT4 fill missing arguments https://github.com/godotengine/godot/pull/40547
+ var file_name = dir.get_next()
+ while not file_name.is_empty():
+ var source = dir.get_current_dir() + ("/" if dir.get_current_dir() != "res://" else "") + file_name
+
+ if dir.current_is_dir():
+ var sub_dir = directory_delete_recursively(source, args)
+ if remove_empty_directory:
+ if test:
+ if not silent_test: logger.warn("[TEST] Remove empty directory: %s" % sub_dir.get_current_dir())
+ else:
+ if source.get_file() == ".git":
+ var empty_dir_path = ProjectSettings.globalize_path(source)
+ var exit = FAILED
+ match OS.get_name():
+ "Windows":
+ empty_dir_path = "\"%s\"" % empty_dir_path
+ empty_dir_path = empty_dir_path.replace("/", "\\")
+ var cmd = "rd /s /q %s" % empty_dir_path
+ exit = OS.execute("cmd", ["/C", cmd])
+ "X11", "OSX", "Server":
+ empty_dir_path = "\'%s\'" % empty_dir_path
+ var cmd = "rm -rf %s" % empty_dir_path
+ exit = OS.execute("bash", ["-c", cmd])
+ # Hacks to remove .git, as git pack files stop it from being removed
+ # See https://stackoverflow.com/questions/1213430/how-to-fully-delete-a-git-repository-created-with-init
+ if exit == OK:
+ logger.debug("Remove empty directory: %s" % sub_dir.get_current_dir())
+ else:
+ logger.debug("Failed to remove empty directory: %s" % sub_dir.get_current_dir())
+ else:
+ if dir.remove(sub_dir.get_current_dir()) == OK:
+ logger.debug("Remove empty directory: %s" % sub_dir.get_current_dir())
+ else:
+ var excluded = false
+ for exclude_key in exclude:
+ if source in exclude_key:
+ excluded = true
+ break
+ if not excluded:
+ if test:
+ if not silent_test: logger.warn("[TEST] Remove file: %s" % source)
+ else:
+ if dir.remove(file_name) == OK:
+ logger.debug("Remove file: %s" % source)
+ file_name = dir.get_next()
+ dir.list_dir_end()
+ else:
+ logger.error("Failed to access path: %s" % dir_path)
+
+ if remove_empty_directory:
+ dir.remove(dir.get_current_dir())
+
+ return dir
+
+func directory_remove_batch(files, args={}):
+ var remove_empty_directory = args.get("remove_empty_directory", true)
+ var keep_import_file = args.get("keep_import_file", false)
+ var keep_import_resource_file = args.get("keep_import_resource_file", false)
+ var test = args.get("test", false)
+ var silent_test = args.get("silent_test", false)
+ var dirs = {}
+ for file in files:
+ var file_dir = file.get_base_dir()
+ var file_name =file.get_file()
+ var dir = dirs.get(file_dir)
+
+ if not dir:
+ dir = DirAccess.open(file_dir)
+ dirs[file_dir] = dir
+
+ if file.ends_with(".import"):
+ if not keep_import_file:
+ _remove_import_file(dir, file, keep_import_resource_file, test, silent_test)
+ else:
+ if test:
+ if not silent_test: logger.warn("[TEST] Remove file: " + file)
+ else:
+ if dir.remove(file_name) == OK:
+ logger.debug("Remove file: " + file)
+ if not keep_import_file:
+ _remove_import_file(dir, file + ".import", keep_import_resource_file, test, silent_test)
+
+ for dir in dirs.values():
+ var slash_count = dir.get_current_dir().count("/") - 2 # Deduct 2 slash from "res://"
+ if test:
+ if not silent_test: logger.warn("[TEST] Remove empty directory: %s" % dir.get_current_dir())
+ else:
+ if dir.remove(dir.get_current_dir()) == OK:
+ logger.debug("Remove empty directory: %s" % dir.get_current_dir())
+ # Dumb method to clean empty ancestor directories
+ logger.debug("Removing emoty ancestor directory for %s..." % dir.get_current_dir())
+ var current_dir = dir.get_current_dir()
+ for i in slash_count:
+ current_dir = current_dir.get_base_dir()
+ var d = DirAccess.open(current_dir)
+ if d.get_open_error() == OK:
+ if test:
+ if not silent_test: logger.warn("[TEST] Remove empty ancestor directory: %s" % d.get_current_dir())
+ else:
+ if d.remove(d.get_current_dir()) == OK:
+ logger.debug("Remove empty ancestor directory: %s" % d.get_current_dir())
+
+func _remove_import_file(dir, file, keep_import_resource_file=false, test=false, silent_test=false):
+ if not dir.file_exists(file):
+ return
+
+ if not keep_import_resource_file:
+ var import_config = ConfigFile.new()
+ if import_config.load(file) == OK:
+ var metadata = import_config.get_value("remap", "metadata", {})
+ var imported_formats = metadata.get("imported_formats", [])
+ if imported_formats:
+ for format in imported_formats:
+ _remove_import_resource_file(dir, import_config, "." + format, test)
+ else:
+ _remove_import_resource_file(dir, import_config, "", test)
+ if test:
+ if not silent_test: logger.warn("[TEST] Remove import file: " + file)
+ else:
+ if dir.remove(file) == OK:
+ logger.debug("Remove import file: " + file)
+ else:
+ # TODO: Sometimes Directory.remove() unable to remove random .import file and return error code 1(Generic Error)
+ # Maybe enforce the removal from shell?
+ logger.warn("Failed to remove import file: " + file)
+
+func _remove_import_resource_file(dir, import_config, import_format="", test=false):
+ var import_resource_file = import_config.get_value("remap", "path" + import_format, "")
+ var checksum_file = import_resource_file.trim_suffix("." + import_resource_file.get_extension()) + ".md5" if import_resource_file else ""
+ if import_resource_file:
+ if dir.file_exists(import_resource_file):
+ if test:
+ logger.info("[IMPORT] Remove import resource file: " + import_resource_file)
+ else:
+ if dir.remove(import_resource_file) == OK:
+ logger.debug("Remove import resource file: " + import_resource_file)
+ if checksum_file:
+ checksum_file = checksum_file.replace(import_format, "")
+ if dir.file_exists(checksum_file):
+ if test:
+ logger.info("[IMPORT] Remove import checksum file: " + checksum_file)
+ else:
+ if dir.remove(checksum_file) == OK:
+ logger.debug("Remove import checksum file: " + checksum_file)
+
+func compare_plugins(p1, p2):
+ var changed_keys = []
+ for key in p1.keys():
+ var v1 = p1[key]
+ var v2 = p2[key]
+ if v1 != v2:
+ changed_keys.append(key)
+ return changed_keys
+
+func get_plugin_name_from_repo(repo):
+ repo = repo.replace(".git", "").trim_suffix("/")
+ return repo.get_file()
+
+func validate_var_type(obj, var_name, type, type_string):
+ var value = obj.get(var_name)
+ var is_valid = typeof(value) == type
+ if not is_valid:
+ logger.error("Expected variable \"%s\" to be %s, given %s" % [var_name, type_string, value])
+ return is_valid
+
+const INIT_PLUG_SCRIPT = \
+"""extends "res://addons/gd-plug/plug.gd"
+
+func _plugging():
+ # Declare plugins with plug(repo, args)
+ # For example, clone from github repo("user/repo_name")
+ # plug("imjp94/gd-YAFSM") # By default, gd-plug will only install anything from "addons/" directory
+ # Or you can explicitly specify which file/directory to include
+ # plug("imjp94/gd-YAFSM", {"include": ["addons/"]}) # By default, gd-plug will only install anything from "addons/" directory
+ pass
+"""
+
+class _GitExecutable extends RefCounted:
+ var cwd = ""
+ var logger
+
+ func _init(p_cwd, p_logger):
+ cwd = p_cwd
+ logger = p_logger
+
+ func _execute(command, output=[], read_stderr=false):
+ var cmd = "cd '%s' && %s" % [cwd, command]
+ # NOTE: OS.execute() seems to ignore read_stderr
+ var exit = FAILED
+ match OS.get_name():
+ "Windows":
+ cmd = cmd.replace("\'", "\"") # cmd doesn't accept single-quotes
+ cmd = cmd if read_stderr else "%s 2> nul" % cmd
+ logger.debug("Execute \"%s\"" % cmd)
+ exit = OS.execute("cmd", ["/C", cmd], output, read_stderr)
+ "macOS", "Linux", "FreeBSD", "NetBSD", "OpenBSD", "BSD":
+ cmd if read_stderr else "%s 2>/dev/null" % cmd
+ logger.debug("Execute \"%s\"" % cmd)
+ exit = OS.execute("bash", ["-c", cmd], output, read_stderr)
+ var unhandled_os:
+ logger.error("Unexpected OS: %s" % unhandled_os)
+ logger.debug("Execution ended(code:%d): %s" % [exit, output])
+ return exit
+
+ func init():
+ logger.debug("Initializing git at %s..." % cwd)
+ var output = []
+ var exit = _execute("git init", output)
+ logger.debug("Successfully init" if exit == OK else "Failed to init")
+ return {"exit": exit, "output": output}
+
+ func clone(src, dest, args={}):
+ logger.debug("Cloning from %s to %s..." % [src, dest])
+ var output = []
+ var branch = args.get("branch", "")
+ var tag = args.get("tag", "")
+ var commit = args.get("commit", "")
+ var command = "git clone --depth=1 --progress '%s' '%s'" % [src, dest]
+ if branch or tag:
+ command = "git clone --depth=1 --single-branch --branch %s '%s' '%s'" % [branch if branch else tag, src, dest]
+ elif commit:
+ return clone_commit(src, dest, commit)
+ var exit = _execute(command, output)
+ logger.debug("Successfully cloned from %s" % src if exit == OK else "Failed to clone from %s" % src)
+ return {"exit": exit, "output": output}
+
+ func clone_commit(src, dest, commit):
+ var output = []
+ if commit.length() < 40:
+ logger.error("Expected full length 40 digits commit-hash to clone specific commit, given {%s}" % commit)
+ return {"exit": FAILED, "output": output}
+
+ logger.debug("Cloning from %s to %s @ %s..." % [src, dest, commit])
+ var result = init()
+ if result.exit == OK:
+ result = remote_add("origin", src)
+ if result.exit == OK:
+ result = fetch("%s %s" % ["origin", commit])
+ if result.exit == OK:
+ result = reset("--hard", "FETCH_HEAD")
+ return result
+
+ func fetch(rm="--all"):
+ logger.debug("Fetching %s..." % rm.replace("--", ""))
+ var output = []
+ var exit = _execute("git fetch %s" % rm, output)
+ logger.debug("Successfully fetched" if exit == OK else "Failed to fetch")
+ return {"exit": exit, "output": output}
+
+ func pull():
+ logger.debug("Pulling...")
+ var output = []
+ var exit = _execute("git pull --rebase", output)
+ logger.debug("Successfully pulled" if exit == OK else "Failed to pull")
+ return {"exit": exit, "output": output}
+
+ func remote_add(name, src):
+ logger.debug("Adding remote %s@%s..." % [name, src])
+ var output = []
+ var exit = _execute("git remote add %s '%s'" % [name, src], output)
+ logger.debug("Successfully added remote" if exit == OK else "Failed to add remote")
+ return {"exit": exit, "output": output}
+
+ func reset(mode, to):
+ logger.debug("Resetting %s %s..." % [mode, to])
+ var output = []
+ var exit = _execute("git reset %s %s" % [mode, to], output)
+ logger.debug("Successfully reset" if exit == OK else "Failed to reset")
+ return {"exit": exit, "output": output}
+
+ func get_commit_comparison(branch_a, branch_b):
+ var output = []
+ var exit = _execute("git rev-list --count --left-right %s...%s" % [branch_a, branch_b], output)
+ var raw_ahead_behind = output[0].split("\t")
+ var ahead_behind = []
+ for msg in raw_ahead_behind:
+ ahead_behind.append(msg.to_int())
+ return ahead_behind if exit == OK else []
+
+ func get_current_branch():
+ var output = []
+ var exit = _execute("git rev-parse --abbrev-ref HEAD", output)
+ return output[0] if exit == OK else ""
+
+ func get_current_tag():
+ var output = []
+ var exit = _execute("git describe --tags --exact-match", output)
+ return output[0] if exit == OK else ""
+
+ func get_current_commit():
+ var output = []
+ var exit = _execute("git rev-parse --short HEAD", output)
+ return output[0] if exit == OK else ""
+
+ func is_detached_head():
+ var output = []
+ var exit = _execute("git rev-parse --short HEAD", output)
+ return (!!output[0]) if exit == OK else true
+
+ func is_up_to_date(args={}):
+ if fetch().exit == OK:
+ var branch = args.get("branch", "")
+ var tag = args.get("tag", "")
+ var commit = args.get("commit", "")
+
+ if branch:
+ if branch == get_current_branch():
+ return FAILED if is_detached_head() else OK
+ elif tag:
+ if tag == get_current_tag():
+ return OK
+ elif commit:
+ if commit == get_current_commit():
+ return OK
+
+ var ahead_behind = get_commit_comparison("HEAD", "origin")
+ var is_commit_behind = !!ahead_behind[1] if ahead_behind.size() == 2 else false
+ return FAILED if is_commit_behind else OK
+ return FAILED
+
+class _ThreadPool extends RefCounted:
+ signal all_thread_finished()
+
+ var active = true
+
+ var _threads = []
+ var _finished_threads = []
+ var _mutex = Mutex.new()
+ var _tasks = []
+ var logger
+
+ func _init(p_logger):
+ logger = p_logger
+ _threads.resize(OS.get_processor_count())
+
+ func _execute_task(task):
+ var thread = _get_thread()
+ var can_execute = thread
+ if can_execute:
+ task.thread = weakref(thread)
+ var callable = task.get("callable")
+ thread.start(_execute.bind(task), task.priority)
+ logger.debug("Execute task %s.%s() " % [callable.get_object(), callable.get_method()])
+ return can_execute
+
+ func _execute(args):
+ var callable = args.get("callable")
+ callable.call()
+ _mutex.lock()
+ var thread = args.thread.get_ref()
+ _threads[_threads.find(thread)] = null
+ _finished_threads.append(thread)
+ var all_finished = is_all_thread_finished()
+ _mutex.unlock()
+
+ logger.debug("Execution finished %s.%s() " % [callable.get_object(), callable.get_method()])
+ if all_finished:
+ logger.debug("All thread finished")
+ emit_signal("all_thread_finished")
+
+ func _flush_tasks():
+ if _tasks.size() == 0:
+ return
+
+ var executed = true
+ while executed:
+ var task = _tasks.pop_front()
+ if task != null:
+ executed = _execute_task(task)
+ if not executed:
+ _tasks.push_front(task)
+ else:
+ executed = false
+
+ func _flush_threads():
+ for i in _finished_threads.size():
+ var thread = _finished_threads.pop_front()
+ if not thread.is_alive():
+ thread.wait_to_finish()
+
+ func enqueue_task(callable, priority=1):
+ enqueue({"callable": callable, "priority": priority})
+
+ func enqueue(task):
+ var can_execute = false
+ if active:
+ can_execute = _execute_task(task)
+ if not can_execute:
+ _tasks.append(task)
+
+ func process(delta):
+ if active:
+ _flush_tasks()
+ _flush_threads()
+
+ func stop():
+ _tasks.clear()
+ _flush_threads()
+
+ func _get_thread():
+ var thread
+ for i in OS.get_processor_count():
+ var t = _threads[i]
+ if t:
+ if not t.is_started():
+ thread = t
+ break
+ else:
+ thread = Thread.new()
+ _threads[i] = thread
+ break
+ return thread
+
+ func is_all_thread_finished():
+ for i in _threads.size():
+ if _threads[i]:
+ return false
+ return true
+
+ func is_all_task_finished():
+ for i in _tasks.size():
+ if _tasks[i]:
+ return false
+ return true
+
+class _Logger extends RefCounted:
+ enum LogLevel {
+ ALL, DEBUG, INFO, WARN, ERROR, NONE
+ }
+ const DEFAULT_LOG_FORMAT_DETAIL = "[{time}] [{level}] {msg}"
+ const DEFAULT_LOG_FORMAT_NORMAL = "{msg}"
+
+ var log_level = LogLevel.INFO
+ var log_format = DEFAULT_LOG_FORMAT_NORMAL
+ var log_time_format = "{year}/{month}/{day} {hour}:{minute}:{second}"
+
+ func debug(msg, raw=false):
+ _log(LogLevel.DEBUG, msg, raw)
+
+ func info(msg, raw=false):
+ _log(LogLevel.INFO, msg, raw)
+
+ func warn(msg, raw=false):
+ _log(LogLevel.WARN, msg, raw)
+
+ func error(msg, raw=false):
+ _log(LogLevel.ERROR, msg, raw)
+
+ func _log(level, msg, raw=false):
+ if log_level <= level:
+ if raw:
+ printraw(format_log(level, msg))
+ else:
+ print(format_log(level, msg))
+
+ func format_log(level, msg):
+ return log_format.format({
+ "time": log_time_format.format(get_formatted_datatime()),
+ "level": LogLevel.keys()[level],
+ "msg": msg
+ })
+
+ func get_formatted_datatime():
+ var datetime = Time.get_datetime_dict_from_system()
+ datetime.year = "%04d" % datetime.year
+ datetime.month = "%02d" % datetime.month
+ datetime.day = "%02d" % datetime.day
+ datetime.hour = "%02d" % datetime.hour
+ datetime.minute = "%02d" % datetime.minute
+ datetime.second = "%02d" % datetime.second
+ return datetime
diff --git a/addons/greeter/plugin.cfg b/addons/greeter/plugin.cfg
index 3f66029..abba850 100644
--- a/addons/greeter/plugin.cfg
+++ b/addons/greeter/plugin.cfg
@@ -4,3 +4,4 @@ name="greeter"
description=""
author="florianvazelle"
version="0.1.0"
+script="plugin.gd"
diff --git a/addons/greeter/plugin.gd b/addons/greeter/plugin.gd
new file mode 100644
index 0000000..da040b5
--- /dev/null
+++ b/addons/greeter/plugin.gd
@@ -0,0 +1,18 @@
+## Template Addon Plugin
+@tool
+extends EditorPlugin
+
+
+## Return the plugin name
+func get_plugin_name():
+ return "Greeter"
+
+
+## Just print a hello message
+func _enter_tree():
+ print("Hello John!")
+
+
+## Just print a goodbye message
+func _exit_tree():
+ print("Goodbye John!")
diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md
new file mode 100644
index 0000000..a31ab6b
--- /dev/null
+++ b/docs/SUMMARY.md
@@ -0,0 +1,8 @@
+- [Welcome](index.md)
+- [User Guide](user-guide/)
+- Developer Guide
+ * [API Reference](dev-guide/api-ref/)
+- About
+ * [Contributing](about/contributing.md)
+ * [Changelog](about/changelog.md)
+ * [License](about/license.md)
diff --git a/docs/about/changelog.md b/docs/about/changelog.md
new file mode 120000
index 0000000..699cc9e
--- /dev/null
+++ b/docs/about/changelog.md
@@ -0,0 +1 @@
+../../CHANGELOG.md
\ No newline at end of file
diff --git a/docs/about/contributing.md b/docs/about/contributing.md
new file mode 120000
index 0000000..f939e75
--- /dev/null
+++ b/docs/about/contributing.md
@@ -0,0 +1 @@
+../../CONTRIBUTING.md
\ No newline at end of file
diff --git a/docs/about/license.md b/docs/about/license.md
new file mode 120000
index 0000000..f0608a6
--- /dev/null
+++ b/docs/about/license.md
@@ -0,0 +1 @@
+../../LICENSE.md
\ No newline at end of file
diff --git a/docs/dev-guide/index.md b/docs/dev-guide/index.md
new file mode 100644
index 0000000..e69de29
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000..f14697f
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,7 @@
+# Godot Autogen Docs
+
+![Godot Badge](https://img.shields.io/badge/godot-4.1-blue?logo=Godot-Engine&logoColor=white)
+![license](https://img.shields.io/badge/license-MIT-green?logo=open-source-initiative&logoColor=white)
+
+Godot addon that automatically turns your code
+ comments into markdown, for use in documentation.
diff --git a/docs/user-guide/index.md b/docs/user-guide/index.md
new file mode 100644
index 0000000..51d0e02
--- /dev/null
+++ b/docs/user-guide/index.md
@@ -0,0 +1,11 @@
+# Usage
+
+To generate a `reference.json`, run:
+```
+godot --editor --headless --quit --script addons/godot-autogen-docs/reference_collector_cli.gd
+```
+
+And to turn this JSON file into markdown, run:
+```
+godot --headless --quit --script addons/godot-autogen-docs/markdown.gd
+```
diff --git a/mkdocs.yml b/mkdocs.yml
new file mode 100644
index 0000000..227b82f
--- /dev/null
+++ b/mkdocs.yml
@@ -0,0 +1,10 @@
+site_name: Greeter Docs
+
+theme:
+ name: readthedocs
+
+
+plugins:
+ - search
+ - literate-nav:
+ nav_file: SUMMARY.md
\ No newline at end of file
diff --git a/plug.gd b/plug.gd
new file mode 100644
index 0000000..5f617c3
--- /dev/null
+++ b/plug.gd
@@ -0,0 +1,6 @@
+extends "res://addons/gd-plug/plug.gd"
+
+
+func _plugging():
+ plug("bitwes/Gut", {"tag": "v9.1.1"})
+ plug("MechanicalFlower/godot-autogen-docs", {"commit": "d503bc44dc82c676ecd8baf06d781f29212dafdf"})
diff --git a/project.godot b/project.godot
index 20981aa..7ea5c9e 100644
--- a/project.godot
+++ b/project.godot
@@ -12,3 +12,7 @@ config_version=5
config/name="Greeter"
config/features=PackedStringArray("4.2")
+
+[editor_plugins]
+
+enabled=PackedStringArray("res://addons/greeter/plugin.cfg", "res://addons/gut/plugin.cfg")
diff --git a/test/greeter.test.gd b/test/greeter.test.gd
new file mode 100644
index 0000000..ea1b756
--- /dev/null
+++ b/test/greeter.test.gd
@@ -0,0 +1,7 @@
+extends "res://addons/gut/test.gd"
+
+const Plugin := preload("res://addons/freeter/plugin.gd")
+
+
+func test_plugin():
+ assert_eq(Plugin.get_plugin_name(), "Greeter")
\ No newline at end of file