@ezmodo/mcp-server 0.13.5 → 0.14.1
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/README.md +112 -42
- package/handlers/auth.js +160 -0
- package/handlers/index.js +2 -0
- package/http.js +6 -1
- package/index.js +21 -52
- package/lib/auth-guidance.js +175 -0
- package/lib/cli-credential.js +3 -12
- package/lib/create-server.js +97 -4
- package/lib/credentials.js +106 -0
- package/lib/git-helpers.js +115 -52
- package/lib/http-client.js +25 -6
- package/lib/instructions.generated.js +14 -0
- package/lib/instructions.js +37 -0
- package/lib/oauth-config.js +98 -0
- package/lib/oauth.js +353 -0
- package/lib/remote-tools.js +6 -0
- package/lib/token-store.js +136 -0
- package/lib/user-paths.js +41 -0
- package/lib/version.js +1 -1
- package/package.json +9 -6
- package/prompts/commands.generated.js +52 -0
- package/prompts/index.js +62 -29
- package/scripts/build-instructions.mjs +76 -0
- package/scripts/build-prompts.mjs +144 -0
- package/tools/auth.js +34 -0
- package/tools/index.js +4 -0
- package/prompts/ai-workflow-automation.js +0 -96
- package/prompts/zephly-usage-guide.js +0 -119
package/lib/cli-credential.js
CHANGED
|
@@ -23,19 +23,10 @@
|
|
|
23
23
|
|
|
24
24
|
import { execFileSync } from 'child_process';
|
|
25
25
|
import { existsSync, readFileSync } from 'fs';
|
|
26
|
-
import { homedir } from 'os';
|
|
27
26
|
import { join } from 'path';
|
|
28
|
-
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
function configDirs() {
|
|
32
|
-
const home = homedir();
|
|
33
|
-
if (process.platform === 'win32') {
|
|
34
|
-
const base = process.env.APPDATA || home;
|
|
35
|
-
return [join(base, 'ezmodo'), join(base, 'zephly')];
|
|
36
|
-
}
|
|
37
|
-
return [join(home, '.config', 'ezmodo'), join(home, '.config', 'zephly')];
|
|
38
|
-
}
|
|
27
|
+
// Shared with lib/token-store.js so the two cannot disagree about where the
|
|
28
|
+
// ezmodo config directory is — they write files side by side there.
|
|
29
|
+
import { configDirs } from './user-paths.js';
|
|
39
30
|
|
|
40
31
|
/** The Linux/Windows path: a 0600 JSON file written by `ezmodo auth login`. */
|
|
41
32
|
function fromCredentialsFile() {
|
package/lib/create-server.js
CHANGED
|
@@ -22,10 +22,19 @@ import {
|
|
|
22
22
|
|
|
23
23
|
import { TOOLS } from '../tools/index.js';
|
|
24
24
|
import { HANDLERS } from '../handlers/index.js';
|
|
25
|
-
import {
|
|
25
|
+
import { listPrompts, getPromptContent } from '../prompts/index.js';
|
|
26
26
|
import { MCP_VERSION } from './version.js';
|
|
27
27
|
import { getLogger } from './logger.js';
|
|
28
28
|
import { isRemoteSafe } from './remote-tools.js';
|
|
29
|
+
import {
|
|
30
|
+
EMAIL_ALREADY_REGISTERED,
|
|
31
|
+
NOT_AUTHENTICATED,
|
|
32
|
+
NO_ORGANIZATION,
|
|
33
|
+
emailAlreadyRegistered,
|
|
34
|
+
organizationRequired,
|
|
35
|
+
signInRequired,
|
|
36
|
+
} from './auth-guidance.js';
|
|
37
|
+
import { getInstructions } from './instructions.js';
|
|
29
38
|
|
|
30
39
|
/**
|
|
31
40
|
* @param {object} [options]
|
|
@@ -34,6 +43,44 @@ import { isRemoteSafe } from './remote-tools.js';
|
|
|
34
43
|
* 'remote' excludes tools that operate on the local filesystem or git — see
|
|
35
44
|
* lib/remote-tools.js for why that is an allowlist and not a denylist.
|
|
36
45
|
*/
|
|
46
|
+
/**
|
|
47
|
+
* Whether a failure means "nobody is signed in" rather than "the call went
|
|
48
|
+
* wrong".
|
|
49
|
+
*
|
|
50
|
+
* 401 counts as well as the no-credential case: a credential that WAS valid can
|
|
51
|
+
* stop being so — a revoked API key, or a refresh token whose grant expired
|
|
52
|
+
* while the editor sat open overnight — and telling the user to sign in is the
|
|
53
|
+
* right answer to both.
|
|
54
|
+
*
|
|
55
|
+
* 403 deliberately does NOT count. That means signed in but not permitted,
|
|
56
|
+
* most often a new account in no organization yet, and sending someone back
|
|
57
|
+
* through a sign-in that cannot fix it is worse than saying nothing. That case
|
|
58
|
+
* gets its own answer below — saying nothing was the placeholder, not the plan.
|
|
59
|
+
*/
|
|
60
|
+
function isAuthFailure(error) {
|
|
61
|
+
return error?.code === NOT_AUTHENTICATED || error?.status === 401;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Whether a failure means "signed in, but in no organization" (#2639).
|
|
66
|
+
*
|
|
67
|
+
* Keyed on the API's code, never on the message: the API owns the wording and
|
|
68
|
+
* will improve it, and a prose match that silently stops matching degrades to
|
|
69
|
+
* the bare 403 this exists to replace — the failure would be invisible, since
|
|
70
|
+
* the call still fails either way, just uselessly.
|
|
71
|
+
*/
|
|
72
|
+
function isNoOrganization(error) {
|
|
73
|
+
return error?.code === NO_ORGANIZATION;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Whether a failure means "signed in, but this email is already spoken for"
|
|
78
|
+
* (#2652). Same keying, and the same reason for it.
|
|
79
|
+
*/
|
|
80
|
+
function isEmailAlreadyRegistered(error) {
|
|
81
|
+
return error?.code === EMAIL_ALREADY_REGISTERED;
|
|
82
|
+
}
|
|
83
|
+
|
|
37
84
|
export function createServer({ surface = 'local' } = {}) {
|
|
38
85
|
const log = getLogger();
|
|
39
86
|
|
|
@@ -46,7 +93,14 @@ export function createServer({ surface = 'local' } = {}) {
|
|
|
46
93
|
|
|
47
94
|
const server = new Server(
|
|
48
95
|
{ name: 'ezmodo-mcp-server', version: MCP_VERSION },
|
|
49
|
-
{
|
|
96
|
+
{
|
|
97
|
+
capabilities: { tools: {}, prompts: {} },
|
|
98
|
+
// Reaches every client on every session, which is what makes it the one
|
|
99
|
+
// place the work-tracking discipline can live without a per-editor
|
|
100
|
+
// plugin (#2633). Varies by surface for the same reason the tool list
|
|
101
|
+
// does.
|
|
102
|
+
instructions: getInstructions(surface),
|
|
103
|
+
}
|
|
50
104
|
);
|
|
51
105
|
|
|
52
106
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
|
|
@@ -83,6 +137,41 @@ export function createServer({ surface = 'local' } = {}) {
|
|
|
83
137
|
} catch (error) {
|
|
84
138
|
const errMsg = error.message || String(error);
|
|
85
139
|
log.error('Tool call failed', { tool: name, error: errMsg, durationMs: Date.now() - start });
|
|
140
|
+
|
|
141
|
+
// An authentication failure is not an error the model should relay as
|
|
142
|
+
// one — it is a request for the user to do something. Funnelled HERE, at
|
|
143
|
+
// the single dispatch point, for the same reason the surface filter is:
|
|
144
|
+
// every tool goes through it, so none of them can be missed or drift
|
|
145
|
+
// (#2632). Only on the local surface; over the connector Claude owns the
|
|
146
|
+
// OAuth and this advice would be wrong.
|
|
147
|
+
if (surface !== 'remote' && isAuthFailure(error)) {
|
|
148
|
+
return {
|
|
149
|
+
content: [{ type: 'text', text: JSON.stringify(signInRequired({ reason: errMsg }), null, 2) }],
|
|
150
|
+
isError: true,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
// Answered on EVERY surface, unlike the sign-in prompt above. Sign-in
|
|
154
|
+
// advice is surface-specific because over the connector Claude owns the
|
|
155
|
+
// OAuth; a missing workspace is ours either way, and a claude.ai user
|
|
156
|
+
// hits it exactly as a local one does.
|
|
157
|
+
if (isNoOrganization(error)) {
|
|
158
|
+
return {
|
|
159
|
+
content: [
|
|
160
|
+
{ type: 'text', text: JSON.stringify(organizationRequired({ reason: errMsg }), null, 2) },
|
|
161
|
+
],
|
|
162
|
+
isError: true,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
// Checked separately from the branch above, never merged into it: these
|
|
166
|
+
// two look alike from outside and their answers point opposite ways.
|
|
167
|
+
if (isEmailAlreadyRegistered(error)) {
|
|
168
|
+
return {
|
|
169
|
+
content: [
|
|
170
|
+
{ type: 'text', text: JSON.stringify(emailAlreadyRegistered({ reason: errMsg }), null, 2) },
|
|
171
|
+
],
|
|
172
|
+
isError: true,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
86
175
|
// Returned as content rather than thrown: a tool that fails is a result
|
|
87
176
|
// the model can read and act on, not a transport error.
|
|
88
177
|
return {
|
|
@@ -92,10 +181,14 @@ export function createServer({ surface = 'local' } = {}) {
|
|
|
92
181
|
}
|
|
93
182
|
});
|
|
94
183
|
|
|
95
|
-
|
|
184
|
+
// Filtered by surface exactly as tools are, and for the same reason: `submit`
|
|
185
|
+
// reads git SHAs and links commits, which a hosted server cannot do.
|
|
186
|
+
server.setRequestHandler(ListPromptsRequestSchema, async () => ({
|
|
187
|
+
prompts: listPrompts(surface),
|
|
188
|
+
}));
|
|
96
189
|
|
|
97
190
|
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
|
|
98
|
-
const content = getPromptContent(request.params.name);
|
|
191
|
+
const content = getPromptContent(request.params.name, request.params.arguments, surface);
|
|
99
192
|
if (!content) {
|
|
100
193
|
throw new Error(`Unknown prompt: ${request.params.name}`);
|
|
101
194
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place that decides which credential a request is made with.
|
|
3
|
+
*
|
|
4
|
+
* Four sources, and the ORDER is the contract (#2631):
|
|
5
|
+
*
|
|
6
|
+
* 1. The request context. Only ever set by the HTTP transport, where one
|
|
7
|
+
* process serves many callers and the credential belongs to the request
|
|
8
|
+
* rather than the process (#2599). Over stdio this is always empty.
|
|
9
|
+
* 2. EZMODO_API_KEY (or legacy ZEPHLY_API_KEY). An EXPLICIT credential must
|
|
10
|
+
* beat an implicit one, or overriding the key for a single project becomes
|
|
11
|
+
* impossible to reason about — and this is what keeps CI, containers and
|
|
12
|
+
* anything headless working exactly as before OAuth existed.
|
|
13
|
+
* 3. The OAuth token this server obtained for itself, refreshed if stale.
|
|
14
|
+
* The zero-configuration path: nothing to paste, nothing in a profile.
|
|
15
|
+
* 4. The credential `ezmodo auth login` stored. Last because it belongs to
|
|
16
|
+
* another program: a server that silently authenticates as whoever the
|
|
17
|
+
* CLI happens to be logged in as should do so only when nothing else
|
|
18
|
+
* said otherwise.
|
|
19
|
+
*
|
|
20
|
+
* 3 above 4 is the deliberate part. Both are implicit, so neither "wins" on
|
|
21
|
+
* explicitness; what separates them is that the OAuth token is THIS server's
|
|
22
|
+
* own, obtained by a person who was shown a consent screen naming this
|
|
23
|
+
* connector, while the CLI's key is a credential borrowed from a different
|
|
24
|
+
* tool. Prefer the one whose grant the user actually saw.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { getApiKey } from './env.js';
|
|
28
|
+
import { getRequestContext, resolveApiKey } from './request-context.js';
|
|
29
|
+
import { getAccessToken, getSignedInIdentity } from './oauth.js';
|
|
30
|
+
import { readCliCredential } from './cli-credential.js';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The CLI credential, looked up at most once.
|
|
34
|
+
*
|
|
35
|
+
* Memoized because reading it is not free: on macOS it shells out to
|
|
36
|
+
* `security` to read the login Keychain, and doing that on every API call
|
|
37
|
+
* would put a subprocess spawn — and a possible ACL prompt — on the hot path.
|
|
38
|
+
* `undefined` means "not looked up yet"; `null` means "looked up, nothing
|
|
39
|
+
* there".
|
|
40
|
+
*/
|
|
41
|
+
let cachedCliCredential;
|
|
42
|
+
|
|
43
|
+
function cliCredential() {
|
|
44
|
+
if (cachedCliCredential === undefined) {
|
|
45
|
+
cachedCliCredential = readCliCredential() ?? null;
|
|
46
|
+
}
|
|
47
|
+
return cachedCliCredential;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the credential for the call in flight.
|
|
52
|
+
*
|
|
53
|
+
* Returns null rather than throwing when there is nothing to use. Every caller
|
|
54
|
+
* is on the path of a tool call, and "sign in first" is something an agent can
|
|
55
|
+
* act on; an exception is not.
|
|
56
|
+
*
|
|
57
|
+
* @returns {Promise<{ token: string, source: string }|null>}
|
|
58
|
+
*/
|
|
59
|
+
export async function resolveCredential() {
|
|
60
|
+
// Steps 1 and 2 together. Deliberately NOT re-implemented here:
|
|
61
|
+
// resolveApiKey() already means "request context, else environment", and a
|
|
62
|
+
// second copy of that precedence is how the two drift apart.
|
|
63
|
+
const explicit = resolveApiKey();
|
|
64
|
+
if (explicit) {
|
|
65
|
+
const fromRequest = Boolean(getRequestContext()?.apiKey);
|
|
66
|
+
return { token: explicit, source: fromRequest ? 'request' : 'EZMODO_API_KEY' };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const fromOAuth = await getAccessToken();
|
|
70
|
+
if (fromOAuth) {
|
|
71
|
+
const identity = getSignedInIdentity();
|
|
72
|
+
return { token: fromOAuth, source: identity?.email ? `OAuth (${identity.email})` : 'OAuth' };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const fromCli = cliCredential();
|
|
76
|
+
if (fromCli) return { token: fromCli.key, source: fromCli.source };
|
|
77
|
+
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* What the startup banner reports, without triggering a network refresh.
|
|
83
|
+
*
|
|
84
|
+
* Startup must not block on Keycloak: an expired token at boot would make the
|
|
85
|
+
* server hang before it ever spoke MCP, and the refresh happens on the first
|
|
86
|
+
* call anyway.
|
|
87
|
+
*
|
|
88
|
+
* @returns {{ source: string, detail?: string }|null}
|
|
89
|
+
*/
|
|
90
|
+
export function describeCredentialSync() {
|
|
91
|
+
const fromEnv = getApiKey();
|
|
92
|
+
if (fromEnv) return { source: 'EZMODO_API_KEY', detail: `${fromEnv.substring(0, 12)}...` };
|
|
93
|
+
|
|
94
|
+
const identity = getSignedInIdentity();
|
|
95
|
+
if (identity) return { source: 'OAuth', detail: identity.email };
|
|
96
|
+
|
|
97
|
+
const fromCli = cliCredential();
|
|
98
|
+
if (fromCli) return { source: fromCli.source, detail: `${fromCli.key.substring(0, 12)}...` };
|
|
99
|
+
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Test seam: forget the memoized CLI lookup. */
|
|
104
|
+
export function resetCredentialCache() {
|
|
105
|
+
cachedCliCredential = undefined;
|
|
106
|
+
}
|
package/lib/git-helpers.js
CHANGED
|
@@ -3,26 +3,60 @@
|
|
|
3
3
|
* Utility functions for git operations used by worktree tools
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { execFileSync } from 'child_process';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* Run git with an ARGUMENT ARRAY — never a command string, and never a shell.
|
|
10
|
+
*
|
|
11
|
+
* This used to be `execSync(command)`, with callers building the command by
|
|
12
|
+
* interpolating branch names and paths into a template. #2614 stopped the
|
|
13
|
+
* remote transport from reaching any of it, which removed the internet-facing
|
|
14
|
+
* blast radius; this closes the hole itself, so the same mistake cannot be made
|
|
15
|
+
* again by a future caller who is not thinking about shells.
|
|
16
|
+
*
|
|
17
|
+
* execFileSync spawns git directly, so the arguments are passed as-is: a value
|
|
18
|
+
* containing `;`, a backtick or a quote is a literal branch name that git will
|
|
19
|
+
* simply not find, rather than a second command. Nothing in this file quotes
|
|
20
|
+
* anything any more, because there is no shell left to quote for.
|
|
21
|
+
*
|
|
22
|
+
* @param {string[]} args - Arguments to git, one element per argument
|
|
11
23
|
* @param {string} cwd - Working directory
|
|
12
|
-
* @returns {string} Command output
|
|
24
|
+
* @returns {string} Command output, trimmed
|
|
13
25
|
*/
|
|
14
|
-
export function execGit(
|
|
26
|
+
export function execGit(args, cwd) {
|
|
27
|
+
if (!Array.isArray(args)) {
|
|
28
|
+
throw new TypeError('execGit takes an argument array, not a command string');
|
|
29
|
+
}
|
|
15
30
|
try {
|
|
16
|
-
return
|
|
31
|
+
return execFileSync('git', args, {
|
|
17
32
|
cwd,
|
|
18
33
|
encoding: 'utf-8',
|
|
19
34
|
stdio: 'pipe',
|
|
20
35
|
}).trim();
|
|
21
36
|
} catch (error) {
|
|
22
|
-
throw new Error(`Git command failed: ${
|
|
37
|
+
throw new Error(`Git command failed: git ${args.join(' ')}\n${error.message}`);
|
|
23
38
|
}
|
|
24
39
|
}
|
|
25
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Reject a value that git would read as an OPTION rather than as a name.
|
|
43
|
+
*
|
|
44
|
+
* The residual risk after dropping the shell is not injection, it is option
|
|
45
|
+
* injection: `--exec=...` in a branch-name position is still one argument, and
|
|
46
|
+
* git still honours it. Callers pass user-supplied refs and paths, so the
|
|
47
|
+
* values that land in those positions are checked here.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} value - The candidate branch name, ref or path
|
|
50
|
+
* @param {string} label - What it is, for the error message
|
|
51
|
+
* @returns {string} The value, unchanged
|
|
52
|
+
*/
|
|
53
|
+
function assertNotOption(value, label) {
|
|
54
|
+
if (typeof value !== 'string' || value.startsWith('-')) {
|
|
55
|
+
throw new Error(`Invalid ${label}: ${value}`);
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
|
|
26
60
|
/**
|
|
27
61
|
* Check if directory is a git repository
|
|
28
62
|
* @param {string} dir - Directory path
|
|
@@ -30,11 +64,7 @@ export function execGit(command, cwd) {
|
|
|
30
64
|
*/
|
|
31
65
|
export function isGitRepository(dir) {
|
|
32
66
|
try {
|
|
33
|
-
|
|
34
|
-
cwd: dir,
|
|
35
|
-
encoding: 'utf-8',
|
|
36
|
-
stdio: 'pipe',
|
|
37
|
-
});
|
|
67
|
+
execGit(['rev-parse', '--git-dir'], dir);
|
|
38
68
|
return true;
|
|
39
69
|
} catch {
|
|
40
70
|
return false;
|
|
@@ -48,11 +78,7 @@ export function isGitRepository(dir) {
|
|
|
48
78
|
*/
|
|
49
79
|
export function getRepositoryRoot(dir) {
|
|
50
80
|
try {
|
|
51
|
-
return
|
|
52
|
-
cwd: dir,
|
|
53
|
-
encoding: 'utf-8',
|
|
54
|
-
stdio: 'pipe',
|
|
55
|
-
}).trim();
|
|
81
|
+
return execGit(['rev-parse', '--show-toplevel'], dir);
|
|
56
82
|
} catch {
|
|
57
83
|
return null;
|
|
58
84
|
}
|
|
@@ -65,7 +91,7 @@ export function getRepositoryRoot(dir) {
|
|
|
65
91
|
*/
|
|
66
92
|
export function getRemoteUrl(repoPath) {
|
|
67
93
|
try {
|
|
68
|
-
return execGit('
|
|
94
|
+
return execGit(['config', '--get', 'remote.origin.url'], repoPath);
|
|
69
95
|
} catch {
|
|
70
96
|
return null;
|
|
71
97
|
}
|
|
@@ -78,7 +104,7 @@ export function getRemoteUrl(repoPath) {
|
|
|
78
104
|
*/
|
|
79
105
|
export function getCurrentBranch(repoPath) {
|
|
80
106
|
try {
|
|
81
|
-
return execGit('
|
|
107
|
+
return execGit(['branch', '--show-current'], repoPath);
|
|
82
108
|
} catch {
|
|
83
109
|
return null;
|
|
84
110
|
}
|
|
@@ -93,15 +119,17 @@ export function getCurrentBranch(repoPath) {
|
|
|
93
119
|
export function branchExists(repoPath, branchName) {
|
|
94
120
|
const result = { local: false, remote: false };
|
|
95
121
|
|
|
122
|
+
assertNotOption(branchName, 'branch name');
|
|
123
|
+
|
|
96
124
|
try {
|
|
97
|
-
execGit(
|
|
125
|
+
execGit(['show-ref', '--verify', `refs/heads/${branchName}`], repoPath);
|
|
98
126
|
result.local = true;
|
|
99
127
|
} catch {
|
|
100
128
|
// Branch doesn't exist locally
|
|
101
129
|
}
|
|
102
130
|
|
|
103
131
|
try {
|
|
104
|
-
execGit(
|
|
132
|
+
execGit(['show-ref', '--verify', `refs/remotes/origin/${branchName}`], repoPath);
|
|
105
133
|
result.remote = true;
|
|
106
134
|
} catch {
|
|
107
135
|
// Branch doesn't exist remotely
|
|
@@ -118,18 +146,21 @@ export function branchExists(repoPath, branchName) {
|
|
|
118
146
|
* @returns {string} Commit hash
|
|
119
147
|
*/
|
|
120
148
|
export function createBranch(repoPath, branchName, baseBranch) {
|
|
149
|
+
assertNotOption(branchName, 'branch name');
|
|
150
|
+
assertNotOption(baseBranch, 'base branch');
|
|
151
|
+
|
|
121
152
|
// Ensure base branch is up to date
|
|
122
153
|
try {
|
|
123
|
-
execGit(
|
|
154
|
+
execGit(['fetch', 'origin', baseBranch], repoPath);
|
|
124
155
|
} catch {
|
|
125
156
|
// Fetch might fail if no remote, that's ok
|
|
126
157
|
}
|
|
127
158
|
|
|
128
159
|
// Create branch
|
|
129
|
-
execGit(
|
|
160
|
+
execGit(['branch', branchName, baseBranch], repoPath);
|
|
130
161
|
|
|
131
162
|
// Get commit hash
|
|
132
|
-
return execGit(
|
|
163
|
+
return execGit(['rev-parse', branchName], repoPath);
|
|
133
164
|
}
|
|
134
165
|
|
|
135
166
|
/**
|
|
@@ -141,8 +172,23 @@ export function createBranch(repoPath, branchName, baseBranch) {
|
|
|
141
172
|
* @returns {void}
|
|
142
173
|
*/
|
|
143
174
|
export function createWorktree(repoPath, worktreePath, branchName, createBranch = false) {
|
|
144
|
-
|
|
145
|
-
|
|
175
|
+
assertNotOption(worktreePath, 'worktree path');
|
|
176
|
+
assertNotOption(branchName, 'branch name');
|
|
177
|
+
|
|
178
|
+
// `git worktree add -b <new-branch> <path>` — the branch name comes FIRST
|
|
179
|
+
// after -b. The string-building version had it the other way round
|
|
180
|
+
// (`add -b "<path>" <branch>`), so git read the path as the branch name and
|
|
181
|
+
// refused it. Nothing caught that because every caller in worktree-tools.js
|
|
182
|
+
// passes createBranch = false, having created the branch already; this path
|
|
183
|
+
// has never worked. Corrected here rather than left as a trap, since the
|
|
184
|
+
// whole point of moving to an argument array is that the arguments are now
|
|
185
|
+
// legible.
|
|
186
|
+
execGit(
|
|
187
|
+
createBranch
|
|
188
|
+
? ['worktree', 'add', '-b', branchName, worktreePath]
|
|
189
|
+
: ['worktree', 'add', worktreePath, branchName],
|
|
190
|
+
repoPath
|
|
191
|
+
);
|
|
146
192
|
}
|
|
147
193
|
|
|
148
194
|
/**
|
|
@@ -152,7 +198,7 @@ export function createWorktree(repoPath, worktreePath, branchName, createBranch
|
|
|
152
198
|
*/
|
|
153
199
|
export function listWorktrees(repoPath) {
|
|
154
200
|
try {
|
|
155
|
-
const output = execGit('
|
|
201
|
+
const output = execGit(['worktree', 'list', '--porcelain'], repoPath);
|
|
156
202
|
const worktrees = [];
|
|
157
203
|
const lines = output.split('\n');
|
|
158
204
|
|
|
@@ -190,8 +236,9 @@ export function listWorktrees(repoPath) {
|
|
|
190
236
|
* @returns {void}
|
|
191
237
|
*/
|
|
192
238
|
export function removeWorktree(repoPath, worktreePath, force = false) {
|
|
193
|
-
|
|
194
|
-
|
|
239
|
+
assertNotOption(worktreePath, 'worktree path');
|
|
240
|
+
|
|
241
|
+
execGit(['worktree', 'remove', ...(force ? ['--force'] : []), worktreePath], repoPath);
|
|
195
242
|
}
|
|
196
243
|
|
|
197
244
|
/**
|
|
@@ -209,7 +256,7 @@ export function getWorktreeStatus(worktreePath) {
|
|
|
209
256
|
};
|
|
210
257
|
|
|
211
258
|
try {
|
|
212
|
-
const output = execGit('
|
|
259
|
+
const output = execGit(['status', '--porcelain'], worktreePath);
|
|
213
260
|
|
|
214
261
|
if (output) {
|
|
215
262
|
status.isDirty = true;
|
|
@@ -248,19 +295,22 @@ export function getAheadBehindCounts(worktreePath, localBranch, remoteBranch) {
|
|
|
248
295
|
const counts = { ahead: 0, behind: 0 };
|
|
249
296
|
|
|
250
297
|
try {
|
|
298
|
+
assertNotOption(localBranch, 'local branch');
|
|
299
|
+
assertNotOption(remoteBranch, 'remote branch');
|
|
300
|
+
|
|
251
301
|
// Fetch latest
|
|
252
|
-
execGit('
|
|
302
|
+
execGit(['fetch', 'origin'], worktreePath);
|
|
253
303
|
|
|
254
304
|
// Get ahead count
|
|
255
305
|
const aheadOutput = execGit(
|
|
256
|
-
|
|
306
|
+
['rev-list', '--count', `${remoteBranch}..${localBranch}`],
|
|
257
307
|
worktreePath
|
|
258
308
|
);
|
|
259
309
|
counts.ahead = parseInt(aheadOutput, 10) || 0;
|
|
260
310
|
|
|
261
311
|
// Get behind count
|
|
262
312
|
const behindOutput = execGit(
|
|
263
|
-
|
|
313
|
+
['rev-list', '--count', `${localBranch}..${remoteBranch}`],
|
|
264
314
|
worktreePath
|
|
265
315
|
);
|
|
266
316
|
counts.behind = parseInt(behindOutput, 10) || 0;
|
|
@@ -280,7 +330,7 @@ export function checkForConflicts(worktreePath) {
|
|
|
280
330
|
const result = { hasConflicts: false, files: [] };
|
|
281
331
|
|
|
282
332
|
try {
|
|
283
|
-
const output = execGit('
|
|
333
|
+
const output = execGit(['status', '--porcelain'], worktreePath);
|
|
284
334
|
const lines = output.split('\n').filter(l => l);
|
|
285
335
|
|
|
286
336
|
for (const line of lines) {
|
|
@@ -310,8 +360,9 @@ export function checkForConflicts(worktreePath) {
|
|
|
310
360
|
* @returns {void}
|
|
311
361
|
*/
|
|
312
362
|
export function deleteBranch(repoPath, branchName, force = false) {
|
|
313
|
-
|
|
314
|
-
|
|
363
|
+
assertNotOption(branchName, 'branch name');
|
|
364
|
+
|
|
365
|
+
execGit(['branch', force ? '-D' : '-d', branchName], repoPath);
|
|
315
366
|
}
|
|
316
367
|
|
|
317
368
|
/**
|
|
@@ -323,7 +374,9 @@ export function deleteBranch(repoPath, branchName, force = false) {
|
|
|
323
374
|
*/
|
|
324
375
|
export function isBranchMerged(repoPath, branchName, baseBranch) {
|
|
325
376
|
try {
|
|
326
|
-
|
|
377
|
+
assertNotOption(baseBranch, 'base branch');
|
|
378
|
+
|
|
379
|
+
const output = execGit(['branch', '--merged', baseBranch], repoPath);
|
|
327
380
|
return output.includes(branchName);
|
|
328
381
|
} catch {
|
|
329
382
|
return false;
|
|
@@ -338,10 +391,12 @@ export function isBranchMerged(repoPath, branchName, baseBranch) {
|
|
|
338
391
|
*/
|
|
339
392
|
export function getLastCommit(repoPath, branchName) {
|
|
340
393
|
try {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
const
|
|
344
|
-
const
|
|
394
|
+
assertNotOption(branchName, 'branch name');
|
|
395
|
+
|
|
396
|
+
const hash = execGit(['rev-parse', branchName], repoPath);
|
|
397
|
+
const author = execGit(['log', '-1', '--format=%an', branchName], repoPath);
|
|
398
|
+
const timestamp = execGit(['log', '-1', '--format=%ct', branchName], repoPath);
|
|
399
|
+
const message = execGit(['log', '-1', '--format=%s', branchName], repoPath);
|
|
345
400
|
|
|
346
401
|
return {
|
|
347
402
|
hash,
|
|
@@ -397,7 +452,10 @@ export function generateBranchName(task, defaultPrefix = 'feature') {
|
|
|
397
452
|
*/
|
|
398
453
|
export function getMergeBase(repoPath, branch1, branch2) {
|
|
399
454
|
try {
|
|
400
|
-
|
|
455
|
+
assertNotOption(branch1, 'branch');
|
|
456
|
+
assertNotOption(branch2, 'branch');
|
|
457
|
+
|
|
458
|
+
return execGit(['merge-base', branch1, branch2], repoPath);
|
|
401
459
|
} catch {
|
|
402
460
|
return null;
|
|
403
461
|
}
|
|
@@ -411,16 +469,16 @@ export function getMergeBase(repoPath, branch1, branch2) {
|
|
|
411
469
|
export function getDefaultBranch(repoPath) {
|
|
412
470
|
// Try to get from remote HEAD
|
|
413
471
|
try {
|
|
414
|
-
const ref = execGit('
|
|
472
|
+
const ref = execGit(['symbolic-ref', 'refs/remotes/origin/HEAD'], repoPath);
|
|
415
473
|
return ref.replace('refs/remotes/origin/', '');
|
|
416
474
|
} catch {
|
|
417
475
|
// Fall back to checking if main or master exists
|
|
418
476
|
try {
|
|
419
|
-
execGit('
|
|
477
|
+
execGit(['show-ref', '--verify', 'refs/heads/main'], repoPath);
|
|
420
478
|
return 'main';
|
|
421
479
|
} catch {
|
|
422
480
|
try {
|
|
423
|
-
execGit('
|
|
481
|
+
execGit(['show-ref', '--verify', 'refs/heads/master'], repoPath);
|
|
424
482
|
return 'master';
|
|
425
483
|
} catch {
|
|
426
484
|
return 'main'; // Default fallback
|
|
@@ -439,13 +497,17 @@ export function getDefaultBranch(repoPath) {
|
|
|
439
497
|
export function getCommitsSince(repoPath, sinceCommit, branchName) {
|
|
440
498
|
try {
|
|
441
499
|
// Format: hash|short|author|email|timestamp|subject
|
|
500
|
+
assertNotOption(branchName, 'branch name');
|
|
501
|
+
if (sinceCommit) {
|
|
502
|
+
assertNotOption(sinceCommit, 'commit');
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// No quotes around the format: there is no shell to strip them, so a
|
|
506
|
+
// quoted format string would reach git verbatim and appear in the output.
|
|
442
507
|
const format = '%H|%h|%an|%ae|%ct|%s';
|
|
443
508
|
const range = sinceCommit ? `${sinceCommit}..${branchName}` : branchName;
|
|
444
509
|
|
|
445
|
-
const output = execGit(
|
|
446
|
-
`git log --format="${format}" ${range}`,
|
|
447
|
-
repoPath
|
|
448
|
-
);
|
|
510
|
+
const output = execGit(['log', `--format=${format}`, range], repoPath);
|
|
449
511
|
|
|
450
512
|
if (!output) {
|
|
451
513
|
return [];
|
|
@@ -469,8 +531,9 @@ export function getCommitsSince(repoPath, sinceCommit, branchName) {
|
|
|
469
531
|
}
|
|
470
532
|
|
|
471
533
|
/**
|
|
472
|
-
* A commit SHA, and nothing else.
|
|
473
|
-
*
|
|
534
|
+
* A commit SHA, and nothing else. Kept after execGit stopped using a shell
|
|
535
|
+
* (#2614 step 5): it is still the cheapest way to tell a real hash from a ref
|
|
536
|
+
* that would make diff-tree mean something other than "this commit".
|
|
474
537
|
*/
|
|
475
538
|
const SHA_PATTERN = /^[0-9a-f]{7,40}$/i;
|
|
476
539
|
|
|
@@ -497,7 +560,7 @@ export function getCommitFiles(repoPath, sha) {
|
|
|
497
560
|
}
|
|
498
561
|
try {
|
|
499
562
|
const output = execGit(
|
|
500
|
-
|
|
563
|
+
['diff-tree', '--no-commit-id', '--name-only', '-r', sha],
|
|
501
564
|
repoPath
|
|
502
565
|
);
|
|
503
566
|
return output ? output.split('\n').map(line => line.trim()).filter(Boolean) : [];
|