@thxgg/steward 0.1.12 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.output/nitro.json +1 -1
- package/.output/public/_nuxt/builds/latest.json +1 -1
- package/.output/public/_nuxt/builds/meta/2ad99048-24f9-4cf6-8622-6c088fe0a244.json +1 -0
- package/.output/server/chunks/_/git.mjs.map +1 -1
- package/.output/server/chunks/build/styles.mjs +2 -2
- package/.output/server/chunks/nitro/nitro.mjs +551 -551
- package/.output/server/package.json +1 -1
- package/dist/host/src/api/git.js +71 -1
- package/dist/host/src/help.js +12 -0
- package/dist/server/utils/git.js +104 -1
- package/docs/MCP.md +21 -0
- package/package.json +1 -1
- package/.output/public/_nuxt/builds/meta/6f66fabf-cc26-482b-8adf-f8731dd68f83.json +0 -1
package/dist/host/src/api/git.js
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
|
-
import { getCommitDiff, getCommitInfo, getFileContent, getFileDiff, isGitRepo, validatePathInRepo } from '../../../server/utils/git.js';
|
|
1
|
+
import { commitStagedChanges, getCommitDiff, getCommitInfo, getFileContent, getFileDiff, getWorkingTreeStatus, isGitRepo, stagePaths, validatePathInRepo } from '../../../server/utils/git.js';
|
|
2
2
|
import { requireRepo } from './repo-context.js';
|
|
3
|
+
function toGitStatus(status) {
|
|
4
|
+
const hasStagedChanges = status.staged.length > 0;
|
|
5
|
+
const hasChanges = hasStagedChanges || status.unstaged.length > 0 || status.untracked.length > 0;
|
|
6
|
+
return {
|
|
7
|
+
...status,
|
|
8
|
+
hasChanges,
|
|
9
|
+
hasStagedChanges
|
|
10
|
+
};
|
|
11
|
+
}
|
|
3
12
|
function resolveGitRepoPath(repo, repoPath) {
|
|
4
13
|
if (!repoPath) {
|
|
5
14
|
return repo.path;
|
|
@@ -15,6 +24,15 @@ function resolveGitRepoPath(repo, repoPath) {
|
|
|
15
24
|
return matchedRepo.absolutePath;
|
|
16
25
|
}
|
|
17
26
|
export const git = {
|
|
27
|
+
async getStatus(repoId, repoPath) {
|
|
28
|
+
const repo = await requireRepo(repoId);
|
|
29
|
+
const gitRepoPath = resolveGitRepoPath(repo, repoPath);
|
|
30
|
+
if (!await isGitRepo(gitRepoPath)) {
|
|
31
|
+
throw new Error('Resolved path is not a git repository');
|
|
32
|
+
}
|
|
33
|
+
const status = await getWorkingTreeStatus(gitRepoPath);
|
|
34
|
+
return toGitStatus(status);
|
|
35
|
+
},
|
|
18
36
|
async getCommits(repoId, shas, repoPath) {
|
|
19
37
|
if (!Array.isArray(shas) || shas.length === 0) {
|
|
20
38
|
throw new Error('At least one SHA is required');
|
|
@@ -85,5 +103,57 @@ export const git = {
|
|
|
85
103
|
throw new Error('Resolved path is not a git repository');
|
|
86
104
|
}
|
|
87
105
|
return await getFileContent(gitRepoPath, commit, file);
|
|
106
|
+
},
|
|
107
|
+
async commitIfChanged(repoId, message, options) {
|
|
108
|
+
if (!message || !message.trim()) {
|
|
109
|
+
throw new Error('message is required');
|
|
110
|
+
}
|
|
111
|
+
const repo = await requireRepo(repoId);
|
|
112
|
+
const relativeRepoPath = options?.repoPath || '';
|
|
113
|
+
const gitRepoPath = resolveGitRepoPath(repo, relativeRepoPath);
|
|
114
|
+
if (!await isGitRepo(gitRepoPath)) {
|
|
115
|
+
throw new Error('Resolved path is not a git repository');
|
|
116
|
+
}
|
|
117
|
+
const paths = Array.isArray(options?.paths)
|
|
118
|
+
? options.paths.filter((path) => typeof path === 'string' && path.trim().length > 0)
|
|
119
|
+
: [];
|
|
120
|
+
if (paths.length > 0) {
|
|
121
|
+
await stagePaths(gitRepoPath, paths);
|
|
122
|
+
}
|
|
123
|
+
const statusBefore = await getWorkingTreeStatus(gitRepoPath);
|
|
124
|
+
if (statusBefore.staged.length === 0) {
|
|
125
|
+
const noChanges = statusBefore.unstaged.length === 0 && statusBefore.untracked.length === 0;
|
|
126
|
+
return {
|
|
127
|
+
committed: false,
|
|
128
|
+
repoPath: relativeRepoPath,
|
|
129
|
+
staged: statusBefore.staged,
|
|
130
|
+
unstaged: statusBefore.unstaged,
|
|
131
|
+
untracked: statusBefore.untracked,
|
|
132
|
+
reason: noChanges ? 'no_changes' : 'no_staged_changes'
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
const commit = await commitStagedChanges(gitRepoPath, message);
|
|
136
|
+
if (!commit) {
|
|
137
|
+
return {
|
|
138
|
+
committed: false,
|
|
139
|
+
repoPath: relativeRepoPath,
|
|
140
|
+
staged: statusBefore.staged,
|
|
141
|
+
unstaged: statusBefore.unstaged,
|
|
142
|
+
untracked: statusBefore.untracked,
|
|
143
|
+
reason: 'no_staged_changes'
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
const statusAfter = await getWorkingTreeStatus(gitRepoPath);
|
|
147
|
+
return {
|
|
148
|
+
committed: true,
|
|
149
|
+
repoPath: relativeRepoPath,
|
|
150
|
+
staged: statusAfter.staged,
|
|
151
|
+
unstaged: statusAfter.unstaged,
|
|
152
|
+
untracked: statusAfter.untracked,
|
|
153
|
+
sha: commit.sha,
|
|
154
|
+
shortSha: commit.shortSha,
|
|
155
|
+
message: commit.message,
|
|
156
|
+
committedFiles: commit.files
|
|
157
|
+
};
|
|
88
158
|
}
|
|
89
159
|
};
|
package/dist/host/src/help.js
CHANGED
|
@@ -39,6 +39,10 @@ const HELP = {
|
|
|
39
39
|
}
|
|
40
40
|
],
|
|
41
41
|
git: [
|
|
42
|
+
{
|
|
43
|
+
signature: 'git.getStatus(repoId, repoPath?)',
|
|
44
|
+
description: 'Load working tree status (staged/unstaged/untracked)'
|
|
45
|
+
},
|
|
42
46
|
{ signature: 'git.getCommits(repoId, shas, repoPath?)', description: 'Load commit metadata' },
|
|
43
47
|
{ signature: 'git.getDiff(repoId, commit, repoPath?)', description: 'Load full commit diff' },
|
|
44
48
|
{
|
|
@@ -48,6 +52,10 @@ const HELP = {
|
|
|
48
52
|
{
|
|
49
53
|
signature: 'git.getFileContent(repoId, commit, file, repoPath?)',
|
|
50
54
|
description: 'Load file content at commit'
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
signature: 'git.commitIfChanged(repoId, message, options?)',
|
|
58
|
+
description: 'Stage optional paths and commit when staged changes exist'
|
|
51
59
|
}
|
|
52
60
|
],
|
|
53
61
|
state: [
|
|
@@ -86,6 +94,10 @@ const HELP = {
|
|
|
86
94
|
{
|
|
87
95
|
title: 'Upsert without repoId',
|
|
88
96
|
code: `await state.upsertCurrent('prd-viewer', {\n notes: '# Updated from MCP'\n})\n\nreturn { saved: true }`
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
title: 'Commit task-related changes when present',
|
|
100
|
+
code: `const repo = await repos.current()\n\nconst result = await git.commitIfChanged(repo.id, 'docs: update task notes', {\n paths: ['docs/prd/prd-viewer.md']\n})\n\nreturn result`
|
|
89
101
|
}
|
|
90
102
|
]
|
|
91
103
|
};
|
package/dist/server/utils/git.js
CHANGED
|
@@ -55,6 +55,109 @@ export function validatePathInRepo(repoPath, filePath) {
|
|
|
55
55
|
const relativePath = relative(resolvedRepo, resolvedFile);
|
|
56
56
|
return !relativePath.startsWith('..') && !isAbsolute(relativePath);
|
|
57
57
|
}
|
|
58
|
+
function dedupeAndSort(values) {
|
|
59
|
+
return Array.from(new Set(values)).sort((a, b) => a.localeCompare(b));
|
|
60
|
+
}
|
|
61
|
+
function normalizeStatusPath(rawPath) {
|
|
62
|
+
const trimmed = rawPath.trim();
|
|
63
|
+
if (!trimmed.includes(' -> ')) {
|
|
64
|
+
return trimmed;
|
|
65
|
+
}
|
|
66
|
+
const segments = trimmed.split(' -> ');
|
|
67
|
+
return segments[segments.length - 1]?.trim() || trimmed;
|
|
68
|
+
}
|
|
69
|
+
function normalizePathForGit(repoPath, path) {
|
|
70
|
+
if (!validatePathInRepo(repoPath, path)) {
|
|
71
|
+
throw new Error(`Invalid file path: ${path}`);
|
|
72
|
+
}
|
|
73
|
+
const absolutePath = isAbsolute(path)
|
|
74
|
+
? resolve(path)
|
|
75
|
+
: resolve(repoPath, path);
|
|
76
|
+
const relativePath = relative(resolve(repoPath), absolutePath);
|
|
77
|
+
if (!relativePath || relativePath === '.') {
|
|
78
|
+
throw new Error('Path must point to a file or subdirectory inside the repository');
|
|
79
|
+
}
|
|
80
|
+
return relativePath;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Get working tree changes split by staged/unstaged/untracked buckets.
|
|
84
|
+
*/
|
|
85
|
+
export async function getWorkingTreeStatus(repoPath) {
|
|
86
|
+
const output = await execGit(repoPath, ['status', '--porcelain']);
|
|
87
|
+
const staged = new Set();
|
|
88
|
+
const unstaged = new Set();
|
|
89
|
+
const untracked = new Set();
|
|
90
|
+
const lines = output
|
|
91
|
+
.split('\n')
|
|
92
|
+
.map(line => line.trimEnd())
|
|
93
|
+
.filter(line => line.length >= 3);
|
|
94
|
+
for (const line of lines) {
|
|
95
|
+
const indexStatus = line.charAt(0);
|
|
96
|
+
const worktreeStatus = line.charAt(1);
|
|
97
|
+
const path = normalizeStatusPath(line.slice(3));
|
|
98
|
+
if (!path) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (indexStatus === '?' && worktreeStatus === '?') {
|
|
102
|
+
untracked.add(path);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (indexStatus !== ' ' && indexStatus !== '?') {
|
|
106
|
+
staged.add(path);
|
|
107
|
+
}
|
|
108
|
+
if (worktreeStatus !== ' ') {
|
|
109
|
+
unstaged.add(path);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
staged: dedupeAndSort(staged),
|
|
114
|
+
unstaged: dedupeAndSort(unstaged),
|
|
115
|
+
untracked: dedupeAndSort(untracked)
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Stage explicit paths in a repository.
|
|
120
|
+
*/
|
|
121
|
+
export async function stagePaths(repoPath, paths) {
|
|
122
|
+
if (!Array.isArray(paths) || paths.length === 0) {
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
const normalizedPaths = dedupeAndSort(paths
|
|
126
|
+
.map(path => path.trim())
|
|
127
|
+
.filter(path => path.length > 0)
|
|
128
|
+
.map(path => normalizePathForGit(repoPath, path)));
|
|
129
|
+
if (normalizedPaths.length === 0) {
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
await execGit(repoPath, ['add', '--', ...normalizedPaths]);
|
|
133
|
+
return normalizedPaths;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Commit currently staged changes. Returns null when nothing is staged.
|
|
137
|
+
*/
|
|
138
|
+
export async function commitStagedChanges(repoPath, message) {
|
|
139
|
+
const trimmedMessage = message.trim();
|
|
140
|
+
if (!trimmedMessage) {
|
|
141
|
+
throw new Error('Commit message is required');
|
|
142
|
+
}
|
|
143
|
+
const stagedOutput = await execGit(repoPath, ['diff', '--cached', '--name-only']);
|
|
144
|
+
const stagedFiles = stagedOutput
|
|
145
|
+
.split('\n')
|
|
146
|
+
.map(line => line.trim())
|
|
147
|
+
.filter(line => line.length > 0);
|
|
148
|
+
if (stagedFiles.length === 0) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
await execGit(repoPath, ['commit', '-m', trimmedMessage]);
|
|
152
|
+
const sha = (await execGit(repoPath, ['rev-parse', 'HEAD'])).trim();
|
|
153
|
+
const shortSha = (await execGit(repoPath, ['rev-parse', '--short', 'HEAD'])).trim();
|
|
154
|
+
return {
|
|
155
|
+
sha,
|
|
156
|
+
shortSha,
|
|
157
|
+
message: trimmedMessage,
|
|
158
|
+
files: stagedFiles
|
|
159
|
+
};
|
|
160
|
+
}
|
|
58
161
|
/**
|
|
59
162
|
* Get commit information by SHA
|
|
60
163
|
*/
|
|
@@ -333,7 +436,7 @@ async function commitExistsInRepo(repoPath, sha) {
|
|
|
333
436
|
*
|
|
334
437
|
* @param repoConfig - The repository configuration with optional gitRepos
|
|
335
438
|
* @param sha - The commit SHA to find
|
|
336
|
-
* @returns
|
|
439
|
+
* @returns Resolved commit data for the matching repo, or throws if not found
|
|
337
440
|
*/
|
|
338
441
|
export async function findRepoForCommit(repoConfig, sha) {
|
|
339
442
|
// Validate SHA format
|
package/docs/MCP.md
CHANGED
|
@@ -119,10 +119,12 @@ In-sandbox discovery helper:
|
|
|
119
119
|
|
|
120
120
|
### `git`
|
|
121
121
|
|
|
122
|
+
- `git.getStatus(repoId, repoPath?)`
|
|
122
123
|
- `git.getCommits(repoId, shas, repoPath?)`
|
|
123
124
|
- `git.getDiff(repoId, commit, repoPath?)`
|
|
124
125
|
- `git.getFileDiff(repoId, commit, file, repoPath?)`
|
|
125
126
|
- `git.getFileContent(repoId, commit, file, repoPath?)`
|
|
127
|
+
- `git.commitIfChanged(repoId, message, options?)`
|
|
126
128
|
|
|
127
129
|
### `state`
|
|
128
130
|
|
|
@@ -181,6 +183,24 @@ return await Promise.all(commits.map(async (entry) => ({
|
|
|
181
183
|
})))
|
|
182
184
|
```
|
|
183
185
|
|
|
186
|
+
Commit task-related changes when present:
|
|
187
|
+
|
|
188
|
+
```js
|
|
189
|
+
const repo = await repos.current()
|
|
190
|
+
|
|
191
|
+
const commit = await git.commitIfChanged(repo.id, 'test: add task graph coverage', {
|
|
192
|
+
paths: ['app/components/graph/Explorer.spec.ts']
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
return commit
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
`git.commitIfChanged` behavior:
|
|
199
|
+
|
|
200
|
+
- stages only the explicit `options.paths` list when provided
|
|
201
|
+
- commits only when staged changes exist
|
|
202
|
+
- returns `committed: false` with `reason: "no_changes" | "no_staged_changes"` instead of creating empty commits
|
|
203
|
+
|
|
184
204
|
Inspect signatures at runtime:
|
|
185
205
|
|
|
186
206
|
```js
|
|
@@ -238,4 +258,5 @@ return { saved: true }
|
|
|
238
258
|
|
|
239
259
|
- This server is for trusted local development.
|
|
240
260
|
- APIs can read local filesystem and git history for registered repositories.
|
|
261
|
+
- `git.commitIfChanged` can create local commits when staged changes exist.
|
|
241
262
|
- Do not expose this server to untrusted environments.
|
package/package.json
CHANGED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"id":"6f66fabf-cc26-482b-8adf-f8731dd68f83","timestamp":1771871608410,"prerendered":[]}
|