diff --git a/README.md b/README.md index 7bfb843..b666228 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,7 @@ Go to **Settings** > **Git File Sync** and select **GitLab**, **GitHub**, or **G - **Branch**: Specify the target branch (default: `main`). - **Root Path**: Prefix for files in the repository (e.g., `notes/`). - **Vault Folder**: Limit sync to a specific folder in your vault. +- **Symbolic links**: Choose how symlinks are synced — *real* (recreate the link, GitHub only), *follow* (sync the target's content), or *skip*. See [Symbolic link handling](docs/symlink-handling.md). --- diff --git a/docs/symlink-handling.md b/docs/symlink-handling.md new file mode 100644 index 0000000..d56d56d --- /dev/null +++ b/docs/symlink-handling.md @@ -0,0 +1,89 @@ +# Symbolic Link Handling + +This document describes how Git File Sync syncs **symbolic links** (symlinks) +between your vault and the remote repository. + +## Background + +In Git, a symbolic link is not a normal file — it is stored as a *blob with file +mode `120000`* whose **content is the link's target path** (for example +`../shared/note.md`). Because of this, symlinks need special treatment: + +- They cannot be fetched/created like normal files through every provider's API. +- Obsidian exposes no symlink API, and **mobile platforms (iOS/Android) cannot + create OS-level symlinks at all**. + +To keep behavior predictable, Git File Sync makes symlink handling an explicit, +configurable choice. + +## The setting + +**Settings → Git File Sync → Symbolic links** + +| Mode | What it does | +| :--- | :--- | +| **Real symlink** (default) | Recreates a real OS symlink on **desktop** when pulling, and pushes local symlinks back as real Git symlinks (mode `120000`). Falls back to content on platforms/providers that can't do this (see below). | +| **Follow** | Treats a symlink as the file it points to: syncs the **target's content** as a normal file. Never creates links. | +| **Skip** | Ignores symlinks entirely — they are not listed, pulled, or pushed. | + +The default is **Real symlink**. + +## Provider support + +Creating a symlink on the remote requires writing a `120000` blob, which is only +possible with a provider that exposes the full **Git Data API**. + +| Provider | Real symlink | Notes | +| :--- | :---: | :--- | +| **GitHub** | ✅ | Full support via the Git Data API (blob → tree → commit → ref). | +| **GitLab** | ❌ | The Commits API can't set the symlink mode. "Real" is treated as **Skip**. | +| **Gitea** | ❌ | No write access to git data endpoints. "Real" is treated as **Skip**. | + +**Config safeguard (防呆):** the "Real symlink" option is only offered in the +settings dropdown when the active provider is GitHub. On other providers a saved +value of `real` is automatically treated as `skip`, so a symlink is never +silently turned into an ordinary file. + +## Platform behavior + +| | Desktop | Mobile (iOS/Android) | +| :--- | :--- | :--- | +| **Real symlink (GitHub)** | Creates/reads real OS symlinks via Node's `fs`. | No symlink API — falls back: on pull, writes the target *path* as the file content. | + +## What happens per direction + +### Pull (remote → vault) + +- **Real** + GitHub + desktop: a remote symlink is recreated as a real OS link + pointing at its target. +- **Real** on mobile (or if the link can't be created): the link target path is + written into the file as its content, so it still round-trips. +- **Follow**: the target's content is written as a normal file. (For a link whose + target is a normal in-repo file, GitHub's API already returns that content.) +- **Skip**: remote symlinks are excluded from the sync list. + +### Push (vault → remote) + +- **Real** + GitHub: a local symlink is committed as a real symlink blob + (mode `120000`) using the Git Data API. +- **Skip**: local symlinks are not pushed. +- **Follow**: the content the link points to is pushed as a normal file. + +#### Safety: Follow never destroys a remote symlink + +A `follow` push reads *through* a local symlink and would otherwise overwrite the +remote with a regular file. To avoid silently converting a remote symlink into an +ordinary file, **if the remote path is detected as a symlink, the push is skipped +with a notice**. Use **Real symlink** (GitHub) if you actually want to manage the +link. + +> Note: remote-symlink detection on push relies on the provider reporting the +> symlink type, which currently only GitHub does. On GitLab/Gitea a `follow` push +> cannot detect a remote symlink and may convert it to a regular file. + +## Error handling + +Earlier versions surfaced confusing errors when a symlinked `.gitignore` (or other +symlink) returned `404` during a refresh. Expected `404`s are now logged at debug +level instead of as errors, and symlinks are detected up front, so a normal +refresh/pull no longer shows spurious "Git Service Request Failed (404)" messages. diff --git a/src/logic/sync-manager.ts b/src/logic/sync-manager.ts index 60061e7..35fb9f8 100644 --- a/src/logic/sync-manager.ts +++ b/src/logic/sync-manager.ts @@ -1,9 +1,10 @@ import { TFile, App, Notice } from 'obsidian'; import { GitServiceInterface } from '../services/git-service-interface'; -import { GitLabFilesPushSettings, getServiceName } from '../settings'; +import { GitLabFilesPushSettings, getServiceName, getEffectiveSymlinkHandling } from '../settings'; import { SyncConflictModal } from '../ui/SyncConflictModal'; import { logger } from '../utils/logger'; import { isBinaryPath, contentsEqual } from '../utils/path'; +import { readLocalSymlinkTarget, createLocalSymlink } from '../utils/symlink'; export class SyncManager { private readonly app: App; @@ -54,8 +55,15 @@ export class SyncManager { return; } - const content = await this.getFileContent(fileOrPath); try { + // Symbolic link handling: real → push as a symlink (GitHub), skip → ignore. + const symlinkTarget = readLocalSymlinkTarget(this.app, path); + if (symlinkTarget !== null && await this.handleSymlinkPush({ path, name }, symlinkTarget)) { + return; + } + + const content = await this.getFileContent(fileOrPath); + // Check if this is a renamed file if (!isString && fileOrPath instanceof TFile) { const renamedFrom = this.detectRename(fileOrPath); @@ -67,7 +75,14 @@ export class SyncManager { // Conflict detection & equality check const remote = await this.gitService.getFile(repoPath, this.settings.branch); - + + // "follow" must not silently convert a remote symlink into a regular + // file. If the remote is a symlink, leave it untouched. + if (remote.isSymlink) { + new Notice(`${name} is a symlink on the remote; not overwriting (use "real" symlink mode to manage links).`); + return; + } + if (remote.sha && this.contentsEqual(content, remote.content)) { await this.updateMetadata(path, remote.sha); new Notice(`${name} is already up to date.`); @@ -84,7 +99,7 @@ export class SyncManager { if (choice === 'local') { await this.performPush({ path, name }, content, remote.sha); } else { - await this.performPull(fileRep, remote.content, remote.sha); + await this.performPull(fileRep, remote.content, remote.sha, false, this.symlinkPullTarget(remote)); } } catch (e) { this.handleError(`Failed to resolve conflict for ${name}`, e); @@ -170,10 +185,37 @@ export class SyncManager { } if (newSha) await this.updateMetadata(file.path, newSha); - + if (!silent) new Notice(`Pushed ${file.name} to ${this.serviceName}`); } + /** + * Handles pushing a local symbolic link per the configured behavior. + * Returns true if the link was handled (pushed as a symlink, or intentionally + * skipped); returns false to let the caller fall through to a normal content + * push ("follow", which reads through the link). + */ + private async handleSymlinkPush(file: {path: string, name: string}, target: string, silent = false): Promise { + const mode = getEffectiveSymlinkHandling(this.settings); + if (mode === 'skip') { + if (!silent) new Notice(`Skipped symlink ${file.name}.`); + return true; + } + if (mode === 'real' && this.gitService.pushSymlink) { + const repoPath = this.getNormalizedPath(file.path); + const result = await this.gitService.pushSymlink(repoPath, target, this.settings.branch, `Update ${file.name} from Obsidian`); + if (result.sha) await this.updateMetadata(file.path, result.sha); + if (!silent) new Notice(`Pushed symlink ${file.name} to ${this.serviceName}`); + return true; + } + return false; + } + + /** The symlink target to recreate on pull, or undefined when the remote isn't a symlink. */ + private symlinkPullTarget(remote: { isSymlink?: boolean; symlinkTarget?: string }): string | undefined { + return remote.isSymlink ? remote.symlinkTarget ?? '' : undefined; + } + async pullFile(fileOrPath: TFile | string) { const { path, name, isString } = this.getFileInfo(fileOrPath); const repoPath = this.getNormalizedPath(path); @@ -205,7 +247,7 @@ export class SyncManager { if (choice === 'local') { await this.performPush(fileRep, localContent || '', remote.sha); } else { - await this.performPull(fileRep, remote.content, remote.sha); + await this.performPull(fileRep, remote.content, remote.sha, false, this.symlinkPullTarget(remote)); } } catch (e) { this.handleError(`Failed to resolve conflict for ${name}`, e); @@ -230,29 +272,38 @@ export class SyncManager { return isBinaryPath(path); } - private async performPull(file: TFile | {path: string, name: string}, remoteContent: string | ArrayBuffer, remoteSha: string, silent = false) { + private async performPull(file: TFile | {path: string, name: string}, remoteContent: string | ArrayBuffer, remoteSha: string, silent = false, symlinkTarget?: string) { await this.ensureParentDirs(file.path); + if (symlinkTarget !== undefined) { + // Remote blob is a symbolic link. Recreate a real OS link when the + // setting is "real" and the platform supports it… + if (getEffectiveSymlinkHandling(this.settings) === 'real' && createLocalSymlink(this.app, file.path, symlinkTarget)) { + await this.updateMetadata(file.path, remoteSha); + if (!silent) new Notice(`Pulled symlink ${file.name} from ${this.serviceName}`); + return; + } + // …otherwise record where it pointed by writing the target as content. + remoteContent = symlinkTarget; + } + + await this.writePulledContent(file, remoteContent); + await this.updateMetadata(file.path, remoteSha); + if (!silent) new Notice(`Pulled ${file.name} from ${this.serviceName}`); + } + + private async writePulledContent(file: TFile | {path: string, name: string}, remoteContent: string | ArrayBuffer): Promise { if (typeof remoteContent !== 'string') { - // remoteContent is ArrayBuffer if (file instanceof TFile) { await this.app.vault.modifyBinary(file, remoteContent); } else { await this.app.vault.adapter.writeBinary(file.path, remoteContent); } + } else if (file instanceof TFile) { + await this.app.vault.modify(file, remoteContent); } else { - // remoteContent is string - if (file instanceof TFile) { - await this.app.vault.modify(file, remoteContent); - } else { - await this.app.vault.adapter.write(file.path, remoteContent); - } + await this.app.vault.adapter.write(file.path, remoteContent); } - - // Update metadata - await this.updateMetadata(file.path, remoteSha); - - if (!silent) new Notice(`Pulled ${file.name} from ${this.serviceName}`); } private async ensureParentDirs(filePath: string): Promise { @@ -372,6 +423,13 @@ export class SyncManager { private async processSingleBatchPush(fileOrPath: TFile | string, path: string, name: string, isString: boolean): Promise { if (!await this.checkFileExists(path, isString)) throw new Error('File no longer exists'); + + // Symbolic link handling: real → push as a symlink (GitHub), skip → ignore. + const symlinkTarget = readLocalSymlinkTarget(this.app, path); + if (symlinkTarget !== null && await this.handleSymlinkPush({ path, name }, symlinkTarget, true)) { + return getEffectiveSymlinkHandling(this.settings) !== 'skip'; + } + const content = await this.getFileContent(fileOrPath); const repoPath = this.getNormalizedPath(path); @@ -385,7 +443,10 @@ export class SyncManager { } const remote = await this.gitService.getFile(repoPath, this.settings.branch); - + + // Don't convert a remote symlink into a regular file. + if (remote.isSymlink) return false; + // Skip if already in sync if (remote.sha && this.contentsEqual(content, remote.content)) { await this.updateMetadata(path, remote.sha); @@ -411,7 +472,7 @@ export class SyncManager { } const fileRep = typeof fileOrPath === 'string' ? { path, name } : fileOrPath; - await this.performPull(fileRep, remote.content, remote.sha, true); + await this.performPull(fileRep, remote.content, remote.sha, true, this.symlinkPullTarget(remote)); return true; } } diff --git a/src/services/git-service-base.ts b/src/services/git-service-base.ts index 62722ce..e90aac7 100644 --- a/src/services/git-service-base.ts +++ b/src/services/git-service-base.ts @@ -5,6 +5,8 @@ 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 diff --git a/src/services/git-service-interface.ts b/src/services/git-service-interface.ts index e4ca517..1b6d388 100644 --- a/src/services/git-service-interface.ts +++ b/src/services/git-service-interface.ts @@ -1,6 +1,10 @@ export interface GitFile { content: string | ArrayBuffer; sha: string; + /** True when the remote blob is a symbolic link (mode 120000). */ + isSymlink?: boolean; + /** The link target path, when isSymlink is true and it could be determined. */ + symlinkTarget?: string; } /** A file entry from the repository tree, with whether it is a symbolic link. */ @@ -17,6 +21,12 @@ export interface GitServiceInterface { listFiles(branch: string, useFilter?: boolean): Promise; /** Like listFiles but also reports which entries are symbolic links (mode 120000). */ listFilesDetailed(branch: string, useFilter?: boolean): Promise; + /** + * Push a file as a symbolic link (Git blob mode 120000) whose content is the + * target path. Optional: only providers with a full Git Data API (GitHub) + * implement it; callers must fall back to pushFile when it's absent. + */ + pushSymlink?(path: string, target: string, branch: string, commitMessage: string): Promise<{ path: string, sha?: string }>; deleteFile(path: string, branch: string, commitMessage: string): Promise; getRepoGitignores(branch: string): Promise; } diff --git a/src/services/github-service.ts b/src/services/github-service.ts index 416408a..c3f0a5d 100644 --- a/src/services/github-service.ts +++ b/src/services/github-service.ts @@ -26,8 +26,15 @@ export class GitHubService extends BaseGitService implements GitServiceInterface try { const url = `${this.getApiUrl(path)}?ref=${branch}`; const response = await this.safeRequest(url, 'GET'); - const data = this.parseJson(response); - + const data = this.parseJson(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 @@ -54,6 +61,40 @@ export class GitHubService extends BaseGitService implements GitServiceInterface 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 { const url = `https://api.github.com/repos/${this.owner}/${this.repo}/git/trees/${branch}?recursive=1`; const response = await this.safeRequest(url, 'GET'); diff --git a/src/settings.ts b/src/settings.ts index 0fdcae1..d0f3233 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -43,6 +43,19 @@ export function getServiceName(settings: GitLabFilesPushSettings): string { return 'GitHub'; } +/** + * Resolves the symlink behavior that actually applies. Only GitHub can create or + * push real symlinks (it has the Git Data API); on other providers "real" is not + * possible, so it is treated as "skip" to avoid silently turning links into + * ordinary files. + */ +export function getEffectiveSymlinkHandling(settings: GitLabFilesPushSettings): SymlinkHandling { + if (settings.symlinkHandling === 'real' && settings.serviceType !== 'github') { + return 'skip'; + } + return settings.symlinkHandling; +} + export const DEFAULT_SETTINGS: GitLabFilesPushSettings = { serviceType: 'gitlab', gitlabToken: '', @@ -134,18 +147,25 @@ export class GitLabSyncSettingTab extends PluginSettingTab { void this.plugin.saveSettings(); })); + // "Real symlink" needs the Git Data API, which only GitHub offers. For + // other providers, offer follow/skip only so the option can't mislead. + const supportsRealSymlink = this.plugin.settings.serviceType === 'github'; new Setting(containerEl) .setName('Symbolic links') - .setDesc('How to sync symlinks: "real" recreates the link on desktop and falls back to the target content on mobile, "follow" always syncs the target content, and "skip" ignores symlinks.') - .addDropdown(dropdown => dropdown - .addOption('real', 'Real symlink (recommended)') - .addOption('follow', 'Follow (sync target content)') - .addOption('skip', 'Skip') - .setValue(this.plugin.settings.symlinkHandling) - .onChange((value: string) => { - this.plugin.settings.symlinkHandling = value as SymlinkHandling; - void this.plugin.saveSettings(); - })); + .setDesc(supportsRealSymlink + ? 'How to sync symlinks: "real" recreates the link on desktop and falls back to the target content on mobile, "follow" always syncs the target content, and "skip" ignores symlinks.' + : 'How to sync symlinks: "follow" syncs the target content as a normal file, "skip" ignores symlinks. Real symlinks require GitHub.') + .addDropdown(dropdown => { + if (supportsRealSymlink) dropdown.addOption('real', 'Real symlink (recommended)'); + dropdown + .addOption('follow', 'Follow (sync target content)') + .addOption('skip', 'Skip') + .setValue(getEffectiveSymlinkHandling(this.plugin.settings)) + .onChange((value: string) => { + this.plugin.settings.symlinkHandling = value as SymlinkHandling; + void this.plugin.saveSettings(); + }); + }); new Setting(containerEl) .setName('Test connection') diff --git a/src/ui/SyncStatusView.ts b/src/ui/SyncStatusView.ts index 3311423..bd04c81 100644 --- a/src/ui/SyncStatusView.ts +++ b/src/ui/SyncStatusView.ts @@ -1,6 +1,6 @@ import { ItemView, WorkspaceLeaf, TFile, Notice, Platform, setIcon, setTooltip } from 'obsidian'; import GitLabFilesPush from '../main'; -import { getServiceName } from '../settings'; +import { getServiceName, getEffectiveSymlinkHandling } from '../settings'; import { ConfirmModal } from './ConfirmModal'; import { logger } from '../utils/logger'; import { type FileStatus, type FilterValue } from './types'; @@ -309,7 +309,7 @@ export class SyncStatusView extends ItemView { // Map remote paths to vault paths const remoteMap = new Map(); // vaultPath -> remoteFullPath - const skipSymlinks = this.plugin.settings.symlinkHandling === 'skip'; + const skipSymlinks = getEffectiveSymlinkHandling(this.plugin.settings) === 'skip'; for (const entry of remoteEntries) { if (entry.symlink && skipSymlinks) continue; // Symlink handling: skip const normalized = this.getNormalizedRemotePath(entry.path); diff --git a/src/utils/symlink.ts b/src/utils/symlink.ts new file mode 100644 index 0000000..a436fe5 --- /dev/null +++ b/src/utils/symlink.ts @@ -0,0 +1,105 @@ +import { App, FileSystemAdapter, Platform } from 'obsidian'; +import { logger } from './logger'; + +/** + * Desktop-only helpers for real OS symbolic links. + * + * Obsidian exposes no symlink API, so on desktop (Electron) we reach for Node's + * `fs` via the global CommonJS `require`. None of this is available on mobile, + * so every entry point is guarded by `canUseRealSymlinks()` and callers must + * fall back to content-based syncing when it returns false. + */ + +// Minimal shapes of the Node APIs we use, so we don't statically import the +// builtins (they don't exist in the mobile bundle). +interface NodeFs { + lstatSync(p: string): { isSymbolicLink(): boolean }; + readlinkSync(p: string): string; + existsSync(p: string): boolean; + mkdirSync(p: string, opts: { recursive: boolean }): void; + rmSync(p: string, opts: { force: boolean }): void; + symlinkSync(target: string, p: string): void; +} + +interface NodePath { + join(...parts: string[]): string; + dirname(p: string): string; +} + +type NodeRequire = (id: string) => unknown; + +export function canUseRealSymlinks(app: App): boolean { + return typeof Platform !== 'undefined' && Platform.isDesktopApp + && typeof FileSystemAdapter === 'function' && app.vault.adapter instanceof FileSystemAdapter; +} + +// Electron exposes a CommonJS `require` on the global object on desktop; it is +// absent on mobile. Resolving it dynamically avoids a static node import. +function nodeModules(): { fs: NodeFs; path: NodePath } | null { + const req = (globalThis as unknown as { require?: NodeRequire }).require; + if (typeof req !== 'function') return null; + try { + return { fs: req('fs') as NodeFs, path: req('path') as NodePath }; + } catch { + return null; + } +} + +function absolutePath(app: App, vaultPath: string, path: NodePath): string | null { + const adapter = app.vault.adapter; + if (!(adapter instanceof FileSystemAdapter)) return null; + return path.join(adapter.getBasePath(), vaultPath); +} + +/** + * Returns the raw target of a local symlink, or null if the path is not a + * symlink (or real symlinks aren't supported on this platform). + */ +export function readLocalSymlinkTarget(app: App, vaultPath: string): string | null { + if (!canUseRealSymlinks(app)) return null; + const mods = nodeModules(); + if (!mods) return null; + try { + const abs = absolutePath(app, vaultPath, mods.path); + if (!abs) return null; + if (!mods.fs.lstatSync(abs).isSymbolicLink()) return null; + return mods.fs.readlinkSync(abs); + } catch (e) { + logger.warn(`Failed to read local symlink ${vaultPath}`, e); + return null; + } +} + +/** + * Creates (or replaces) a real OS symlink at vaultPath pointing to target. + * Returns true on success, false if it couldn't be created (caller should then + * fall back to writing the target content as a normal file). + */ +export function createLocalSymlink(app: App, vaultPath: string, target: string): boolean { + if (!canUseRealSymlinks(app)) return false; + const mods = nodeModules(); + if (!mods) return false; + try { + const abs = absolutePath(app, vaultPath, mods.path); + if (!abs) return false; + mods.fs.mkdirSync(mods.path.dirname(abs), { recursive: true }); + // Replace whatever is there (a stale file or an outdated link). existsSync + // follows links, so check lstat too in case of a dangling link. + if (mods.fs.existsSync(abs) || isSymlink(mods.fs, abs)) { + mods.fs.rmSync(abs, { force: true }); + } + mods.fs.symlinkSync(target, abs); + return true; + } catch (e) { + logger.warn(`Failed to create local symlink ${vaultPath} -> ${target}`, e); + return false; + } +} + +function isSymlink(fs: NodeFs, abs: string): boolean { + try { + return fs.lstatSync(abs).isSymbolicLink(); + } catch { + return false; + } +} diff --git a/tests/logic/sync-manager.test.ts b/tests/logic/sync-manager.test.ts index 58fd657..16f8e9c 100644 --- a/tests/logic/sync-manager.test.ts +++ b/tests/logic/sync-manager.test.ts @@ -113,6 +113,18 @@ describe('SyncManager', () => { ); }); + it('does not overwrite a remote symlink on push (follow mode safety)', async () => { + const mockFile = Object.assign(new TFile(), { path: 'link.md', name: 'link.md' }); + vi.spyOn(mockApp.vault, 'read').mockResolvedValue('local content'); + vi.spyOn(mockGitLab, 'getFile').mockResolvedValue({ content: '', sha: 'link-sha', isSymlink: true, symlinkTarget: '../x.md' }); + const pushSpy = vi.spyOn(mockGitLab, 'pushFile').mockResolvedValue({ path: 'link.md', sha: 'new' }); + + await manager.pushFile(mockFile); + + // The remote symlink must be left untouched. + expect(pushSpy).not.toHaveBeenCalled(); + }); + it('should detect conflict when remote SHA differs from last synced SHA', async () => { const mockFile = Object.assign(new TFile(), { path: 'test.md', name: 'test.md' }); diff --git a/tests/services/github-service.test.ts b/tests/services/github-service.test.ts index 8fc1c44..33ed214 100644 --- a/tests/services/github-service.test.ts +++ b/tests/services/github-service.test.ts @@ -55,6 +55,49 @@ describe('GitHubService', () => { }); }); + describe('getFile symlink detection', () => { + it('flags a symlink response and returns its target', async () => { + mockRequest({ status: 200, json: { type: 'symlink', target: '../shared/note.md', sha: 'link-sha' } }); + const result = await service.getFile('link.md', 'main'); + expect(result).toEqual({ content: '', sha: 'link-sha', isSymlink: true, symlinkTarget: '../shared/note.md' }); + }); + + it('treats a normal file response as non-symlink', async () => { + mockRequest({ status: 200, json: { content: btoa('hello'), sha: 'sha', type: 'file' } }); + const result = await service.getFile('note.md', 'main'); + expect(result.isSymlink).toBeUndefined(); + expect(result.content).toBe('hello'); + }); + }); + + describe('pushSymlink (Git Data API)', () => { + it('creates a blob, tree (mode 120000), commit, and moves the ref', async () => { + vi.mocked(requestUrl) + .mockResolvedValueOnce({ status: 200, json: { object: { sha: 'commit1' } } } as unknown as RequestUrlResponse) // get ref + .mockResolvedValueOnce({ status: 200, json: { tree: { sha: 'tree1' } } } as unknown as RequestUrlResponse) // get commit + .mockResolvedValueOnce({ status: 201, json: { sha: 'blob1' } } as unknown as RequestUrlResponse) // create blob + .mockResolvedValueOnce({ status: 201, json: { sha: 'tree2' } } as unknown as RequestUrlResponse) // create tree + .mockResolvedValueOnce({ status: 201, json: { sha: 'commit2' } } as unknown as RequestUrlResponse) // create commit + .mockResolvedValueOnce({ status: 200, json: {} } as unknown as RequestUrlResponse); // update ref + + const result = await service.pushSymlink('link.md', '../target.md', 'main', 'add link'); + + expect(result).toEqual({ path: 'link.md', sha: 'blob1' }); + const calls = vi.mocked(requestUrl).mock.calls.map(c => c[0] as RequestUrlParam); + expect(calls).toHaveLength(6); + // blob carries the target as utf-8 + const blobBody = JSON.parse(calls[2]?.body as string) as { content: string; encoding: string }; + expect(blobBody).toEqual({ content: '../target.md', encoding: 'utf-8' }); + // tree entry uses symlink mode 120000 + const treeBody = JSON.parse(calls[3]?.body as string) as { base_tree: string; tree: Array<{ path: string; mode: string; type: string; sha: string }> }; + expect(treeBody.base_tree).toBe('tree1'); + expect(treeBody.tree[0]).toEqual({ path: 'link.md', mode: '120000', type: 'blob', sha: 'blob1' }); + // ref update points at the new commit + expect(calls[5]?.method).toBe('PATCH'); + expect(JSON.parse(calls[5]?.body as string)).toEqual({ sha: 'commit2' }); + }); + }); + describe('pushFile', () => { it('should push new file correctly (no sha provided)', async () => { vi.mocked(requestUrl).mockResolvedValueOnce({