mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-26 02:06:49 +00:00
feat(git-sources): harden create-from-git with LFS + submodule warnings (#609)
* feat(git-sources): surface LFS and submodule warnings on create
Creating a stack from a Git repo now detects two common anomalies and
tells the user about them rather than silently producing broken stacks.
- LFS-pointer compose/env files fail early with a clear error instead
of writing a 130-byte pointer stub to disk as real content.
- Repositories containing .gitmodules produce a non-fatal warning so
the user knows build contexts or volumes inside submodules will be
empty at deploy time.
Also refines the create dialog: sr-only DialogDescription for a11y,
short commit SHA suffix on the success toast, env-path hint under the
"Sync .env" checkbox showing which path will be read, and a route-level
diagnostic log line gated on developer mode for support debugging.
* test(git-sources): cover LFS, submodule, and nested env_path paths
Adds unit coverage for the new LFS-pointer rejection and submodule
warning plumbing, plus a nested compose_path case that exercises the
default env_path resolution ("apps/web/compose.yaml" with sync_env on
and env_path unset writes "apps/web/.env" both to disk and to the DB).
Extends the E2E suite with a happy-path assertion that the full-length
commit SHA is returned in the create response, and a UI flow that
verifies the short-SHA suffix appears in the success toast.
* docs(git-sources): add troubleshooting for LFS, submodules, HTTPS-only
Adds troubleshooting entries for the newly surfaced LFS and submodule
anomalies, expands the clone-timeout entry with the bounded-fetch
explanation, and adds a dedicated HTTPS-only entry. Also consolidates
the known limitations into a single list covering LFS, submodules,
branch-tracking, and HTTPS-only.
* fix(settings): use Route icon for notification routing
The routing section in Settings previously used GitBranch, which now
clashes with the Git Source feature's icon across the editor. Switch
to Route (a branching-flow glyph) so routing rules have a distinct
visual identity and aren't visually conflated with Git-backed stacks.
* fix(git-sources): return 400 for upstream auth failures and disambiguate 404s
Upstream git-host auth failures were mapping to HTTP 401, which the frontend
apiFetch treats as a Sencho session expiry and fires the global logout event.
They now return 400 with code=AUTH_FAILED in the body so the UI can branch on
the discriminator without logging the user out. The status mapping moved into
utils/gitSourceHttp so it can be unit-tested without booting the app.
mapGitError also relied on the HttpError class alone, so any non-2xx response
(including 404) was classified as auth failure. It now inspects the numeric
status on err.data and considers whether a token was supplied, producing more
actionable messages for missing repos, private repos, and wrong-scope tokens.
This commit is contained in:
@@ -50,6 +50,12 @@ export interface FetchResult {
|
||||
composeContent: string;
|
||||
envContent: string | null;
|
||||
commitSha: string;
|
||||
/**
|
||||
* Non-fatal issues detected during the fetch (e.g. the repo uses
|
||||
* submodules that are not cloned). The stack is still usable but the
|
||||
* UI should surface these so the user is not surprised later.
|
||||
*/
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface UpsertInput {
|
||||
@@ -82,6 +88,7 @@ export interface CreateStackFromGitResult {
|
||||
source: PublicGitSource;
|
||||
commitSha: string;
|
||||
envWritten: boolean;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface PullResult {
|
||||
@@ -142,7 +149,7 @@ function scrubCredentials(message: string): string {
|
||||
* repo URL that could contain an inline credential. Falls back to
|
||||
* `unknown` for malformed URLs.
|
||||
*/
|
||||
function repoHost(url: string): string {
|
||||
export function repoHost(url: string): string {
|
||||
try {
|
||||
return new URL(url).host || 'unknown';
|
||||
} catch {
|
||||
@@ -150,6 +157,41 @@ function repoHost(url: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Git LFS stores large files as small pointer stubs in the working tree.
|
||||
* The pointer is a short text file that always begins with this line.
|
||||
* isomorphic-git does not resolve LFS, so if the compose or env file is
|
||||
* tracked through LFS we would silently write the pointer as content.
|
||||
* Detect this and refuse, with a clear error, before it ever lands on
|
||||
* disk.
|
||||
*/
|
||||
const LFS_POINTER_PREFIX = 'version https://git-lfs.github.com/spec/v';
|
||||
|
||||
function isLfsPointer(content: string): boolean {
|
||||
// Pointer files are a few lines of ASCII, always starting with the
|
||||
// version header on the first line. Check just the leading bytes so
|
||||
// a very large plain file does not trigger a full scan.
|
||||
return content.slice(0, LFS_POINTER_PREFIX.length) === LFS_POINTER_PREFIX;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the cloned tree references Git submodules. We do not
|
||||
* fetch submodule contents (isomorphic-git does not support them), so
|
||||
* warn the caller that any paths inside submodule directories will be
|
||||
* empty at deploy time.
|
||||
*/
|
||||
async function hasSubmodules(dir: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fsPromises.stat(path.join(dir, '.gitmodules'));
|
||||
return stat.isFile() && stat.size > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const SUBMODULE_WARNING =
|
||||
'Repository contains Git submodules. Their contents are not cloned; any paths referenced from them will be missing at deploy time.';
|
||||
|
||||
/**
|
||||
* Reject any relative path that resolves into the `.git` metadata
|
||||
* directory. The path-traversal check in `fetchFromGit` already bounds
|
||||
@@ -377,7 +419,7 @@ export class GitSourceService {
|
||||
timeout,
|
||||
]);
|
||||
} catch (e) {
|
||||
throw this.mapGitError(e as Error);
|
||||
throw this.mapGitError(e as Error, Boolean(token));
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
@@ -401,6 +443,13 @@ export class GitSourceService {
|
||||
}
|
||||
throw new GitSourceError('GIT_ERROR', scrubCredentials((e as Error).message));
|
||||
}
|
||||
if (isLfsPointer(composeContent)) {
|
||||
console.error(`[GitSource] LFS pointer detected in ${composePath}`);
|
||||
throw new GitSourceError(
|
||||
'GIT_ERROR',
|
||||
`Compose file at ${composePath} is stored in Git LFS, which is not supported. Commit the plain file or replace the LFS pointer before linking this repository.`,
|
||||
);
|
||||
}
|
||||
|
||||
let envContent: string | null = null;
|
||||
if (envPath) {
|
||||
@@ -420,14 +469,30 @@ export class GitSourceService {
|
||||
throw new GitSourceError('GIT_ERROR', scrubCredentials((e as Error).message));
|
||||
}
|
||||
}
|
||||
if (envContent !== null && isLfsPointer(envContent)) {
|
||||
console.error(`[GitSource] LFS pointer detected in ${envPath}`);
|
||||
throw new GitSourceError(
|
||||
'GIT_ERROR',
|
||||
`Env file at ${envPath} is stored in Git LFS, which is not supported. Commit the plain file or replace the LFS pointer before linking this repository.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Submodule detection: non-fatal, surfaced as a warning. isomorphic-git
|
||||
// does not recursively clone submodules, so any path that lives inside
|
||||
// a submodule directory will be empty after apply. Users need to know.
|
||||
const warnings: string[] = [];
|
||||
if (await hasSubmodules(dir)) {
|
||||
console.warn(`[GitSource] Submodules detected in ${repoHost(repoUrl)}; contents not cloned.`);
|
||||
warnings.push(SUBMODULE_WARNING);
|
||||
}
|
||||
|
||||
if (diag) {
|
||||
console.log(
|
||||
`[GitSource:diag] fetch ok host=${repoHost(repoUrl)} branch=${branch} sha=${commitSha.slice(0, 7)} env=${envContent !== null ? 'present' : 'absent'} elapsedMs=${Date.now() - startedAt}`
|
||||
`[GitSource:diag] fetch ok host=${repoHost(repoUrl)} branch=${branch} sha=${commitSha.slice(0, 7)} env=${envContent !== null ? 'present' : 'absent'} warnings=${warnings.length} elapsedMs=${Date.now() - startedAt}`
|
||||
);
|
||||
}
|
||||
return { composeContent, envContent, commitSha };
|
||||
return { composeContent, envContent, commitSha, warnings };
|
||||
} catch (err) {
|
||||
if (diag) {
|
||||
const msg = err instanceof GitSourceError ? `${err.code}: ${err.message}` : (err as Error).message;
|
||||
@@ -441,13 +506,43 @@ export class GitSourceService {
|
||||
}
|
||||
}
|
||||
|
||||
private mapGitError(err: Error): GitSourceError {
|
||||
private mapGitError(err: Error, hasToken: boolean): GitSourceError {
|
||||
const raw = scrubCredentials(err.message || String(err));
|
||||
const code = (err as Error & { code?: string }).code;
|
||||
// isomorphic-git's HttpError exposes the numeric status on .data; inspect
|
||||
// it directly so a 404 is not misclassified as auth failure. GitHub hides
|
||||
// private-repo existence by returning 404 to unauthenticated requests, so
|
||||
// we also treat 401/403 without a supplied token as "not found or private"
|
||||
// to guide the user to add a token rather than "check your token" when
|
||||
// they never provided one.
|
||||
const statusCode = (err as Error & { data?: { statusCode?: number } }).data?.statusCode;
|
||||
|
||||
// isomorphic-git error codes
|
||||
if (code === 'HttpError' || /401|403|authentication/i.test(raw)) {
|
||||
return new GitSourceError('AUTH_FAILED', 'Repository authentication failed. Check your token.');
|
||||
// GitHub returns 404 for both "repo genuinely missing" and "private repo
|
||||
// the caller cannot see". We cannot distinguish the two without a second
|
||||
// probe, so tailor the hint by whether credentials were supplied:
|
||||
// - no token: suggest adding one for private repos
|
||||
// - token present: suggest checking the URL and token scopes, since a
|
||||
// valid token against a missing or wrong-scoped repo also lands here
|
||||
if (statusCode === 404) {
|
||||
if (hasToken) {
|
||||
return new GitSourceError('REPO_NOT_FOUND', 'Repository not found. Verify the URL and that your token has read access to this repo.');
|
||||
}
|
||||
return new GitSourceError('REPO_NOT_FOUND', 'Repository not found, or it is private. Add a Personal Access Token if the repo is private.');
|
||||
}
|
||||
if (statusCode === 401 || statusCode === 403) {
|
||||
if (hasToken) {
|
||||
return new GitSourceError('AUTH_FAILED', 'Repository authentication failed. Check your token.');
|
||||
}
|
||||
return new GitSourceError('REPO_NOT_FOUND', 'Repository not found, or it is private. Add a Personal Access Token if the repo is private.');
|
||||
}
|
||||
|
||||
// Fallbacks for errors without a numeric status attached (e.g. git CLI
|
||||
// output, DNS/lib errors, or future isomorphic-git transports that do
|
||||
// not populate err.data.statusCode). Kept as defense-in-depth.
|
||||
if (/401|403|authentication/i.test(raw)) {
|
||||
return hasToken
|
||||
? new GitSourceError('AUTH_FAILED', 'Repository authentication failed. Check your token.')
|
||||
: new GitSourceError('REPO_NOT_FOUND', 'Repository not found, or it is private. Add a Personal Access Token if the repo is private.');
|
||||
}
|
||||
if (code === 'NotFoundError' || /404|not found|could not resolve/i.test(raw)) {
|
||||
return new GitSourceError('REPO_NOT_FOUND', 'Repository not found or not accessible.');
|
||||
@@ -458,6 +553,10 @@ export class GitSourceService {
|
||||
if (code === 'ECONNABORTED' || /timeout|timed out|ETIMEDOUT|ENOTFOUND|ECONNREFUSED/i.test(raw)) {
|
||||
return new GitSourceError('NETWORK_TIMEOUT', 'Network timeout or host unreachable.');
|
||||
}
|
||||
// Last-resort: an HttpError with a status we did not specifically handle.
|
||||
if (code === 'HttpError') {
|
||||
return new GitSourceError('GIT_ERROR', `Unexpected HTTP response from git host${statusCode ? ` (${statusCode})` : ''}.`);
|
||||
}
|
||||
return new GitSourceError('GIT_ERROR', raw);
|
||||
}
|
||||
|
||||
@@ -787,9 +886,9 @@ export class GitSourceService {
|
||||
|
||||
console.log(`[GitSource] Created stack ${input.stackName} from ${repoHost(input.repoUrl)} at ${fetched.commitSha.slice(0, 7)}`);
|
||||
if (diag) {
|
||||
console.log(`[GitSource:diag] createStackFromGit ok stack=${input.stackName} sha=${fetched.commitSha.slice(0, 7)} envWritten=${envWritten}`);
|
||||
console.log(`[GitSource:diag] createStackFromGit ok stack=${input.stackName} sha=${fetched.commitSha.slice(0, 7)} envWritten=${envWritten} warnings=${fetched.warnings.length}`);
|
||||
}
|
||||
return { source, commitSha: fetched.commitSha, envWritten };
|
||||
return { source, commitSha: fetched.commitSha, envWritten, warnings: fetched.warnings };
|
||||
} catch (e) {
|
||||
// Roll back any partial on-disk state so the caller can retry
|
||||
// cleanly. The DB row is only inserted at step 4, so an error
|
||||
|
||||
Reference in New Issue
Block a user