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:
parent
7f114a4454
commit
e236a0cfad
4 changed files with 248 additions and 0 deletions
58
src/scripts/git_commit.py
Normal file
58
src/scripts/git_commit.py
Normal 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
49
src/scripts/git_init.py
Normal 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
70
src/scripts/git_status.py
Normal 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)
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue