godot-xterm/addons/gd-plug/plug.gd
Leroy Hopson 1d2898f6ab
chore(spelling): add codespell pre-commit hook
Fixes spelling mistakes identified by it.
2024-03-30 01:27:28 +13:00

1336 lines
41 KiB
GDScript

# SPDX-FileCopyrightText: 2021 Tan Jian Ping
# SPDX-License-Identifier: MIT
# Source: https://raw.githubusercontent.com/imjp94/gd-plug/b762f285a9ac10902a3f613edeee9633a08d8c2a/addons/gd-plug/plug.gd
@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
var help = false
var help_config = false
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:
"help":
help = true
"help-config":
help_config = true
"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 help_config:
show_config_syntax()
elif help or args.size() == 0:
show_syntax()
else:
_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])
show_syntax()
# NOTE: Do no put anything after this line except request_quit(), as _plug_*() may call request_quit()
request_quit()
func show_syntax():
logger.info("gd-plug - Minimal plugin manager for Godot")
logger.info("")
logger.info("Usage: godot --headless -s plug.gd action [options...]")
logger.info("")
logger.info("Actions:")
var actions = {
"init": "Initialize current project by creating plug.gd at root",
"status":
"Check the status of plugins(installed, added or removed), execute this command whenever in doubts",
"install(alias update)": "Install or update plugins based on plug.gd",
"uninstall": "Uninstall all plugins, regardless of plug.gd",
"clean": "Clean unused files/folders from /.plugged",
"upgrade": "Upgrade addons/gd-plug/plug.gd to the latest version",
"version": "Print current version of gd-plug",
}
logger.indent()
logger.table_start()
for action_name in actions:
logger.table_row([action_name, actions[action_name]])
logger.table_end()
logger.dedent()
logger.info("")
logger.info("Options:")
var options = {
"production":
"Install only plugins not marked as dev, or uninstall already installed dev plugins",
"test": "Testing mode, no files will actually be installed/uninstalled",
"force":
"Force gd-plug to overwrite destination files when running install command. *WARNING: Check README for more details*",
"keep-import-file": 'Keep ".import" files generated by plugin, when run uninstall command',
"keep-import-resource-file":
'Keep files located in ".import" that generated by plugin, when run uninstall command',
"debug(alias d)": "Print debug message",
"detail": 'Print with datetime and log level, "[{time}] [{level}] {msg}"',
"quiet(alias q, silent)": "Disable logging",
"help": "Show this help",
"help-config": "plug.gd configuration documentation"
}
logger.indent()
logger.table_start()
for option_name in options:
logger.table_row([option_name, options[option_name]])
logger.table_end()
logger.dedent()
logger.info("")
func show_config_syntax():
logger.info("Configs: plug(src, args={})")
logger.info("")
logger.info("Sources:")
logger.indent()
logger.info('Github repo: "username/repo", for example, "imjp94/gd-plug"')
logger.info("or")
logger.info('Any valid git url, for example, "git@github.com:username/repo.git"')
logger.dedent()
logger.info("")
logger.info("Arguments:")
var arguments = {
"include":
'Array of strings that define what files or directory to include. Only "addons/" will be included if omitted',
"exclude": "Array of strings that define what files or directory to exclude",
"branch": "Name of branch to freeze to",
"tag": "Name of tag to freeze to",
"commit":
"Commit hash string to freeze to, must be full length 40 digits commit-hash, for example, 7a642f90d3fb88976dd913051de994e58e838d1a",
"dev":
"Boolean to mark the plugin as dev or not, plugin marked as dev will not be installed when production command given",
"on-updated":
"Post update hook, a function name declared in plug.gd that will be called whenever the plugin installed/updated"
}
logger.indent()
logger.table_start()
for argument_name in arguments:
logger.table_row([argument_name, arguments[argument_name]])
logger.table_end()
logger.dedent()
logger.info("")
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 = 0):
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(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(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(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(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 empty 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}"
var indent_level = 0
var is_locked = false
var _rows
var _max_column_length = []
var _max_column_size = 0
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 is_locked:
return
if typeof(msg) != TYPE_STRING:
msg = str(msg)
if log_level <= level:
match level:
LogLevel.WARN:
push_warning(format_log(level, msg))
LogLevel.ERROR:
push_error(format_log(level, msg))
_:
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.indent(" ".repeat(indent_level))
}
)
func indent():
indent_level += 1
func dedent():
indent_level -= 1
max(indent_level, 0)
func lock():
is_locked = true
func unlock():
is_locked = false
func table_start():
_rows = []
func table_end():
assert(_rows != null, "Expected table_start() to be called first")
for columns in _rows:
var text = ""
for i in columns.size():
var column = columns[i]
var max_tab_count = ceil(float(_max_column_length[i]) / 4.0)
var tab_count = max_tab_count - ceil(float(column.length()) / 4.0)
var extra_spaces = ceil(float(column.length()) / 4.0) * 4 - column.length()
if i < _max_column_size - 1:
text += column + " ".repeat(extra_spaces) + " ".repeat(tab_count)
else:
text += column
info(text)
_rows.clear()
_rows = null
_max_column_length.clear()
_max_column_size = 0
func table_row(columns = []):
assert(_rows != null, "Expected table_start() to be called first")
_rows.append(columns)
_max_column_size = max(_max_column_size, columns.size())
for i in columns.size():
var column = columns[i]
if _max_column_length.size() >= i + 1:
var max_column_length = _max_column_length[i]
_max_column_length[i] = max(max_column_length, column.length())
else:
_max_column_length.append(column.length())
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