From e236a0cfadc27cea01b156edf7304c53752e1fd7 Mon Sep 17 00:00:00 2001 From: Karstein Phobic Nyvold Kvistad Date: Sat, 25 Apr 2026 19:01:09 +0200 Subject: [PATCH] feat: add git_status / git_init / git_commit MCP tools (CODESYS Git plug-in) Cross-references the official Git scripting docs at https://content.helpme-codesys.com/en/CODESYS%20Git/_git_using_scripting.html which exposes a project-bound git API as 'primary_project.git' (when the CODESYS Git plug-in is loaded). Confirmed earlier today via dir(scriptengine) that 'git' is one of the top-level scriptengine modules on this install. Three tools in this commit, ordered shallow-to-deep: git_status Read-only. Returns current branch via project.git.branch_show_current() AND a defensive probe of any status/changes/diff/changed_files methods on project.git (the docs page lists the call patterns by example but does not enumerate the full API surface, so the probe + diagnostic dump is how we'll discover the rest in the next iteration). git_init Wraps project.git.init(local_repo_path). Defaults the repo path to the project file's parent directory so a plain 'init this project's folder' call needs no extra args. One-shot setup; pair with git_status afterwards. git_commit Wraps project.git.commit_complete(message, user, mail). Required: message + authorName + authorEmail (latter validated as email by zod). Stages all working-tree changes and commits in one shot per the docs. Multi-line messages handled via triple-quoted Python injection with standard backslash + triple-quote escaping. Defensive checks across all three: - hasattr(script_engine, 'git') so we can distinguish "Git plug-in not installed" from "project not in a repo". - project.git is None handled with a clear 'run git_init first' message. - On missing methods the script dumps sorted(dir(project.git)) so the next debug session sees the exact surface. Out of scope for this commit (deliberate -- one feature per commit per the project rule, more git ops can land separately): - branch ops (branch_copy, checkout) - remote ops (remote_add, push, pull, fetch, branch_set_upstream_to) - merge - clone (script_engine.git.clone -- top-level, not project-bound) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/scripts/git_commit.py | 58 ++++++++++++++++++++++++++++++++ src/scripts/git_init.py | 49 +++++++++++++++++++++++++++ src/scripts/git_status.py | 70 ++++++++++++++++++++++++++++++++++++++ src/server.ts | 71 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 248 insertions(+) create mode 100644 src/scripts/git_commit.py create mode 100644 src/scripts/git_init.py create mode 100644 src/scripts/git_status.py diff --git a/src/scripts/git_commit.py b/src/scripts/git_commit.py new file mode 100644 index 0000000..d411248 --- /dev/null +++ b/src/scripts/git_commit.py @@ -0,0 +1,58 @@ +import sys, scriptengine as script_engine, os, traceback + +COMMIT_MESSAGE = """{COMMIT_MESSAGE}""" +AUTHOR_NAME = "{AUTHOR_NAME}" +AUTHOR_EMAIL = "{AUTHOR_EMAIL}" + +try: + print("DEBUG: git_commit: Project='%s', Author='%s <%s>'" % ( + PROJECT_FILE_PATH, AUTHOR_NAME, AUTHOR_EMAIL)) + print("DEBUG: message (%d chars):" % len(COMMIT_MESSAGE)) + print(COMMIT_MESSAGE) + + if not COMMIT_MESSAGE.strip(): + raise ValueError("Commit message is empty.") + if not AUTHOR_NAME.strip(): + raise ValueError("Author name is empty.") + if not AUTHOR_EMAIL.strip(): + raise ValueError("Author email is empty.") + + primary_project = ensure_project_open(PROJECT_FILE_PATH) + + git = getattr(primary_project, 'git', None) + if git is None: + raise RuntimeError( + "Project '%s' is not bound to a Git repository (primary_project.git " + "is None). Run git_init first." % PROJECT_FILE_PATH) + + if not hasattr(git, 'commit_complete'): + attrs = sorted([a for a in dir(git) if not a.startswith('_')]) + raise AttributeError( + "project.git does not expose commit_complete(). " + "Available attributes: %s" % attrs) + + # commit_complete signature per docs: (message, user, mail). + # Stages all working-tree changes and commits in one shot. + print("DEBUG: calling git.commit_complete(message, '%s', '%s')" % (AUTHOR_NAME, AUTHOR_EMAIL)) + git.commit_complete(COMMIT_MESSAGE, AUTHOR_NAME, AUTHOR_EMAIL) + print("DEBUG: commit_complete returned without exception.") + + # Best-effort: report current branch after commit + branch = "?" + if hasattr(git, 'branch_show_current'): + try: + branch = str(git.branch_show_current()) + except Exception: + pass + + print("Committed on branch: %s" % branch) + print("Author: %s <%s>" % (AUTHOR_NAME, AUTHOR_EMAIL)) + print("SCRIPT_SUCCESS: git_commit complete.") + sys.exit(0) +except Exception as e: + detailed = traceback.format_exc() + msg = "Error in git_commit for project '%s': %s\n%s" % ( + PROJECT_FILE_PATH, e, detailed) + print(msg) + print("SCRIPT_ERROR: %s" % msg) + sys.exit(1) diff --git a/src/scripts/git_init.py b/src/scripts/git_init.py new file mode 100644 index 0000000..9653078 --- /dev/null +++ b/src/scripts/git_init.py @@ -0,0 +1,49 @@ +import sys, scriptengine as script_engine, os, traceback + +LOCAL_REPO_PATH = r"{LOCAL_REPO_PATH}" + +try: + print("DEBUG: git_init: Project='%s', LocalRepoPath='%s'" % ( + PROJECT_FILE_PATH, LOCAL_REPO_PATH)) + primary_project = ensure_project_open(PROJECT_FILE_PATH) + + if not hasattr(script_engine, 'git'): + raise RuntimeError( + "CODESYS Git plug-in is not available (scriptengine.git not " + "found). Install the Git package via the CODESYS Installer.") + + git = getattr(primary_project, 'git', None) + if git is None: + raise RuntimeError( + "primary_project.git is None; the Git plug-in is loaded but " + "the project does not yet have a git binding. The init() " + "call below should establish one if a repo path is supplied.") + + if not LOCAL_REPO_PATH: + # Default: init a repo in the project's own directory + LOCAL_REPO_PATH = os.path.dirname(PROJECT_FILE_PATH) + + print("DEBUG: calling git.init('%s')" % LOCAL_REPO_PATH) + git.init(LOCAL_REPO_PATH) + print("DEBUG: init returned without exception.") + + # Re-read the binding to pick up the now-existing repo + git = getattr(primary_project, 'git', None) + branch = "?" + if git is not None and hasattr(git, 'branch_show_current'): + try: + branch = str(git.branch_show_current()) + except Exception as e: + print("DEBUG: branch_show_current after init failed: %s" % e) + + print("Initialised git repo at: %s" % LOCAL_REPO_PATH) + print("Current branch: %s" % branch) + print("SCRIPT_SUCCESS: git_init complete.") + sys.exit(0) +except Exception as e: + detailed = traceback.format_exc() + msg = "Error in git_init for project '%s': %s\n%s" % ( + PROJECT_FILE_PATH, e, detailed) + print(msg) + print("SCRIPT_ERROR: %s" % msg) + sys.exit(1) diff --git a/src/scripts/git_status.py b/src/scripts/git_status.py new file mode 100644 index 0000000..1c1788d --- /dev/null +++ b/src/scripts/git_status.py @@ -0,0 +1,70 @@ +import sys, scriptengine as script_engine, os, traceback + +try: + print("DEBUG: git_status: Project='%s'" % PROJECT_FILE_PATH) + primary_project = ensure_project_open(PROJECT_FILE_PATH) + + # The project's git repo is exposed as primary_project.git when the + # CODESYS Git plug-in is loaded AND the project is in a git working + # tree. Probe defensively: not every CODESYS install ships Git. + git = getattr(primary_project, 'git', None) + if git is None: + # Fall back to script_engine.git presence to distinguish "Git + # plug-in missing" from "project not in a repo". + if not hasattr(script_engine, 'git'): + raise RuntimeError( + "CODESYS Git plug-in is not available on this install " + "(scriptengine.git not found). Install the Git package " + "via the CODESYS Installer.") + raise RuntimeError( + "Project '%s' is not bound to a Git repository. Use git_init " + "to initialise one, or open a project that is already in a " + "working tree." % PROJECT_FILE_PATH) + + git_attrs = sorted([a for a in dir(git) if not a.startswith('_')]) + print("DEBUG: project.git attributes: %s" % git_attrs) + + # Branch name: documented as branch_show_current(). + branch = "?" + try: + if hasattr(git, 'branch_show_current'): + branch = str(git.branch_show_current()) + elif hasattr(git, 'current_branch'): + v = git.current_branch + branch = str(v() if callable(v) else v) + except Exception as e: + print("DEBUG: branch lookup failed: %s" % e) + + # Probe for status / diff / changes methods. Doc page didn't specify + # exact names, so try several. Reports any that returned a value. + status_lines = [] + for method_name in ('status', 'get_status', 'changes', 'get_changes', + 'diff', 'get_diff', 'changed_files'): + if not hasattr(git, method_name): + continue + try: + attr = getattr(git, method_name) + value = attr() if callable(attr) else attr + status_lines.append(" %s -> %r" % (method_name, value)) + except Exception as e: + status_lines.append(" %s -> (raised) %s: %s" % (method_name, type(e).__name__, e)) + + print("Project: %s" % os.path.basename(PROJECT_FILE_PATH)) + print("Current branch: %s" % branch) + if status_lines: + print("Status probe results:") + for line in status_lines: + print(line) + else: + print("Status probe results: (none of status/changes/diff/changed_files exposed)") + print("project.git surface: %s" % git_attrs) + + print("SCRIPT_SUCCESS: git_status reported.") + sys.exit(0) +except Exception as e: + detailed = traceback.format_exc() + msg = "Error in git_status for project '%s': %s\n%s" % ( + PROJECT_FILE_PATH, e, detailed) + print(msg) + print("SCRIPT_ERROR: %s" % msg) + sys.exit(1) diff --git a/src/server.ts b/src/server.ts index 44faa53..1540099 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1044,6 +1044,77 @@ export async function startMcpServer(config: ServerConfig): Promise { } ); + // ─── Git Tools (CODESYS Git plug-in via project.git) ────────────────── + + s.tool( + 'git_status', + "Reports the project's git status: current branch, plus a probe of any status/changes/diff methods exposed on project.git. Read-only. Requires the CODESYS Git plug-in and a project bound to a git working tree (use git_init if not). Diagnostic dump of project.git surface is included.", + { + projectFilePath: z.string().describe("Path to the project file."), + }, + async (args: { projectFilePath: string }) => { + const escaped = resolvePath(args.projectFilePath, workspaceDir); + const script = scriptManager.prepareScriptWithHelpers( + 'git_status', + { PROJECT_FILE_PATH: escaped }, + ['ensure_project_open'] + ); + const result = await executor.executeScript(script); + return formatToolResponse(result, `git_status for ${args.projectFilePath} (see output for branch and probe results).`); + } + ); + + s.tool( + 'git_init', + "Initialises a Git repository for the project's directory (or a custom path) via project.git.init(). One-shot setup; use git_status afterwards to confirm. Requires CODESYS Git plug-in.", + { + projectFilePath: z.string().describe("Path to the project file."), + localRepoPath: z.string().optional().describe("Filesystem path to initialise the repo at. Defaults to the project file's parent directory."), + }, + async (args: { projectFilePath: string; localRepoPath?: string }) => { + const escaped = resolvePath(args.projectFilePath, workspaceDir); + const repoPath = args.localRepoPath ? resolvePath(args.localRepoPath, workspaceDir) : ''; + const script = scriptManager.prepareScriptWithHelpers( + 'git_init', + { + PROJECT_FILE_PATH: escaped, + LOCAL_REPO_PATH: repoPath, + }, + ['ensure_project_open'] + ); + const result = await executor.executeScript(script); + return formatToolResponse(result, `git_init complete for ${args.projectFilePath}.`); + } + ); + + s.tool( + 'git_commit', + "Stages all working-tree changes and commits them via project.git.commit_complete(message, user, mail). Requires the project to already be bound to a git repo (use git_init first if needed).", + { + projectFilePath: z.string().describe("Path to the project file."), + message: z.string().min(1).describe("Commit message. Multiline OK."), + authorName: z.string().min(1).describe("Author name (used as the 'user' parameter to commit_complete)."), + authorEmail: z.string().email().describe("Author email."), + }, + async (args: { projectFilePath: string; message: string; authorName: string; authorEmail: string }) => { + const escaped = resolvePath(args.projectFilePath, workspaceDir); + // Escape message for triple-quoted Python string injection + const safeMessage = args.message.replace(/\\/g, '\\\\').replace(/"""/g, '\\"\\"\\"'); + const script = scriptManager.prepareScriptWithHelpers( + 'git_commit', + { + PROJECT_FILE_PATH: escaped, + COMMIT_MESSAGE: safeMessage, + AUTHOR_NAME: args.authorName, + AUTHOR_EMAIL: args.authorEmail, + }, + ['ensure_project_open'] + ); + const result = await executor.executeScript(script); + return formatToolResponse(result, `git_commit complete for ${args.projectFilePath}.`); + } + ); + // ─── Library Management Tools ───────────────────────────────────────── s.tool(