mirror of
https://github.com/firstsun-dev/git-files-sync.git
synced 2026-07-22 17:20:30 +00:00
- Root path setting's folder picker now suggests folders from the remote repo tree (RemoteFolderSuggest) instead of the local vault, since Root path is a repo-side setting unrelated to local vault structure. - GitHub/Gitea deleteFile: URL-encode path segments (matching GitLab's existing behavior) so paths with spaces/non-ASCII don't 404 on the pre-delete sha lookup; throw a clear error instead of silently sending a DELETE with an empty sha when that lookup 404s. - SyncStatusView delete flow now surfaces the real error message instead of swallowing it into a bare "N failed" notice. - SyncStatusView.readFileContent: guard against EISDIR when a hidden local-only symlinked folder (not yet known to the remote) is read as a file, falling back to the raw symlink target. Closes firstsun-dev/blog#78 (misfiled; this is the actual fix location).
162 lines
7 KiB
TypeScript
162 lines
7 KiB
TypeScript
import { GitServiceInterface, GitTreeEntry } from './git-service-interface';
|
|
import { BaseGitService, ConnectionTestResult, GitFile, GitHubContentResponse, GitHubTreeResponse, GIT_SYMLINK_MODE } from './git-service-base';
|
|
import { logger } from '../utils/logger';
|
|
|
|
export class GitHubService extends BaseGitService implements GitServiceInterface {
|
|
private owner: string = '';
|
|
private repo: string = '';
|
|
|
|
updateConfig(token: string, owner: string, repo: string, rootPath: string = '') {
|
|
this.token = token;
|
|
this.owner = owner;
|
|
this.repo = repo;
|
|
this.rootPath = rootPath;
|
|
}
|
|
|
|
protected addAuthHeader(headers: Record<string, string>): void {
|
|
headers['Authorization'] = `token ${this.token}`;
|
|
}
|
|
|
|
private getApiUrl(path: string): string {
|
|
const fullPath = this.getFullPath(path);
|
|
const encodedPath = fullPath.split('/').map(encodeURIComponent).join('/');
|
|
return `https://api.github.com/repos/${this.owner}/${this.repo}/contents/${encodedPath}`;
|
|
}
|
|
|
|
async getFile(path: string, branch: string): Promise<GitFile> {
|
|
try {
|
|
const url = `${this.getApiUrl(path)}?ref=${branch}`;
|
|
const response = await this.safeRequest(url, 'GET');
|
|
const data = this.parseJson<GitHubContentResponse & { type?: string; target?: string }>(response);
|
|
|
|
// An unresolved symlink is returned as type 'symlink' with the literal
|
|
// target. (Links whose target is a normal in-repo file are followed by
|
|
// the Contents API and come back as ordinary file content.)
|
|
if (data.type === 'symlink') {
|
|
return { content: '', sha: data.sha, isSymlink: true, symlinkTarget: data.target };
|
|
}
|
|
|
|
return {
|
|
content: this.decodeContent(data.content, path),
|
|
sha: data.sha
|
|
};
|
|
} catch (e) {
|
|
return this.handleFileNotFound(e);
|
|
}
|
|
}
|
|
|
|
async pushFile(path: string, content: string | ArrayBuffer, branch: string, message: string, sha?: string): Promise<{ path: string, sha?: string }> {
|
|
const url = this.getApiUrl(path);
|
|
const body: { message: string; content: string; branch: string; sha?: string } = {
|
|
message,
|
|
content: this.encodeContent(content),
|
|
branch,
|
|
};
|
|
// GitHub's Contents API rejects a blank sha with HTTP 422. Only include
|
|
// it when updating an existing file; a 404 lookup yields sha === '' for
|
|
// new files, which must be created without a sha.
|
|
if (sha) body.sha = sha;
|
|
|
|
const response = await this.safeRequest(url, 'PUT', body);
|
|
const data = this.parseJson<{ content: { path: string, sha: string } }>(response);
|
|
return { path: data.content.path, sha: data.content.sha };
|
|
}
|
|
|
|
async pushSymlink(path: string, target: string, branch: string, message: string): Promise<{ path: string, sha?: string }> {
|
|
// The Contents API can only create regular (100644) files, so symlinks
|
|
// (mode 120000) must be committed through the lower-level Git Data API:
|
|
// blob -> tree (with the symlink mode) -> commit -> move the branch ref.
|
|
const fullPath = this.getFullPath(path);
|
|
const base = `https://api.github.com/repos/${this.owner}/${this.repo}`;
|
|
|
|
const refResp = await this.safeRequest(`${base}/git/ref/heads/${branch}`, 'GET');
|
|
const latestCommitSha = this.parseJson<{ object: { sha: string } }>(refResp).object.sha;
|
|
|
|
const commitResp = await this.safeRequest(`${base}/git/commits/${latestCommitSha}`, 'GET');
|
|
const baseTreeSha = this.parseJson<{ tree: { sha: string } }>(commitResp).tree.sha;
|
|
|
|
const blobResp = await this.safeRequest(`${base}/git/blobs`, 'POST', { content: target, encoding: 'utf-8' });
|
|
const blobSha = this.parseJson<{ sha: string }>(blobResp).sha;
|
|
|
|
const treeResp = await this.safeRequest(`${base}/git/trees`, 'POST', {
|
|
base_tree: baseTreeSha,
|
|
tree: [{ path: fullPath, mode: GIT_SYMLINK_MODE, type: 'blob', sha: blobSha }],
|
|
});
|
|
const newTreeSha = this.parseJson<{ sha: string }>(treeResp).sha;
|
|
|
|
const newCommitResp = await this.safeRequest(`${base}/git/commits`, 'POST', {
|
|
message,
|
|
tree: newTreeSha,
|
|
parents: [latestCommitSha],
|
|
});
|
|
const newCommitSha = this.parseJson<{ sha: string }>(newCommitResp).sha;
|
|
|
|
await this.safeRequest(`${base}/git/refs/heads/${branch}`, 'PATCH', { sha: newCommitSha });
|
|
|
|
return { path: fullPath, sha: blobSha };
|
|
}
|
|
|
|
async listFilesDetailed(branch: string, useFilter = true): Promise<GitTreeEntry[]> {
|
|
const url = `https://api.github.com/repos/${this.owner}/${this.repo}/git/trees/${branch}?recursive=1`;
|
|
let data: GitHubTreeResponse;
|
|
try {
|
|
const response = await this.safeRequest(url, 'GET');
|
|
data = this.parseJson<GitHubTreeResponse>(response);
|
|
} catch (e) {
|
|
throw this.branchNotFoundError(e, branch);
|
|
}
|
|
|
|
if (data.truncated) {
|
|
logger.warn('GitHub tree result is truncated. Some files might not be shown.');
|
|
}
|
|
|
|
const entries = data.tree
|
|
.filter(item => item.type === 'blob')
|
|
.map(item => ({ path: item.path, symlink: item.mode === GIT_SYMLINK_MODE, sha: item.sha }));
|
|
|
|
if (!useFilter) return entries;
|
|
|
|
return entries.filter(e => {
|
|
if (!this.rootPath) return true;
|
|
const cleanRoot = this.rootPath.endsWith('/') ? this.rootPath : `${this.rootPath}/`;
|
|
return e.path === this.rootPath || e.path.startsWith(cleanRoot);
|
|
});
|
|
}
|
|
|
|
async getBlob(sha: string, path: string): Promise<GitFile> {
|
|
return this.fetchGitHubStyleBlob(`https://api.github.com/repos/${this.owner}/${this.repo}/git/blobs/${sha}`, path);
|
|
}
|
|
|
|
async deleteFile(path: string, branch: string, message: string): Promise<void> {
|
|
const file = await this.getFile(path, branch);
|
|
if (!file.sha) {
|
|
throw new Error(`Cannot delete "${path}": file was not found on branch "${branch}".`);
|
|
}
|
|
const url = this.getApiUrl(path);
|
|
const body = {
|
|
message,
|
|
sha: file.sha,
|
|
branch
|
|
};
|
|
|
|
await this.safeRequest(url, 'DELETE', body);
|
|
}
|
|
|
|
async testConnection(branch: string): Promise<ConnectionTestResult> {
|
|
try {
|
|
const url = `https://api.github.com/repos/${this.owner}/${this.repo}`;
|
|
await this.safeRequest(url, 'GET');
|
|
} catch (e) {
|
|
return { repoOk: false, branchOk: false, error: e instanceof Error ? e.message : String(e) };
|
|
}
|
|
|
|
try {
|
|
const branchUrl = `https://api.github.com/repos/${this.owner}/${this.repo}/branches/${branch}`;
|
|
await this.safeRequest(branchUrl, 'GET', undefined, undefined, true);
|
|
return { repoOk: true, branchOk: true };
|
|
} catch {
|
|
return { repoOk: true, branchOk: false };
|
|
}
|
|
}
|
|
|
|
}
|