feat(sync): real symbolic link support (GitHub) with configurable handling

Adds end-to-end symlink syncing driven by the "Symbolic links" setting
(real / follow / skip; default real), building on the earlier detection.

- Pull: on desktop with "real", a remote symlink is recreated as a real OS
  link via Node fs (utils/symlink.ts, guarded by Platform/FileSystemAdapter);
  otherwise the target path is written as content.
- Push: GitHubService.pushSymlink commits a real symlink blob (mode 120000)
  through the Git Data API (blob -> tree -> commit -> ref). getFile now
  reports isSymlink/symlinkTarget.
- Config 防呆: only GitHub offers "real"; on GitLab/Gitea (no API to create
  symlinks) "real" resolves to "skip" via getEffectiveSymlinkHandling.
- Safety: a "follow" push never overwrites a detected remote symlink with a
  regular file; it is skipped with a notice.
- Docs: docs/symlink-handling.md plus a README settings note.

Lint is satisfied without disables (Electron global require, minimal Node
type shims). Adds tests for getFile detection, the pushSymlink Git Data
sequence, and the remote-symlink push guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DwioG4CNKUBuKiZdowLFWe
This commit is contained in:
Claude 2026-06-28 05:14:35 +00:00
parent 13d802e33e
commit 9bcaed65f4
No known key found for this signature in database
11 changed files with 419 additions and 35 deletions

View file

@ -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).
---

89
docs/symlink-handling.md Normal file
View file

@ -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.

View file

@ -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<boolean> {
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<void> {
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<void> {
@ -372,6 +423,13 @@ export class SyncManager {
private async processSingleBatchPush(fileOrPath: TFile | string, path: string, name: string, isString: boolean): Promise<boolean> {
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;
}
}

View file

@ -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

View file

@ -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<string[]>;
/** Like listFiles but also reports which entries are symbolic links (mode 120000). */
listFilesDetailed(branch: string, useFilter?: boolean): Promise<GitTreeEntry[]>;
/**
* 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<void>;
getRepoGitignores(branch: string): Promise<string[]>;
}

View file

@ -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<GitHubContentResponse>(response);
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
@ -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<GitTreeEntry[]> {
const url = `https://api.github.com/repos/${this.owner}/${this.repo}/git/trees/${branch}?recursive=1`;
const response = await this.safeRequest(url, 'GET');

View file

@ -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')

View file

@ -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<string, string>(); // 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);

105
src/utils/symlink.ts Normal file
View file

@ -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;
}
}

View file

@ -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' });

View file

@ -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({