firstsun-dev_git-files-sync/src/services/git-service-base.ts
ClaudiaFang 2f6859a2f1 fix(sync): clearer branch-not-found errors and connection test
- listFilesDetailed rethrows 404 branch-resolution failures with a
  message naming the branch, instead of a bare "Git Service Error (404)"
- testConnection now also checks the configured branch exists and
  reports repoOk/branchOk separately so the settings UI can warn when
  the repo is reachable but the branch is missing
- fixes settings.ts silently reporting "connection successful" even
  when testConnection's result was never checked

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 04:57:47 +00:00

244 lines
9.8 KiB
TypeScript

import { requestUrl, RequestUrlResponse } from 'obsidian';
import { logger } from '../utils/logger';
import { GitTreeEntry } from './git-service-interface';
export interface GitFile {
content: string | ArrayBuffer;
sha: string;
isSymlink?: boolean;
symlinkTarget?: string;
}
// Git file mode for a symbolic link. Symlinks are stored as blobs whose content
// is the link target path, so they need special handling during sync.
export const GIT_SYMLINK_MODE = '120000';
export interface GitHubContentResponse {
content: string;
sha: string;
path: string;
}
export interface GitHubTreeItem {
path: string;
type: string;
mode?: string;
}
export interface GitHubTreeResponse {
tree: GitHubTreeItem[];
truncated: boolean;
}
export interface GitLabFileResponse {
content: string;
blob_id: string;
file_path: string;
last_commit_id: string;
}
export interface GitLabTreeItem {
path: string;
type: string;
mode?: string;
}
export interface ConnectionTestResult {
/** Whether the repository/project itself was reachable with the given credentials. */
repoOk: boolean;
/** Whether the configured branch was found. Only meaningful when repoOk is true. */
branchOk: boolean;
/** Populated when repoOk is false, describing the repo-level failure. */
error?: string;
}
export abstract class BaseGitService {
protected token: string = '';
protected rootPath: string = '';
/**
* Safely wraps requestUrl to handle potential throws from Obsidian and provide better error messages.
*/
protected async safeRequest(url: string, method: string, body?: unknown, extraHeaders?: Record<string, string>, silent = false): Promise<RequestUrlResponse> {
let response: RequestUrlResponse;
try {
const headers: Record<string, string> = {
...extraHeaders,
'Content-Type': 'application/json',
};
this.addAuthHeader(headers);
const options = {
url,
method,
headers,
body: body ? JSON.stringify(body) : undefined,
throw: false
};
response = await requestUrl(options);
} catch (error) {
// Network-level failure (DNS, offline, TLS, etc.)
if (!silent) logger.error('Git Service Request Failed:', error);
if (error instanceof Error) throw error;
throw new Error(`Network error or unexpected failure: ${String(error)}`);
}
if (response.status >= 400) {
const errorMsg = this.parseErrorResponse(response);
// 404 is routinely an expected "does not exist" probe (getFile treats
// it as an empty file, gitignore lookups ignore it). Log it at debug
// level so it doesn't surface as a failure, but still throw so callers
// can handle it. Other statuses are genuine errors.
if (!silent) {
if (response.status === 404) logger.debug(`Git Service 404 (not found): ${url}`);
else logger.error(`Git Service Request Failed (${response.status}): ${url}`, errorMsg);
}
throw new Error(`Git Service Error (${response.status}): ${errorMsg}`);
}
return response;
}
protected abstract addAuthHeader(headers: Record<string, string>): void;
/**
* Safely parses a response body as JSON.
*
* Some servers/proxies return a 2xx (or redirect) response whose body is an
* HTML page — a login screen, an SSO redirect, or a proxy/error page — rather
* than JSON. Accessing `response.json` directly then throws a cryptic
* "Unexpected token '<', \"<!DOCTYPE ...\" is not valid JSON" error. This helper
* detects that case and throws a clear, actionable message instead.
*/
protected parseJson<T>(response: RequestUrlResponse): T {
const contentType = (response.headers?.['content-type'] ?? response.headers?.['Content-Type'] ?? '').toLowerCase();
const bodyText = (response.text ?? '').trimStart();
const looksLikeHtml = bodyText.startsWith('<') || contentType.includes('html');
try {
const data = response.json as T;
if (data === undefined && looksLikeHtml) throw new Error('non-JSON response');
return data;
} catch (e) {
if (looksLikeHtml) {
throw new Error(
'Expected a JSON response from the Git server but received an HTML page ' +
'(likely a login, SSO redirect, or proxy/error page). ' +
'Please check the server URL, your access token, and any network proxy or firewall.'
);
}
throw new Error(`Failed to parse the Git server response as JSON: ${e instanceof Error ? e.message : String(e)}`);
}
}
protected parseErrorResponse(response: RequestUrlResponse): string {
try {
const data = response.json as { message?: string; error?: string };
return data.message || data.error || JSON.stringify(data);
} catch {
const bodyText = (response.text ?? '').trim();
const contentType = (response.headers?.['content-type'] ?? response.headers?.['Content-Type'] ?? '').toLowerCase();
if (bodyText.startsWith('<') || contentType.includes('html')) {
return 'Received an HTML page instead of a JSON error (likely a login, redirect, or proxy/error page). Check the server URL, access token, and network proxy.';
}
return bodyText || 'Unknown error';
}
}
protected getFullPath(path: string): string {
// Leading / = absolute repo path; skip rootPath prefix entirely
if (path.startsWith('/')) return path.slice(1);
if (!this.rootPath) return path;
const cleanRoot = this.rootPath.endsWith('/') ? this.rootPath : `${this.rootPath}/`;
// Already contains rootPath prefix (path came from listFiles or vault IS repo root)
if (path.startsWith(cleanRoot)) return path;
return cleanRoot + path;
}
protected isBinary(path: string): boolean {
const ext = path.split('.').pop()?.toLowerCase();
if (!ext) return false;
const BINARY_EXTENSIONS = new Set([
'png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'pdf', 'zip', 'gz', '7z', 'rar',
'mp3', 'mp4', 'wav', 'ogg', 'webm', 'mov', 'avi', 'wmv', 'webp',
'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'epub', 'exe', 'dll', 'so',
'ttf', 'woff', 'woff2', 'eot', 'wasm', 'dmg', 'iso'
]);
return BINARY_EXTENSIONS.has(ext);
}
protected encodeContent(content: string | ArrayBuffer): string {
if (typeof content === 'string') {
const bytes = new TextEncoder().encode(content);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
const byte = bytes[i];
if (byte !== undefined) binary += String.fromCodePoint(byte);
}
return btoa(binary);
} else {
const bytes = new Uint8Array(content);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
const byte = bytes[i];
if (byte !== undefined) binary += String.fromCodePoint(byte);
}
return btoa(binary);
}
}
protected decodeContent(base64: string, path: string): string | ArrayBuffer {
const binary = atob(base64.replace(/\s/g, ''));
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
const cp = binary.codePointAt(i);
bytes[i] = cp !== undefined ? cp : 0;
}
return this.isBinary(path) ? bytes.buffer : new TextDecoder().decode(bytes);
}
protected handleFileNotFound(e: unknown): GitFile {
if (e instanceof Error && e.message.includes('404')) {
return { content: '', sha: '' };
}
throw e;
}
/**
* Rethrows a branch-resolution failure (e.g. the "resolve branch to a commit"
* request in listFilesDetailed) with a message that names the branch, since a
* bare "404" gives no clue that the configured Branch setting is the problem.
*/
protected branchNotFoundError(e: unknown, branch: string): Error {
if (e instanceof Error && e.message.includes('404')) {
return new Error(
`Branch "${branch}" was not found in the repository. Check the Branch setting, ` +
'or confirm the repository actually has a branch with this name (its default branch ' +
'might be "master" instead of "main", or the repository may have no commits yet).'
);
}
return e instanceof Error ? e : new Error(String(e));
}
async getRepoGitignores(branch: string): Promise<string[]> {
try {
const allFiles = await this.listFiles(branch, false); // Fetch ALL files to find gitignores
return allFiles.filter(p => p.endsWith('.gitignore'));
} catch {
return [];
}
}
async listFiles(branch: string, useFilter = true): Promise<string[]> {
const entries = await this.listFilesDetailed(branch, useFilter);
return entries.map(e => e.path);
}
abstract getFile(path: string, branch: string): Promise<GitFile>;
abstract pushFile(path: string, content: string | ArrayBuffer, branch: string, message: string, sha?: string): Promise<{ path: string, sha?: string }>;
abstract listFilesDetailed(branch: string, useFilter?: boolean): Promise<GitTreeEntry[]>;
abstract deleteFile(path: string, branch: string, message: string): Promise<void>;
abstract testConnection(branch: string): Promise<ConnectionTestResult>;
}