mirror of
https://github.com/firstsun-dev/git-files-sync.git
synced 2026-07-22 06:54:27 +00:00
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:
parent
13d802e33e
commit
9bcaed65f4
11 changed files with 419 additions and 35 deletions
|
|
@ -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
89
docs/symlink-handling.md
Normal 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.
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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[]>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
105
src/utils/symlink.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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' });
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue