Skip to content

Commit b4fd9fb

Browse files
committed
fix: resolve default branch explicitly when updating shallow-cloned custom modules
With shallow clones (--depth 1), `origin/HEAD` becomes stale after the initial clone. The update path used `git reset --hard origin/HEAD` which never picked up new commits pushed to the default branch. Resolve the default branch name via `git symbolic-ref refs/remotes/origin/HEAD`, then fetch and reset against `origin/<branch>` explicitly. Falls back to `main` if origin/HEAD is not set.
1 parent 2395b0e commit b4fd9fb

1 file changed

Lines changed: 177 additions & 19 deletions

File tree

tools/installer/modules/custom-module-manager.js

Lines changed: 177 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ const path = require('node:path');
44
const { execSync } = require('node:child_process');
55
const prompts = require('../prompts');
66

7+
function quoteCustomRef(ref) {
8+
if (typeof ref !== 'string' || !/^[\w.\-+/]+$/.test(ref)) {
9+
throw new Error(`Unsafe ref name: ${JSON.stringify(ref)}`);
10+
}
11+
return `"${ref}"`;
12+
}
13+
714
/**
815
* Manages custom modules installed from user-provided sources.
916
* Supports any Git host (GitHub, GitLab, Bitbucket, self-hosted) and local file paths.
@@ -38,8 +45,8 @@ class CustomModuleManager {
3845
};
3946
}
4047

41-
const trimmed = input.trim();
42-
if (!trimmed) {
48+
const trimmedRaw = input.trim();
49+
if (!trimmedRaw) {
4350
return {
4451
type: null,
4552
cloneUrl: null,
@@ -52,8 +59,53 @@ class CustomModuleManager {
5259
};
5360
}
5461

62+
// Extract optional @<tag-or-branch> suffix from the end of the input.
63+
// Semver-valid characters: letters, digits, dot, hyphen, underscore, plus, slash.
64+
// Raw commit SHAs are NOT supported here — `git clone --branch` can't take
65+
// them; use --pin at the module level or check out the SHA manually.
66+
// Only strip when the tail looks like a ref, so we don't disturb
67+
// URLs without a version spec or the SSH protocol's `git@host:...` prefix.
68+
let trimmed = trimmedRaw;
69+
let versionSuffix = null;
70+
const lastAt = trimmedRaw.lastIndexOf('@');
71+
// Skip if @ is part of git@github.com:... (first char cannot be stripped as version)
72+
// and skip if @ appears before the path rather than after a ref-shaped tail.
73+
if (lastAt > 0) {
74+
const candidate = trimmedRaw.slice(lastAt + 1);
75+
const before = trimmedRaw.slice(0, lastAt);
76+
// candidate must be ref-shaped and must not itself look like a URL / SSH host
77+
if (/^[\w.\-+/]+$/.test(candidate) && !candidate.includes(':')) {
78+
// Avoid consuming the @ in `git@host:owner/repo` — `before` wouldn't end with a path separator
79+
// in that case. Require that the @ comes after the host/path, not inside the auth segment.
80+
// Rule: the @ is a version suffix only if `before` looks like a complete URL or local path.
81+
const beforeLooksLikeRepo =
82+
before.startsWith('/') ||
83+
before.startsWith('./') ||
84+
before.startsWith('../') ||
85+
before.startsWith('~') ||
86+
/^https?:\/\//i.test(before) ||
87+
/^git@[^:]+:.+/.test(before);
88+
if (beforeLooksLikeRepo) {
89+
versionSuffix = candidate;
90+
trimmed = before;
91+
}
92+
}
93+
}
94+
5595
// Local path detection: starts with /, ./, ../, or ~
5696
if (trimmed.startsWith('/') || trimmed.startsWith('./') || trimmed.startsWith('../') || trimmed.startsWith('~')) {
97+
if (versionSuffix) {
98+
return {
99+
type: 'local',
100+
cloneUrl: null,
101+
subdir: null,
102+
localPath: null,
103+
cacheKey: null,
104+
displayName: null,
105+
isValid: false,
106+
error: 'Local paths do not support @version suffixes',
107+
};
108+
}
57109
return this._parseLocalPath(trimmed);
58110
}
59111

@@ -66,6 +118,8 @@ class CustomModuleManager {
66118
cloneUrl: trimmed,
67119
subdir: null,
68120
localPath: null,
121+
version: versionSuffix || null,
122+
rawInput: trimmedRaw,
69123
cacheKey: `${host}/${owner}/${repo}`,
70124
displayName: `${owner}/${repo}`,
71125
isValid: true,
@@ -79,29 +133,47 @@ class CustomModuleManager {
79133
const [, host, owner, repo, remainder] = httpsMatch;
80134
const cloneUrl = `https://${host}/${owner}/${repo}`;
81135
let subdir = null;
136+
let urlRef = null; // branch/tag extracted from /tree/<ref>/subdir
82137

83138
if (remainder) {
84139
// Extract subdir from deep path patterns used by various Git hosts
85140
const deepPathPatterns = [
86-
/^\/(?:-\/)?tree\/[^/]+\/(.+)$/, // GitHub /tree/branch/path, GitLab /-/tree/branch/path
87-
/^\/(?:-\/)?blob\/[^/]+\/(.+)$/, // /blob/branch/path (treat same as tree)
88-
/^\/src\/[^/]+\/(.+)$/, // Gitea/Forgejo /src/branch/path
141+
{ regex: /^\/(?:-\/)?tree\/([^/]+)\/(.+)$/, refIdx: 1, pathIdx: 2 }, // GitHub, GitLab
142+
{ regex: /^\/(?:-\/)?blob\/([^/]+)\/(.+)$/, refIdx: 1, pathIdx: 2 },
143+
{ regex: /^\/src\/([^/]+)\/(.+)$/, refIdx: 1, pathIdx: 2 }, // Gitea/Forgejo
89144
];
145+
// Also match `/tree/<ref>` with no subdir
146+
const refOnlyPatterns = [/^\/(?:-\/)?tree\/([^/]+?)\/?$/, /^\/(?:-\/)?blob\/([^/]+?)\/?$/, /^\/src\/([^/]+?)\/?$/];
90147

91-
for (const pattern of deepPathPatterns) {
92-
const match = remainder.match(pattern);
148+
for (const p of deepPathPatterns) {
149+
const match = remainder.match(p.regex);
93150
if (match) {
94-
subdir = match[1].replace(/\/$/, ''); // strip trailing slash
151+
urlRef = match[p.refIdx];
152+
subdir = match[p.pathIdx].replace(/\/$/, '');
95153
break;
96154
}
97155
}
156+
if (!subdir) {
157+
for (const r of refOnlyPatterns) {
158+
const match = remainder.match(r);
159+
if (match) {
160+
urlRef = match[1];
161+
break;
162+
}
163+
}
164+
}
98165
}
99166

167+
// Precedence: explicit @version suffix > URL /tree/<ref> path segment.
168+
const version = versionSuffix || urlRef || null;
169+
100170
return {
101171
type: 'url',
102172
cloneUrl,
103173
subdir,
104174
localPath: null,
175+
version,
176+
rawInput: trimmedRaw,
105177
cacheKey: `${host}/${owner}/${repo}`,
106178
displayName: `${owner}/${repo}`,
107179
isValid: true,
@@ -255,6 +327,10 @@ class CustomModuleManager {
255327
const silent = options.silent || false;
256328
const displayName = parsed.displayName;
257329

330+
// Pin override: --pin CODE=TAG resolved at module-selection time overrides
331+
// any @version suffix present in the URL.
332+
const effectiveVersion = options.pinOverride || parsed.version || null;
333+
258334
await fs.ensureDir(path.dirname(repoCacheDir));
259335

260336
const createSpinner = async () => {
@@ -264,8 +340,23 @@ class CustomModuleManager {
264340
return await prompts.spinner();
265341
};
266342

343+
// If an existing cache exists but was cloned at a different version, re-clone.
344+
// Tracked via .bmad-source.json's recorded version.
345+
if (await fs.pathExists(repoCacheDir)) {
346+
let cachedVersion = null;
347+
try {
348+
const existing = await fs.readJson(path.join(repoCacheDir, '.bmad-source.json'));
349+
cachedVersion = existing?.version || null;
350+
} catch {
351+
// no metadata; treat as mismatched to be safe if a version was requested
352+
}
353+
if ((effectiveVersion || null) !== (cachedVersion || null)) {
354+
await fs.remove(repoCacheDir);
355+
}
356+
}
357+
267358
if (await fs.pathExists(repoCacheDir)) {
268-
// Update existing clone
359+
// Update existing clone (same version as before)
269360
const fetchSpinner = await createSpinner();
270361
fetchSpinner.start(`Updating ${displayName}...`);
271362
try {
@@ -274,10 +365,42 @@ class CustomModuleManager {
274365
stdio: ['ignore', 'pipe', 'pipe'],
275366
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
276367
});
277-
execSync('git reset --hard origin/HEAD', {
278-
cwd: repoCacheDir,
279-
stdio: ['ignore', 'pipe', 'pipe'],
280-
});
368+
if (effectiveVersion) {
369+
// Fetch the ref as either a tag or a branch — `origin <ref>` works
370+
// for both, whereas `origin tag <ref>` fails for branch refs parsed
371+
// out of /tree/<branch>/... URLs.
372+
execSync(`git fetch --depth 1 origin ${quoteCustomRef(effectiveVersion)} --no-tags`, {
373+
cwd: repoCacheDir,
374+
stdio: ['ignore', 'pipe', 'pipe'],
375+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
376+
});
377+
execSync(`git checkout --quiet FETCH_HEAD`, {
378+
cwd: repoCacheDir,
379+
stdio: ['ignore', 'pipe', 'pipe'],
380+
});
381+
} else {
382+
// Resolve the default branch (origin/HEAD) and fetch it explicitly.
383+
// With shallow clones, `origin/HEAD` is stale and `git reset --hard
384+
// origin/HEAD` never picks up new commits on the default branch.
385+
let defaultBranch = 'main';
386+
try {
387+
defaultBranch = execSync('git symbolic-ref refs/remotes/origin/HEAD --short', {
388+
cwd: repoCacheDir,
389+
stdio: 'pipe',
390+
}).toString().trim().replace('origin/', '');
391+
} catch {
392+
// Fallback if origin/HEAD is not set
393+
}
394+
execSync(`git fetch --depth 1 origin ${quoteCustomRef(defaultBranch)}`, {
395+
cwd: repoCacheDir,
396+
stdio: ['ignore', 'pipe', 'pipe'],
397+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
398+
});
399+
execSync(`git reset --hard origin/${defaultBranch}`, {
400+
cwd: repoCacheDir,
401+
stdio: ['ignore', 'pipe', 'pipe'],
402+
});
403+
}
281404
fetchSpinner.stop(`Updated ${displayName}`);
282405
} catch {
283406
fetchSpinner.error(`Update failed, re-downloading ${displayName}`);
@@ -287,25 +410,44 @@ class CustomModuleManager {
287410

288411
if (!(await fs.pathExists(repoCacheDir))) {
289412
const fetchSpinner = await createSpinner();
290-
fetchSpinner.start(`Cloning ${displayName}...`);
413+
fetchSpinner.start(`Cloning ${displayName}${effectiveVersion ? ` @ ${effectiveVersion}` : ''}...`);
291414
try {
292-
execSync(`git clone --depth 1 "${parsed.cloneUrl}" "${repoCacheDir}"`, {
293-
stdio: ['ignore', 'pipe', 'pipe'],
294-
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
295-
});
415+
if (effectiveVersion) {
416+
execSync(`git clone --depth 1 --branch ${quoteCustomRef(effectiveVersion)} "${parsed.cloneUrl}" "${repoCacheDir}"`, {
417+
stdio: ['ignore', 'pipe', 'pipe'],
418+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
419+
});
420+
} else {
421+
execSync(`git clone --depth 1 "${parsed.cloneUrl}" "${repoCacheDir}"`, {
422+
stdio: ['ignore', 'pipe', 'pipe'],
423+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
424+
});
425+
}
296426
fetchSpinner.stop(`Cloned ${displayName}`);
297427
} catch (error_) {
298428
fetchSpinner.error(`Failed to clone ${displayName}`);
299-
throw new Error(`Failed to clone ${parsed.cloneUrl}: ${error_.message}`);
429+
const refSuffix = effectiveVersion ? `@${effectiveVersion}` : '';
430+
throw new Error(`Failed to clone ${parsed.cloneUrl}${refSuffix}: ${error_.message}`);
300431
}
301432
}
302433

434+
// Record the resolved SHA for the manifest writer.
435+
let resolvedSha = null;
436+
try {
437+
resolvedSha = execSync('git rev-parse HEAD', { cwd: repoCacheDir, stdio: 'pipe' }).toString().trim();
438+
} catch {
439+
// swallow — a non-git repo (local path) wouldn't reach here anyway
440+
}
441+
303442
// Write source metadata for later URL reconstruction
304443
const metadataPath = path.join(repoCacheDir, '.bmad-source.json');
305444
await fs.writeJson(metadataPath, {
306445
cloneUrl: parsed.cloneUrl,
307446
cacheKey: parsed.cacheKey,
308447
displayName: parsed.displayName,
448+
version: effectiveVersion || null,
449+
rawInput: parsed.rawInput || sourceInput,
450+
sha: resolvedSha,
309451
clonedAt: new Date().toISOString(),
310452
});
311453

@@ -346,10 +488,26 @@ class CustomModuleManager {
346488
const resolver = new PluginResolver();
347489
const resolved = await resolver.resolve(repoPath, plugin);
348490

491+
// Read clone metadata (written by cloneRepo) so we can pick up the
492+
// resolved git ref + SHA for manifest recording.
493+
let cloneMetadata = null;
494+
if (sourceUrl) {
495+
try {
496+
cloneMetadata = await fs.readJson(path.join(repoPath, '.bmad-source.json'));
497+
} catch {
498+
// no metadata — local-source or legacy cache
499+
}
500+
}
501+
349502
// Stamp source info onto each resolved module for manifest tracking
350503
for (const mod of resolved) {
351504
if (sourceUrl) mod.repoUrl = sourceUrl;
352505
if (localPath) mod.localPath = localPath;
506+
if (cloneMetadata) {
507+
mod.cloneRef = cloneMetadata.version || null;
508+
mod.cloneSha = cloneMetadata.sha || null;
509+
mod.rawInput = cloneMetadata.rawInput || null;
510+
}
353511
CustomModuleManager._resolutionCache.set(mod.code, mod);
354512
}
355513

0 commit comments

Comments
 (0)