# 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