0
0
Fork 0

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) <noreply@anthropic.com>
This commit is contained in:
Karstein Phobic Nyvold Kvistad 2026-04-25 19:01:09 +02:00
parent 7f114a4454
commit e236a0cfad
4 changed files with 248 additions and 0 deletions

58
src/scripts/git_commit.py Normal file
View file

@ -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)

49
src/scripts/git_init.py Normal file
View file

@ -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)

70
src/scripts/git_status.py Normal file
View file

@ -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)

View file

@ -1044,6 +1044,77 @@ export async function startMcpServer(config: ServerConfig): Promise<void> {
}
);
// ─── 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(