Merge pull request #43 from firstsun-dev/claude/git-files-sync-issue-31-54izdm

Gitea support, symlink handling, sync reliability fixes, and dependency security updates
This commit is contained in:
ClaudiaFang 2026-07-05 15:19:32 +08:00 committed by GitHub
commit a20d30844b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 4705 additions and 1767 deletions

View file

@ -0,0 +1,94 @@
---
name: clean-code
description: Apply Robert C. Martin's Clean Code principles (naming, functions, comments, formatting, error handling, tests, classes, code smells). Use when writing new code, reviewing pull requests, refactoring legacy code, or aligning on team coding standards.
---
# Clean Code Skill
## 🧠 Core Philosophy
> "Code is clean if it can be read, and enhanced by a developer other than its original author." — Grady Booch
## When to Use
Use this skill when:
- **Writing new code**: To ensure high quality from the start.
- **Reviewing Pull Requests**: To provide constructive, principle-based feedback.
- **Refactoring legacy code**: To identify and remove code smells.
- **Improving team standards**: To align on industry-standard best practices.
## 1. Meaningful Names
- **Use Intention-Revealing Names**: `elapsedTimeInDays` instead of `d`.
- **Avoid Disinformation**: Don't use `accountList` if it's actually a `Map`.
- **Make Meaningful Distinctions**: Avoid `ProductData` vs `ProductInfo`.
- **Use Pronounceable/Searchable Names**: Avoid `genymdhms`.
- **Class Names**: Use nouns (`Customer`, `WikiPage`). Avoid `Manager`, `Data`.
- **Method Names**: Use verbs (`postPayment`, `deletePage`).
## 2. Functions
- **Small!**: Functions should be shorter than you think.
- **Do One Thing**: A function should do only one thing, and do it well.
- **One Level of Abstraction**: Don't mix high-level business logic with low-level details (like regex).
- **Descriptive Names**: `isPasswordValid` is better than `check`.
- **Arguments**: 0 is ideal, 1-2 is okay, 3+ requires a very strong justification.
- **No Side Effects**: Functions shouldn't secretly change global state.
## 3. Comments
- **Don't Comment Bad Code—Rewrite It**: Most comments are a sign of failure to express ourselves in code.
- **Explain Yourself in Code**:
```python
# Check if employee is eligible for full benefits
if employee.flags & HOURLY and employee.age > 65:
```
vs
```python
if employee.isEligibleForFullBenefits():
```
- **Good Comments**: Legal, Informative (regex intent), Clarification (external libraries), TODOs.
- **Bad Comments**: Mumbling, Redundant, Misleading, Mandated, Noise, Position Markers.
## 4. Formatting
- **The Newspaper Metaphor**: High-level concepts at the top, details at the bottom.
- **Vertical Density**: Related lines should be close to each other.
- **Distance**: Variables should be declared near their usage.
- **Indentation**: Essential for structural readability.
## 5. Objects and Data Structures
- **Data Abstraction**: Hide the implementation behind interfaces.
- **The Law of Demeter**: A module should not know about the innards of the objects it manipulates. Avoid `a.getB().getC().doSomething()`.
- **Data Transfer Objects (DTO)**: Classes with public variables and no functions.
## 6. Error Handling
- **Use Exceptions instead of Return Codes**: Keeps logic clean.
- **Write Try-Catch-Finally First**: Defines the scope of the operation.
- **Don't Return Null**: It forces the caller to check for null every time.
- **Don't Pass Null**: Leads to `NullPointerException`.
## 7. Unit Tests
- **The Three Laws of TDD**:
1. Don't write production code until you have a failing unit test.
2. Don't write more of a unit test than is sufficient to fail.
3. Don't write more production code than is sufficient to pass the failing test.
- **F.I.R.S.T. Principles**: Fast, Independent, Repeatable, Self-Validating, Timely.
## 8. Classes
- **Small!**: Classes should have a single responsibility (SRP).
- **The Stepdown Rule**: We want the code to read like a top-down narrative.
## 9. Smells and Heuristics
- **Rigidity**: Hard to change.
- **Fragility**: Breaks in many places.
- **Immobility**: Hard to reuse.
- **Viscosity**: Hard to do the right thing.
- **Needless Complexity/Repetition**.
## 🛠️ Implementation Checklist
- [ ] Is this function smaller than 20 lines?
- [ ] Does this function do exactly one thing?
- [ ] Are all names searchable and intention-revealing?
- [ ] Have I avoided comments by making the code clearer?
- [ ] Am I passing too many arguments?
- [ ] Is there a failing test for this change?
## Limitations
- Use this skill only when the task clearly matches the scope described above.
- Do not treat the output as a substitute for environment-specific validation, testing, or expert review.
- Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

View file

@ -0,0 +1,55 @@
---
name: frontend-design
description: Guidance for distinctive, intentional visual design when building new UI or reshaping an existing one. Helps with aesthetic direction, typography, and making choices that don't read as templated defaults.
license: Complete terms in LICENSE.txt
---
# Frontend Design
Approach this as the design lead at a small studio known for giving every client a visual identity that could not be mistaken for anyone else's. This client has already rejected proposals that felt templated, and is paying for a distinctive point of view: make deliberate, opinionated choices about palette, typography, and layout that are specific to this brief, and take one real aesthetic risk you can justify.
## Ground it in the subject
If the brief does not pin down what the product or subject is, pin it yourself before designing: name one concrete subject, its audience, and the page's single job, and state your choice. If there's any information in your memory about the human's preferences, context about what they're building, or designs you've made before use that as a hint. The subject's own world, its materials, instruments, artifacts, and vernacular, is where distinctive choices come from. Build with the brief's real content and subject matter throughout.
## Design principles
For web designs, the hero is a thesis. Open with the most characteristic thing in the subject's world, in whatever form makes sense for it: a headline, an image, an animation, a live demo, an interactive moment. Be deliberate with your choice: a big number with a small label, supporting stats, and a gradient accent is the template answer, only use if that's truly the best option.
Typography carries the personality of the page. Pair the display and body faces deliberately, not the same families you would reach for on any other project, and set a clear type scale with intentional weights, widths, and spacing. Make the type treatment itself a memorable part of the design, not a neutral delivery vehicle for the content.
Structure is information. Structural devices, numbering, eyebrows, dividers, labels, should encode something true about the content, not decorate it. Many generic designs use numbered markers (01 / 02 / 03), but that's only appropriate if the content actually is a sequence - like a real process or a typed timeline where order carries information the reader needs. Question if choices like numbered markers actually make sense before incorporating them.
Leverage motion deliberately. Think about where and if animation can serve the subject: a page-load sequence, a scroll-triggered reveal, hover micro-interactions, ambient atmosphere. An orchestrated moment usually lands harder than scattered effects; choose what the direction calls for. However, sometimes less is more, and extra animation contributes to the feeling that the design is AI-generated.
Match complexity to the vision. Maximalist directions need elaborate execution; minimal directions need precision in spacing, type, and detail. Elegance is executing the chosen vision well.
Consider written content carefully. Often a design brief may not contain real content, and it's up to you to come up with copy. Copy can make a design feel as templated as the design itself. See the below section on writing for more guidance.
## Process: brainstorm, explore, plan, critique, build, critique again
For calibration: AI-generated design right now clusters around three looks: (1) a warm cream background (near #F4F1EA) with a high-contrast serif display and a terracotta accent; (2) a near-black background with a single bright acid-green or vermilion accent; (3) a broadsheet-style layout with hairline rules, zero border-radius, and dense newspaper-like columns. All three are legitimate for some briefs, but they are defaults rather than choices, and they appear regardless of subject. Where the brief pins down a visual direction, follow it exactly — the brief's own words always win, including when it asks for one of these looks. Where it leaves an axis free, don't spend that freedom on one of these defaults. Just like a human designer who's hired, there's often a careful balance between doing what you're good at and taking each project as a chance to experiment and learn.
Work in two passes. First, brainstorm a short design plan based on the human's design brief: create a compact token system with color, type, layout, and signature. Color: describe the palette as 46 named hex values. Type: the typefaces for 2+ roles (a characterful display face that's used with restraint, a complementary body face, and a utility face for captions or data if needed). Layout: a layout concept, using one-sentence prose descriptions and ASCII wireframes to ideate and compare. Signature: the single unique element this page will be remembered by that embodies the brief in an appropriate way.
Then review that plan against the brief before building: if any part of it reads like the generic default you would produce for any similar page (work through a similar prompt to see if you arrive somewhere similar) rather than a choice made for this specific brief — revise that part, say what you changed and why. Only after you've confirmed the relative uniqueness of your design plan should you start to write the code, following the revised plan exactly and deriving every color and type decision from it.
When writing the code, be careful of structuring your CSS selector specificities. It's easy to generate CSS classes that cancel each other out (especially with a type-based selector like .section and a element-based selector like .cta). This can happen often with paddings/margins between sections.
Try to do a lot of this planning and iteration in your thinking, and only show ideas to the user when you have higher confidence it'll delight them.
## Restraint and self-critique
Spend your boldness in one place. Let the signature element be the one memorable thing, keep everything around it quiet and disciplined, and cut any decoration that does not serve the brief. Not taking a risk can be a risk itself! Build to a quality floor without announcing it: responsive down to mobile, visible keyboard focus, reduced motion respected. Critique your own work as you build, taking screenshots if your environment supports it a picture is worth 1000 tokens. Consider Chanel's advice: before leaving the house, take a look in the mirror and remove one accessory. Human creators have memory and always try to do something new, so if you have a space to quickly jot down notes about what you've tried, it can help you in future passes.
## More on writing in design
Words appear in a design for one reason: to make it easier to understand, and therefore easier to use. They are design material, not decoration. Bring the same intentionality to copy that you would bring to spacing and color. Before writing anything, ask what the design needs to say, and how it can best be said to help the person navigate the experience.
Write from the end user's side of the screen. Name things by what people control and recognize, never by how the system is built. A person manages notifications, not webhook config. Describe what something does in plain terms rather than selling it. Being specific is always better than being clever.
Use active voice as default. A control should say exactly what happens when it's used: "Save changes," not "Submit." An action keeps the same name through the whole flow, so the button that says "Publish" produces a toast that says "Published." The vocabulary of an interface is the signposting for someone navigating the product. Cohesion and consistency are how people learn their way around.
Treat failure and emptiness as moments for direction, not mood. Explain what went wrong and how to fix it, in the interface's voice rather than a person's. Errors don't apologize, and they are never vague about what happened. An empty screen is an invitation to act.
Keep the register conversational and tuned: plain verbs, sentence case, no filler, with tone matched to the brand and the audience. Let each element do exactly one job. A label labels, an example demonstrates, and nothing quietly does double duty.

View file

@ -18,7 +18,37 @@ jobs:
uses: firstsun-dev/.github/.github/workflows/obsidian-plugin-ci.yml@v1
with:
plugin-id: "git-file-sync"
skip-sonar: ${{ vars.ENABLE_CI_SONAR != 'true' }}
secrets:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
build-artifact:
name: Upload build artifact
runs-on: ubuntu-latest
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/master'
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- run: npm run build
- name: Set artifact name
id: artifact
run: |
BRANCH=$(echo "${{ github.ref_name }}" | tr '/' '-')
SHA=$(echo "${{ github.sha }}" | cut -c1-7)
echo "name=plugin-${BRANCH}-${SHA}" >> $GITHUB_OUTPUT
- uses: actions/upload-artifact@v5
with:
name: ${{ steps.artifact.outputs.name }}
path: |
main.js
manifest.json
styles.css
retention-days: 7

View file

@ -24,19 +24,19 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
# Queries can be 'security-extended' or 'security-and-quality'
queries: security-extended
- name: Auto-build
uses: github/codeql-action/autobuild@v3
uses: github/codeql-action/autobuild@v4
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{matrix.language}}"

View file

@ -8,7 +8,12 @@
[![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg?style=flat-square)](https://conventionalcommits.org)
[![License](https://img.shields.io/github/license/firstsun-dev/git-files-sync?style=flat-square)](LICENSE)
**Git File Sync** is a powerful Obsidian plugin that enables seamless synchronization of individual notes with GitLab or GitHub repositories. Unlike full-vault sync solutions, it gives you granular control over what gets pushed and pulled, making it perfect for shared projects, selective backups, and cross-platform workflows.
**Supports:**
<img src="https://img.shields.io/badge/GitHub-181717?style=flat-square&logo=github&logoColor=white" alt="GitHub" height="20">
<img src="https://img.shields.io/badge/GitLab-FC6D26?style=flat-square&logo=gitlab&logoColor=white" alt="GitLab" height="20">
<img src="https://img.shields.io/badge/Gitea-609926?style=flat-square&logo=gitea&logoColor=white" alt="Gitea" height="20">
**Git File Sync** is a powerful Obsidian plugin that enables seamless synchronization of individual notes with GitLab, GitHub, or self-hosted Gitea repositories. Unlike full-vault sync solutions, it gives you granular control over what gets pushed and pulled, making it perfect for shared projects, selective backups, and cross-platform workflows.
[繁體中文使用說明](USAGE_zh.md)
@ -22,6 +27,22 @@
---
## Supported Providers
<img src="https://img.shields.io/badge/GitHub-181717?style=for-the-badge&logo=github&logoColor=white" alt="GitHub" height="28"> &nbsp;
<img src="https://img.shields.io/badge/GitLab-FC6D26?style=for-the-badge&logo=gitlab&logoColor=white" alt="GitLab" height="28"> &nbsp;
<img src="https://img.shields.io/badge/Gitea-609926?style=for-the-badge&logo=gitea&logoColor=white" alt="Gitea" height="28">
| Provider | Hosting | Min. Version |
| :--- | :--- | :--- |
| <img src="https://img.shields.io/badge/GitHub-181717?style=flat-square&logo=github&logoColor=white" alt="GitHub"> | github.com · GitHub Enterprise | — |
| <img src="https://img.shields.io/badge/GitLab-FC6D26?style=flat-square&logo=gitlab&logoColor=white" alt="GitLab"> | gitlab.com · self-hosted | GitLab 13.0+ |
| <img src="https://img.shields.io/badge/Gitea-609926?style=flat-square&logo=gitea&logoColor=white" alt="Gitea"> | self-hosted | Gitea 1.12+ |
> **Gitea compatibility note**: The plugin uses the Gitea API v1 (`/api/v1`). It resolves branch names to commit SHAs before fetching the file tree, which ensures compatibility with Gitea 1.12 and later. Versions before 1.12 are not supported.
---
## Key Features
### Selective Synchronization
@ -31,7 +52,7 @@ Don't sync your whole vault. Selectively push or pull individual notes, or use b
A comprehensive dashboard provides a bird's-eye view of your vault's status:
- **Status Filtering**: Instantly see what's modified, new, or missing.
- **Visual Diffs**: Compare local and remote changes line-by-line before syncing.
- **Remote-Only Detection**: Identify files existing on GitLab/GitHub that aren't in your vault yet.
- **Remote-Only Detection**: Identify files existing on GitLab/GitHub/Gitea that aren't in your vault yet.
### Intelligent Conflict Resolution
When versions clash, Git File Sync provides a dedicated diff viewer to help you resolve conflicts manually. Choose the local version, the remote version, or merge them with confidence.
@ -62,18 +83,27 @@ Full support for Obsidian Mobile. Push and pull your notes on the go with a resp
*Configure the plugin by selecting your preferred Git service and providing the necessary credentials.*
### 1. Choose Your Service
Go to **Settings** > **Git File Sync** and select either **GitLab** or **GitHub**.
Go to **Settings** > **Git File Sync** and select **GitLab**, **GitHub**, or **Gitea** from the dropdown.
### 2. Provider Setup
| Service | Required Info | Scope Needed |
| :--- | :--- | :--- |
| **GitLab** | Personal Access Token, Project ID, Base URL | `api` |
| **GitHub** | Personal Access Token, Owner, Repo Name | `repo` |
| | Provider | Required Info | Token Scope |
| :---: | :--- | :--- | :--- |
| <img src="https://img.shields.io/badge/GitHub-181717?style=flat-square&logo=github&logoColor=white" alt="GitHub"> | **GitHub** | Personal Access Token, Owner, Repo Name | `repo` |
| <img src="https://img.shields.io/badge/GitLab-FC6D26?style=flat-square&logo=gitlab&logoColor=white" alt="GitLab"> | **GitLab** | Personal Access Token, Project ID, Base URL | `api` |
| <img src="https://img.shields.io/badge/Gitea-609926?style=flat-square&logo=gitea&logoColor=white" alt="Gitea"> | **Gitea** | Personal Access Token, Base URL, Owner, Repo Name | (all) |
**GitHub token**: Settings → Developer settings → Personal access tokens → `repo` scope.
**GitLab token**: User settings → Access tokens → `api` scope. The Base URL defaults to `https://gitlab.com`; change it for self-hosted instances.
**Gitea token**: User settings → Applications → Access tokens. Set the Base URL to your Gitea instance (e.g. `https://gitea.example.com`).
### 3. Common Settings
- **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).
---
@ -87,9 +117,9 @@ Once configured, you should perform an initial status check:
### Daily Workflow: Pushing Changes
When you finish editing a note and want to save it to Git:
- **Current Note**: Use the cloud icon in the ribbon or the command `Push current file to GitLab/GitHub`.
- **Current Note**: Use the cloud icon in the ribbon or the command `Push current file to GitLab/GitHub/Gitea`.
- **Multiple Notes**: Open the Sync Status View, use the **Modified** filter, select the files you want to sync, and click **Push selected**.
- **Context Menu**: Right-click any file in the File Explorer and select `Push to GitLab/GitHub`.
- **Context Menu**: Right-click any file in the File Explorer and select `Push to GitLab/GitHub/Gitea`.
### Daily Workflow: Pulling Changes
To get the latest updates from other devices:
@ -115,7 +145,7 @@ On mobile devices:
## Privacy and Security
- **Local Storage**: Your Personal Access Tokens (PAT) are stored locally in the plugin's data folder within your vault. They are never sent to any server other than GitLab/GitHub.
- **Local Storage**: Your Personal Access Tokens (PAT) are stored locally in the plugin's data folder within your vault. They are never sent to any server other than your configured Git provider.
- **No Telemetry**: This plugin does not collect any data or usage analytics.
---

View file

@ -2,7 +2,21 @@
<video src="imgs/git-file-sync-zh.webm" width="100%" controls autoplay loop muted playsinline></video>
本指南將引導您如何使用 Git File Sync 插件,在行動裝置與桌面電腦之間,透過 GitLab 或 GitHub 輕鬆同步您的筆記。
本指南將引導您如何使用 Git File Sync 插件,在行動裝置與桌面電腦之間,透過 GitLab、GitHub 或自架的 Gitea 輕鬆同步您的筆記。
---
## 支援的 Git 服務
<img src="https://img.shields.io/badge/GitHub-181717?style=for-the-badge&logo=github&logoColor=white" alt="GitHub" height="28"> &nbsp;
<img src="https://img.shields.io/badge/GitLab-FC6D26?style=for-the-badge&logo=gitlab&logoColor=white" alt="GitLab" height="28"> &nbsp;
<img src="https://img.shields.io/badge/Gitea-609926?style=for-the-badge&logo=gitea&logoColor=white" alt="Gitea" height="28">
| | 服務 | 適用情境 | 最低版本 |
| :---: | :--- | :--- | :--- |
| <img src="https://img.shields.io/badge/GitHub-181717?style=flat-square&logo=github&logoColor=white" alt="GitHub"> | **GitHub** | 公開 / 私人儲存庫 | — |
| <img src="https://img.shields.io/badge/GitLab-FC6D26?style=flat-square&logo=gitlab&logoColor=white" alt="GitLab"> | **GitLab** | gitlab.com 或自架 | GitLab 13.0+ |
| <img src="https://img.shields.io/badge/Gitea-609926?style=flat-square&logo=gitea&logoColor=white" alt="Gitea"> | **Gitea** | 自架 Git 伺服器 | Gitea 1.12+ |
---
@ -13,10 +27,11 @@
![Plugin Settings](imgs/plugin-settings.png)
*在設定面板選擇您的 Git 服務並填入對應的憑證與路徑。*
1. **選擇服務**:在 `設定` > `Git File Sync` 中選擇 GitLab 或 GitHub
1. **選擇服務**:在 `設定` > `Git File Sync` 中選擇 GitLab、GitHub 或 Gitea
2. **填寫憑證**
- **GitHub**:需要 個人存取權杖 (PAT)、帳號名稱、儲存庫名稱。
- **GitLab**:需要 個人存取權杖 (PAT)、專案 ID、伺服器網址 (預設為 gitlab.com)。
- **GitHub**:需要 個人存取權杖 (PAT)、帳號名稱、儲存庫名稱。權杖需具備 `repo` 權限。
- **GitLab**:需要 個人存取權杖 (PAT)、專案 ID、伺服器網址預設為 `https://gitlab.com`,自架請改為您的網址)。權杖需具備 `api` 權限。
- **Gitea**:需要 個人存取權杖 (PAT)、伺服器網址(例如 `https://gitea.example.com`)、帳號名稱、儲存庫名稱。權杖在 `使用者設定` > `應用程式` > `存取權杖` 中建立。
3. **儲存庫路徑**:如果您想將筆記存放在儲存庫的特定資料夾(例如 `notes/`),請在 `Root Path` 中設定。
---
@ -41,7 +56,7 @@
當您寫完筆記,想備份到雲端時:
- **單一檔案**
- 點擊左側功能列的 **雲端上傳圖示**
- 或者在檔案列表點擊右鍵,選擇 `Push to GitLab/GitHub`。
- 或者在檔案列表點擊右鍵,選擇 `Push to GitLab/GitHub/Gitea`。
- **批量上傳**
- 在同步面板勾選多個檔案,點擊下方的 **Push selected**
@ -50,7 +65,7 @@
### ⬇️ 如何下載Pull
當您在另一台裝置更新了筆記,想同步回目前裝置時:
1. 打開同步面板,點擊 **Refresh status**
2. 找到顯示為 **Remote only****Modified** (雲端版本較新) 的檔案。
2. 找到顯示為 **Remote only****Modified**(雲端版本較新)的檔案。
3. 勾選後點擊 **Pull selected**
4. **注意**Pull 會覆蓋掉您本機的內容。如果有衝突,會自動開啟衝突解決視窗。
@ -79,5 +94,5 @@
## 🔒 隱私與安全
- 您的存取權杖 (Token) 僅會儲存在您本機的 Obsidian 資料夾內。
- 您的存取權杖 (Token) 僅會儲存在您本機的 Obsidian 資料夾內,不會傳送至任何第三方伺服器
- 本插件不會收集任何個人數據或使用紀錄。

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,8 +1,8 @@
{
"id": "git-file-sync",
"name": "Git File Sync",
"version": "1.1.2",
"minAppVersion": "1.12.7",
"version": "1.2.0",
"minAppVersion": "1.13.0",
"description": "Selectively sync individual notes with GitLab or GitHub. Push, pull, diff, and resolve conflicts — file by file, on mobile and desktop.",
"author": "ClaudiaFang",
"authorUrl": "https://firstsun.heavenfortress.com/en/",

2711
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "git-file-sync",
"version": "1.1.2",
"version": "1.2.0",
"description": "Selectively sync individual notes with GitLab or GitHub. Push, pull, diff, and resolve conflicts — file by file, on mobile and desktop.",
"main": "main.js",
"type": "module",
@ -27,11 +27,11 @@
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/exec": "^7.1.0",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^12.0.6",
"@semantic-release/github": "^12.0.9",
"@types/jsdom": "^28.0.3",
"@types/node": "^24.0.0",
"@vitest/coverage-v8": "^4.1.5",
"@vitest/ui": "^4.1.2",
"@vitest/coverage-v8": "^4.1.9",
"@vitest/ui": "^4.1.9",
"conventional-changelog-conventionalcommits": "^9.3.1",
"esbuild": "0.28.1",
"eslint-plugin-obsidianmd": "0.1.9",
@ -39,15 +39,27 @@
"globals": "14.0.0",
"husky": "^9.1.7",
"jiti": "2.6.1",
"jsdom": "^29.0.1",
"semantic-release": "^25.0.3",
"jsdom": "^29.1.1",
"semantic-release": "^25.0.5",
"tslib": "2.4.0",
"typescript": "^5.8.3",
"typescript-eslint": "8.35.1",
"vitest": "^4.1.2"
"vitest": "^4.1.9"
},
"dependencies": {
"ignore": "^7.0.5",
"obsidian": "latest"
"obsidian": "^1.13.1"
},
"overrides": {
"npm": "^11.18.0",
"@actions/http-client": {
"undici": "^6.27.0"
},
"sigstore": "^4.1.1",
"@sigstore/core": "^3.2.1",
"@sigstore/verify": "^3.1.1",
"tar": "^7.5.19",
"ip-address": "^10.1.1",
"js-yaml": "^4.2.0"
}
}

View file

@ -1,6 +1,24 @@
{
"version": 1,
"skills": {
"clean-code": {
"source": "firstsun-dev/skills",
"sourceType": "github",
"skillPath": "external/develop/code-quality/clean-code/SKILL.md",
"computedHash": "3f3c09033d11ce887e64d99c3639faf011355cb73c5d6891da05efe8004956f2"
},
"design-taste-frontend": {
"source": "firstsun-dev/skills",
"sourceType": "github",
"skillPath": "external/develop/frontend/design-taste-frontend/SKILL.md",
"computedHash": "6d838b246d0e35d0b53f4f23f98ba7a1dd561937e64f7d0c7553b0928e376c3e"
},
"frontend-design": {
"source": "firstsun-dev/skills",
"sourceType": "github",
"skillPath": "external/develop/frontend/frontend-design/SKILL.md",
"computedHash": "4eabc66183767153e404b39d1b839b1c37f2d82d86f0a0d7e880a579d8d62336"
},
"obsidian-development": {
"source": "firstsun-dev/skills",
"sourceType": "github",

View file

@ -1,9 +1,13 @@
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';
/** Result of syncing one file within a batch push/pull. */
type BatchOutcome = 'done' | 'unchanged' | 'conflict';
export class SyncManager {
private readonly app: App;
@ -31,6 +35,13 @@ export class SyncManager {
await this.saveSettings();
}
/** Drop sync metadata for a path that's been deleted, so it can't be mistaken for a rename source later. */
public async clearMetadata(path: string): Promise<void> {
if (!(path in this.settings.syncMetadata)) return;
delete this.settings.syncMetadata[path];
await this.saveSettings();
}
private getNormalizedPath(path: string): string {
if (!this.settings.vaultFolder) return path;
const folderPath = this.settings.vaultFolder + '/';
@ -54,11 +65,18 @@ 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);
const renamedFrom = await this.detectRename(fileOrPath, content);
if (renamedFrom) {
await this.handleRename(fileOrPath, renamedFrom, content);
return;
@ -67,7 +85,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 +109,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);
@ -100,19 +125,24 @@ export class SyncManager {
}
}
private detectRename(file: TFile): string | null {
// Check if there's a metadata entry with the same SHA but different path
/**
* A missing local file at a tracked path is only weak evidence of a rename
* any orphaned metadata entry (e.g. from a local delete) matches it too. Only
* report a rename once the remote content at the old path still matches the
* content being pushed now, confirming it's really the same file that moved.
*/
private async detectRename(file: TFile, content: string | ArrayBuffer): Promise<string | null> {
const metadataEntries = Object.keys(this.settings.syncMetadata);
for (const oldPath of metadataEntries) {
const metadata = this.settings.syncMetadata[oldPath];
if (!metadata) continue;
if (oldPath === file.path || metadata.lastKnownPath !== oldPath) continue;
if (this.app.vault.getFileByPath(oldPath)) continue;
if (oldPath !== file.path && metadata.lastKnownPath === oldPath) {
// Check if the old file no longer exists
if (!this.app.vault.getFileByPath(oldPath)) {
// This might be a rename
return oldPath;
}
const oldRepoPath = this.getNormalizedPath(oldPath);
const remoteAtOldPath = await this.gitService.getFile(oldRepoPath, this.settings.branch);
if (remoteAtOldPath.sha && this.contentsEqual(content, remoteAtOldPath.content)) {
return oldPath;
}
}
return null;
@ -123,13 +153,18 @@ export class SyncManager {
const repoPath = this.getNormalizedPath(file.path);
const oldRepoPath = this.getNormalizedPath(oldPath);
// The new path may already exist on the remote (e.g. a prior push, or a
// stale rename match); if so we must send its sha or the API rejects the
// request as a duplicate create.
const existingAtNewPath = await this.gitService.getFile(repoPath, this.settings.branch);
// Push the file to the new location
const result = await this.gitService.pushFile(
repoPath,
content,
this.settings.branch,
`Rename ${oldRepoPath} to ${repoPath}`,
undefined
existingAtNewPath.sha
);
// Update metadata
@ -170,10 +205,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 +267,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 +292,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> {
@ -280,11 +351,11 @@ export class SyncManager {
new Notice(`${message}: ${detail}`);
}
async pushAllFiles(files: (TFile | string)[], onProgress?: (current: number, total: number, fileName: string) => void): Promise<{ success: number; failed: number; errors: Array<{ file: string; error: string }> }> {
async pushAllFiles(files: (TFile | string)[], onProgress?: (current: number, total: number, fileName: string) => void): Promise<{ success: number; failed: number; conflicts: number; errors: Array<{ file: string; error: string }> }> {
return this.processBatch(files, 'push', onProgress);
}
async pullAllFiles(files: (TFile | string)[], onProgress?: (current: number, total: number, fileName: string) => void): Promise<{ success: number; failed: number; errors: Array<{ file: string; error: string }> }> {
async pullAllFiles(files: (TFile | string)[], onProgress?: (current: number, total: number, fileName: string) => void): Promise<{ success: number; failed: number; conflicts: number; errors: Array<{ file: string; error: string }> }> {
return this.processBatch(files, 'pull', onProgress);
}
@ -292,8 +363,8 @@ export class SyncManager {
files: (TFile | string)[],
op: 'push' | 'pull',
onProgress?: (current: number, total: number, fileName: string) => void
): Promise<{ success: number; failed: number; errors: Array<{ file: string; error: string }> }> {
const results = { success: 0, failed: 0, errors: [] as Array<{ file: string; error: string }> };
): Promise<{ success: number; failed: number; conflicts: number; errors: Array<{ file: string; error: string }> }> {
const results = { success: 0, failed: 0, conflicts: 0, errors: [] as Array<{ file: string; error: string }> };
for (let i = 0; i < files.length; i++) {
const fileOrPath = files[i];
@ -303,13 +374,12 @@ export class SyncManager {
onProgress?.(i + 1, files.length, name);
try {
let performed = false;
if (op === 'push') {
performed = await this.processSingleBatchPush(fileOrPath, path, name, isString);
} else {
performed = await this.processSingleBatchPull(fileOrPath, path, name, isString);
}
if (performed) results.success++;
const outcome = op === 'push'
? await this.processSingleBatchPush(fileOrPath, path, name, isString)
: await this.processSingleBatchPull(fileOrPath, path, name, isString);
if (outcome === 'done') results.success++;
else if (outcome === 'conflict') results.conflicts++;
} catch (e) {
logger.error(`Failed to ${op} ${path}:`, e);
results.failed++;
@ -318,16 +388,19 @@ export class SyncManager {
}
await this.saveSettings();
this.notifyBatchResult(op, results.success, results.failed);
this.notifyBatchResult(op, results.success, results.failed, results.conflicts);
return results;
}
private notifyBatchResult(op: 'push' | 'pull', success: number, failed: number): void {
private notifyBatchResult(op: 'push' | 'pull', success: number, failed: number, conflicts: number): void {
const opName = op === 'push' ? 'Pushed' : 'Pulled';
if (success > 0) {
new Notice(`${opName} ${success} file(s) to ${this.serviceName}`);
}
if (conflicts > 0) {
new Notice(`Skipped ${conflicts} file(s) with conflicting changes on both sides. Push or pull each one individually to resolve.`, 8000);
}
if (failed > 0) {
new Notice(`Failed to ${op} ${failed} file(s). Check console for details.`);
}
@ -352,42 +425,71 @@ export class SyncManager {
const binary = this.isBinary(path);
if (typeof fileOrPath === 'string') {
return binary
return binary
? await this.app.vault.adapter.readBinary(fileOrPath)
: await this.app.vault.adapter.read(fileOrPath);
}
return binary
? await this.app.vault.readBinary(fileOrPath)
: await this.app.vault.read(fileOrPath);
try {
return binary
? await this.app.vault.readBinary(fileOrPath)
: await this.app.vault.read(fileOrPath);
} catch (e) {
// Obsidian's cached vault.read can fail for symlinked files (notably
// on mobile); fall back to reading the path directly via the adapter.
logger.warn(`vault.read failed for ${path}; falling back to adapter`, e);
return binary
? await this.app.vault.adapter.readBinary(path)
: await this.app.vault.adapter.read(path);
}
}
private async processSingleBatchPush(fileOrPath: TFile | string, path: string, name: string, isString: boolean): Promise<boolean> {
private async processSingleBatchPush(fileOrPath: TFile | string, path: string, name: string, isString: boolean): Promise<BatchOutcome> {
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' ? 'done' : 'unchanged';
}
const content = await this.getFileContent(fileOrPath);
const repoPath = this.getNormalizedPath(path);
// Rename detection
if (!isString && fileOrPath instanceof TFile) {
const renamedFrom = this.detectRename(fileOrPath);
const renamedFrom = await this.detectRename(fileOrPath, content);
if (renamedFrom) {
await this.handleRename(fileOrPath, renamedFrom, content);
return true;
return 'done';
}
}
const remote = await this.gitService.getFile(repoPath, this.settings.branch);
// Don't convert a remote symlink into a regular file.
if (remote.isSymlink) return 'unchanged';
// Skip if already in sync
if (remote.sha && this.contentsEqual(content, remote.content)) {
await this.updateMetadata(path, remote.sha);
return false;
return 'unchanged';
}
// Same conflict check as the single-file flow: if the remote has moved on
// from what we last synced, overwriting it here would silently discard
// whatever changed on the remote. Skip it instead of force-pushing so the
// batch action can't quietly clobber changes the way a single push would
// stop and ask about via SyncConflictModal.
const lastSynced = this.settings.syncMetadata[path];
if (remote.sha && lastSynced && remote.sha !== lastSynced.lastSyncedSha) {
return 'conflict';
}
await this.performPush({ path, name }, content, remote.sha || undefined, true);
return true;
return 'done';
}
private async processSingleBatchPull(fileOrPath: TFile | string, path: string, name: string, isString: boolean): Promise<boolean> {
private async processSingleBatchPull(fileOrPath: TFile | string, path: string, name: string, isString: boolean): Promise<BatchOutcome> {
const repoPath = this.getNormalizedPath(path);
const remote = await this.gitService.getFile(repoPath, this.settings.branch);
if (!remote.sha) throw new Error('File not found in remote');
@ -397,12 +499,18 @@ export class SyncManager {
const localContent = await this.getFileContent(fileOrPath);
if (this.contentsEqual(localContent, remote.content)) {
await this.updateMetadata(path, remote.sha);
return false;
return 'unchanged';
}
// Same conflict check as the single-file flow (see processSingleBatchPush).
const lastSynced = this.settings.syncMetadata[path];
if (lastSynced && remote.sha !== lastSynced.lastSyncedSha) {
return 'conflict';
}
}
const fileRep = typeof fileOrPath === 'string' ? { path, name } : fileOrPath;
await this.performPull(fileRep, remote.content, remote.sha, true);
return true;
await this.performPull(fileRep, remote.content, remote.sha, true, this.symlinkPullTarget(remote));
return 'done';
}
}

View file

@ -1,7 +1,8 @@
import { Plugin, TFile, MarkdownView, Notice, Platform } from 'obsidian';
import { Plugin, TFile, MarkdownView, Notice, Platform, setTooltip } from 'obsidian';
import { DEFAULT_SETTINGS, GitLabFilesPushSettings, GitLabSyncSettingTab, getServiceName } from "./settings";
import { GitLabService } from './services/gitlab-service';
import { GitHubService } from './services/github-service';
import { GiteaService } from './services/gitea-service';
import { GitServiceInterface } from './services/git-service-interface';
import { SyncManager } from './logic/sync-manager';
import { SyncStatusView, SYNC_STATUS_VIEW_TYPE } from './ui/SyncStatusView';
@ -14,6 +15,7 @@ export default class GitLabFilesPush extends Plugin {
gitService: GitServiceInterface;
sync: SyncManager;
gitignoreManager: GitignoreManager;
private pushRibbonEl: HTMLElement;
async onload() {
await this.loadSettings();
@ -24,7 +26,7 @@ export default class GitLabFilesPush extends Plugin {
(leaf) => new SyncStatusView(leaf, this)
);
this.addRibbonIcon('list-checks', 'Open sync status', async () => {
this.addRibbonIcon('git-compare', 'Open sync status', async () => {
await this.activateSyncStatusView();
});
@ -40,7 +42,7 @@ export default class GitLabFilesPush extends Plugin {
this.gitignoreManager = new GitignoreManager(this.app, this.gitService, this.settings.branch, this.settings.rootPath, this.settings.vaultFolder);
this.sync = new SyncManager(this.app, this.gitService, this.settings, this.saveSettings.bind(this));
this.addRibbonIcon('upload-cloud', Platform.isMobile ? `Push` : `Push to ${this.serviceName}`, async () => {
this.pushRibbonEl = this.addRibbonIcon('upload-cloud', this.pushRibbonLabel(), async () => {
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (activeView && activeView.file instanceof TFile) {
await this.sync.pushFile(activeView.file);
@ -49,9 +51,13 @@ export default class GitLabFilesPush extends Plugin {
}
});
// Command names are set once at registration and Obsidian has no API to
// rename them later, so they stay generic rather than embedding the
// configured service — otherwise switching service in Settings would
// leave a stale name in the Command Palette until Obsidian reloads.
this.addCommand({
id: 'push-current-file',
name: `Push current file to ${this.serviceName}`,
name: 'Push current file',
callback: async () => {
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (activeView && activeView.file instanceof TFile) {
@ -62,7 +68,7 @@ export default class GitLabFilesPush extends Plugin {
this.addCommand({
id: 'pull-current-file',
name: `Pull current file from ${this.serviceName}`,
name: 'Pull current file',
callback: async () => {
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (activeView && activeView.file instanceof TFile) {
@ -109,6 +115,17 @@ export default class GitLabFilesPush extends Plugin {
return getServiceName(this.settings);
}
private pushRibbonLabel(): string {
return Platform.isMobile ? 'Push' : `Push to ${this.serviceName}`;
}
// The ribbon icon's tooltip is set once when addRibbonIcon runs, so it goes
// stale if the user switches Git service afterwards without reloading the
// plugin. Re-apply it whenever settings are saved to keep it in sync.
private updateRibbonTooltip(): void {
if (this.pushRibbonEl) setTooltip(this.pushRibbonEl, this.pushRibbonLabel());
}
async activateSyncStatusView(): Promise<void> {
const { workspace } = this.app;
@ -235,6 +252,16 @@ export default class GitLabFilesPush extends Plugin {
this.settings.rootPath
);
this.gitService = service;
} else if (this.settings.serviceType === 'gitea') {
const service = new GiteaService();
service.updateConfig(
this.settings.giteaBaseUrl,
this.settings.giteaToken,
this.settings.giteaOwner,
this.settings.giteaRepo,
this.settings.rootPath
);
this.gitService = service;
} else {
const service = new GitHubService();
service.updateConfig(
@ -273,5 +300,6 @@ export default class GitLabFilesPush extends Plugin {
async saveSettings() {
await this.saveData(this.settings);
this.initializeGitService();
this.updateRibbonTooltip();
}
}

View file

@ -1,11 +1,18 @@
import { requestUrl, RequestUrlResponse } from 'obsidian';
import { logger } from '../utils/logger';
import { GitTreeEntry } from './git-service-interface';
export interface GitFile {
content: string | ArrayBuffer;
sha: string;
isSymlink?: boolean;
symlinkTarget?: string;
}
// Git file mode for a symbolic link. Symlinks are stored as blobs whose content
// is the link target path, so they need special handling during sync.
export const GIT_SYMLINK_MODE = '120000';
export interface GitHubContentResponse {
content: string;
sha: string;
@ -15,6 +22,7 @@ export interface GitHubContentResponse {
export interface GitHubTreeItem {
path: string;
type: string;
mode?: string;
}
export interface GitHubTreeResponse {
@ -32,6 +40,16 @@ export interface GitLabFileResponse {
export interface GitLabTreeItem {
path: string;
type: string;
mode?: string;
}
export interface ConnectionTestResult {
/** Whether the repository/project itself was reachable with the given credentials. */
repoOk: boolean;
/** Whether the configured branch was found. Only meaningful when repoOk is true. */
branchOk: boolean;
/** Populated when repoOk is false, describing the repo-level failure. */
error?: string;
}
export abstract class BaseGitService {
@ -42,6 +60,7 @@ export abstract class BaseGitService {
* Safely wraps requestUrl to handle potential throws from Obsidian and provide better error messages.
*/
protected async safeRequest(url: string, method: string, body?: unknown, extraHeaders?: Record<string, string>, silent = false): Promise<RequestUrlResponse> {
let response: RequestUrlResponse;
try {
const headers: Record<string, string> = {
...extraHeaders,
@ -57,30 +76,73 @@ export abstract class BaseGitService {
throw: false
};
const response = await requestUrl(options);
if (response.status >= 400) {
const errorMsg = this.parseErrorResponse(response);
if (!silent) logger.error(`Git Service Request Failed (${response.status}): ${url}`, errorMsg);
throw new Error(`Git Service Error (${response.status}): ${errorMsg}`);
}
return response;
response = await requestUrl(options);
} catch (error) {
// Network-level failure (DNS, offline, TLS, etc.)
if (!silent) logger.error('Git Service Request Failed:', error);
if (error instanceof Error) throw error;
throw new Error(`Network error or unexpected failure: ${String(error)}`);
}
if (response.status >= 400) {
const errorMsg = this.parseErrorResponse(response);
// 404 is routinely an expected "does not exist" probe (getFile treats
// it as an empty file, gitignore lookups ignore it). Log it at debug
// level so it doesn't surface as a failure, but still throw so callers
// can handle it. Other statuses are genuine errors.
if (!silent) {
if (response.status === 404) logger.debug(`Git Service 404 (not found): ${url}`);
else logger.error(`Git Service Request Failed (${response.status}): ${url}`, errorMsg);
}
throw new Error(`Git Service Error (${response.status}): ${errorMsg}`);
}
return response;
}
protected abstract addAuthHeader(headers: Record<string, string>): void;
/**
* Safely parses a response body as JSON.
*
* Some servers/proxies return a 2xx (or redirect) response whose body is an
* HTML page a login screen, an SSO redirect, or a proxy/error page rather
* than JSON. Accessing `response.json` directly then throws a cryptic
* "Unexpected token '<', \"<!DOCTYPE ...\" is not valid JSON" error. This helper
* detects that case and throws a clear, actionable message instead.
*/
protected parseJson<T>(response: RequestUrlResponse): T {
const contentType = (response.headers?.['content-type'] ?? response.headers?.['Content-Type'] ?? '').toLowerCase();
const bodyText = (response.text ?? '').trimStart();
const looksLikeHtml = bodyText.startsWith('<') || contentType.includes('html');
try {
const data = response.json as T;
if (data === undefined && looksLikeHtml) throw new Error('non-JSON response');
return data;
} catch (e) {
if (looksLikeHtml) {
throw new Error(
'Expected a JSON response from the Git server but received an HTML page ' +
'(likely a login, SSO redirect, or proxy/error page). ' +
'Please check the server URL, your access token, and any network proxy or firewall.'
);
}
throw new Error(`Failed to parse the Git server response as JSON: ${e instanceof Error ? e.message : String(e)}`);
}
}
protected parseErrorResponse(response: RequestUrlResponse): string {
try {
const data = response.json as { message?: string; error?: string };
return data.message || data.error || JSON.stringify(data);
} catch {
return response.text || 'Unknown error';
const bodyText = (response.text ?? '').trim();
const contentType = (response.headers?.['content-type'] ?? response.headers?.['Content-Type'] ?? '').toLowerCase();
if (bodyText.startsWith('<') || contentType.includes('html')) {
return 'Received an HTML page instead of a JSON error (likely a login, redirect, or proxy/error page). Check the server URL, access token, and network proxy.';
}
return bodyText || 'Unknown error';
}
}
@ -144,6 +206,22 @@ export abstract class BaseGitService {
throw e;
}
/**
* Rethrows a branch-resolution failure (e.g. the "resolve branch to a commit"
* request in listFilesDetailed) with a message that names the branch, since a
* bare "404" gives no clue that the configured Branch setting is the problem.
*/
protected branchNotFoundError(e: unknown, branch: string): Error {
if (e instanceof Error && e.message.includes('404')) {
return new Error(
`Branch "${branch}" was not found in the repository. Check the Branch setting, ` +
'or confirm the repository actually has a branch with this name (its default branch ' +
'might be "master" instead of "main", or the repository may have no commits yet).'
);
}
return e instanceof Error ? e : new Error(String(e));
}
async getRepoGitignores(branch: string): Promise<string[]> {
try {
const allFiles = await this.listFiles(branch, false); // Fetch ALL files to find gitignores
@ -153,9 +231,14 @@ export abstract class BaseGitService {
}
}
async listFiles(branch: string, useFilter = true): Promise<string[]> {
const entries = await this.listFilesDetailed(branch, useFilter);
return entries.map(e => e.path);
}
abstract getFile(path: string, branch: string): Promise<GitFile>;
abstract pushFile(path: string, content: string | ArrayBuffer, branch: string, message: string, sha?: string): Promise<{ path: string, sha?: string }>;
abstract listFiles(branch: string, useFilter?: boolean): Promise<string[]>;
abstract listFilesDetailed(branch: string, useFilter?: boolean): Promise<GitTreeEntry[]>;
abstract deleteFile(path: string, branch: string, message: string): Promise<void>;
abstract testConnection(): Promise<boolean>;
abstract testConnection(branch: string): Promise<ConnectionTestResult>;
}

View file

@ -1,14 +1,35 @@
import { ConnectionTestResult } from './git-service-base';
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. */
export interface GitTreeEntry {
path: string;
symlink: boolean;
}
export interface GitServiceInterface {
updateConfig(...args: unknown[]): void;
getFile(path: string, branch: string): Promise<GitFile>;
pushFile(path: string, content: string | ArrayBuffer, branch: string, commitMessage: string, existingSha?: string): Promise<{ path: string, sha?: string }>;
testConnection(): Promise<boolean>;
/** Checks the repository is reachable and the given branch exists. */
testConnection(branch: string): Promise<ConnectionTestResult>;
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

@ -0,0 +1,119 @@
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 GiteaService extends BaseGitService implements GitServiceInterface {
private baseUrl: string = '';
private owner: string = '';
private repo: string = '';
updateConfig(baseUrl: string, token: string, owner: string, repo: string, rootPath: string = '') {
this.baseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
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);
return `${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}/contents/${fullPath}`;
}
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>(response);
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,
};
// Only send sha when updating an existing file; a blank sha is rejected.
if (sha) body.sha = sha;
const method = sha ? 'PUT' : 'POST';
const response = await this.safeRequest(url, method, body);
const data = this.parseJson<{ content: { path: string, sha: string } }>(response);
return { path: data.content.path, sha: data.content.sha };
}
async listFilesDetailed(branch: string, useFilter = true): Promise<GitTreeEntry[]> {
// Resolve branch name to commit SHA first for compatibility with all Gitea versions,
// since the git/trees endpoint requires a SHA (not a ref name) on older instances.
const branchUrl = `${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}/branches/${branch}`;
let commitSha: string;
try {
const branchResponse = await this.safeRequest(branchUrl, 'GET');
commitSha = this.parseJson<{ commit: { id: string } }>(branchResponse).commit.id;
} catch (e) {
throw this.branchNotFoundError(e, branch);
}
const treeUrl = `${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}/git/trees/${commitSha}?recursive=1`;
const treeResponse = await this.safeRequest(treeUrl, 'GET');
const treeData = this.parseJson<GitHubTreeResponse>(treeResponse);
if (treeData.truncated) {
logger.warn('Gitea tree result is truncated. Some files might not be shown.');
}
const entries = treeData.tree
.filter(item => item.type === 'blob')
.map(item => ({ path: item.path, symlink: item.mode === GIT_SYMLINK_MODE }));
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 deleteFile(path: string, branch: string, message: string): Promise<void> {
const file = await this.getFile(path, 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 = `${this.baseUrl}/api/v1/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 = `${this.baseUrl}/api/v1/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 };
}
}
}

View file

@ -1,5 +1,5 @@
import { GitServiceInterface } from './git-service-interface';
import { BaseGitService, GitFile, GitHubContentResponse, GitHubTreeResponse } from './git-service-base';
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 {
@ -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 = response.json as GitHubContentResponse;
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
@ -39,37 +46,79 @@ export class GitHubService extends BaseGitService implements GitServiceInterface
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 = {
const body: { message: string; content: string; branch: string; sha?: string } = {
message,
content: this.encodeContent(content),
branch,
sha
};
// 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 = response.json as { content: { path: string, sha: string } };
const data = this.parseJson<{ content: { path: string, sha: string } }>(response);
return { path: data.content.path, sha: data.content.sha };
}
async listFiles(branch: string, useFilter = true): Promise<string[]> {
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');
const data = response.json as GitHubTreeResponse;
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 files = data.tree
const entries = data.tree
.filter(item => item.type === 'blob')
.map(item => item.path);
.map(item => ({ path: item.path, symlink: item.mode === GIT_SYMLINK_MODE }));
if (!useFilter) return files;
if (!useFilter) return entries;
return files.filter(p => {
return entries.filter(e => {
if (!this.rootPath) return true;
const cleanRoot = this.rootPath.endsWith('/') ? this.rootPath : `${this.rootPath}/`;
return p === this.rootPath || p.startsWith(cleanRoot);
return e.path === this.rootPath || e.path.startsWith(cleanRoot);
});
}
@ -85,13 +134,20 @@ export class GitHubService extends BaseGitService implements GitServiceInterface
await this.safeRequest(url, 'DELETE', body);
}
async testConnection(): Promise<boolean> {
async testConnection(branch: string): Promise<ConnectionTestResult> {
try {
const url = `https://api.github.com/repos/${this.owner}/${this.repo}`;
await this.safeRequest(url, 'GET');
return true;
} 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 false;
return { repoOk: true, branchOk: false };
}
}

View file

@ -1,5 +1,5 @@
import { GitServiceInterface } from './git-service-interface';
import { BaseGitService, GitFile, GitLabFileResponse, GitLabTreeItem } from './git-service-base';
import { GitServiceInterface, GitTreeEntry } from './git-service-interface';
import { BaseGitService, ConnectionTestResult, GitFile, GitLabFileResponse, GitLabTreeItem, GIT_SYMLINK_MODE } from './git-service-base';
export class GitLabService extends BaseGitService implements GitServiceInterface {
private baseUrl: string = 'https://gitlab.com';
@ -27,7 +27,7 @@ export class GitLabService extends BaseGitService implements GitServiceInterface
try {
const url = `${this.getApiUrl(path)}?ref=${branch}`;
const response = await this.safeRequest(url, 'GET');
const data = response.json as GitLabFileResponse;
const data = this.parseJson<GitLabFileResponse>(response);
return {
content: this.decodeContent(data.content, path),
@ -40,53 +40,59 @@ export class GitLabService extends BaseGitService implements GitServiceInterface
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 = {
const body: { branch: string; content: string; encoding: string; commit_message: string; last_commit_id?: string } = {
branch,
content: this.encodeContent(content),
encoding: 'base64',
commit_message: message,
last_commit_id: sha
};
// A blank sha means the file is new: create it (POST) without last_commit_id.
if (sha) body.last_commit_id = sha;
const method = sha ? 'PUT' : 'POST';
const response = await this.safeRequest(url, method, body);
const data = response.json as GitLabFileResponse;
const data = this.parseJson<GitLabFileResponse>(response);
return { path: data.file_path };
}
async listFiles(branch: string, useFilter = true): Promise<string[]> {
async listFilesDetailed(branch: string, useFilter = true): Promise<GitTreeEntry[]> {
const encodedProjectId = encodeURIComponent(this.projectId);
let allPaths: string[] = [];
let allEntries: GitTreeEntry[] = [];
let page = 1;
const perPage = 100;
while (true) {
const url = `${this.baseUrl}/api/v4/projects/${encodedProjectId}/repository/tree?ref=${branch}&recursive=true&per_page=${perPage}&page=${page}`;
const response = await this.safeRequest(url, 'GET');
const data = response.json as GitLabTreeItem[];
let data: GitLabTreeItem[];
try {
const response = await this.safeRequest(url, 'GET');
data = this.parseJson<GitLabTreeItem[]>(response);
} catch (e) {
throw this.branchNotFoundError(e, branch);
}
if (!data || data.length === 0) break;
const paths = data
const entries = data
.filter(item => item.type === 'blob')
.map(item => item.path);
.map(item => ({ path: item.path, symlink: item.mode === GIT_SYMLINK_MODE }));
if (useFilter) {
const filtered = paths.filter(p => {
const filtered = entries.filter(e => {
if (!this.rootPath) return true;
const cleanRoot = this.rootPath.endsWith('/') ? this.rootPath : `${this.rootPath}/`;
return p === this.rootPath || p.startsWith(cleanRoot);
return e.path === this.rootPath || e.path.startsWith(cleanRoot);
});
allPaths = allPaths.concat(filtered);
allEntries = allEntries.concat(filtered);
} else {
allPaths = allPaths.concat(paths);
allEntries = allEntries.concat(entries);
}
if (data.length < perPage) break;
page++;
}
return allPaths;
return allEntries;
}
async deleteFile(path: string, branch: string, message: string): Promise<void> {
@ -99,14 +105,22 @@ export class GitLabService extends BaseGitService implements GitServiceInterface
await this.safeRequest(url, 'DELETE', body);
}
async testConnection(): Promise<boolean> {
async testConnection(branch: string): Promise<ConnectionTestResult> {
const encodedProjectId = encodeURIComponent(this.projectId);
try {
const encodedProjectId = encodeURIComponent(this.projectId);
const url = `${this.baseUrl}/api/v4/projects/${encodedProjectId}`;
await this.safeRequest(url, 'GET');
return true;
} catch (e) {
return { repoOk: false, branchOk: false, error: e instanceof Error ? e.message : String(e) };
}
try {
const encodedBranch = encodeURIComponent(branch);
const branchUrl = `${this.baseUrl}/api/v4/projects/${encodedProjectId}/repository/branches/${encodedBranch}`;
await this.safeRequest(branchUrl, 'GET', undefined, undefined, true);
return { repoOk: true, branchOk: true };
} catch {
return false;
return { repoOk: true, branchOk: false };
}
}

View file

@ -1,4 +1,4 @@
import {App, PluginSettingTab, Setting, Notice} from 'obsidian';
import {App, PluginSettingTab, Setting, Notice, TextComponent, SettingDefinitionItem} from 'obsidian';
import GitLabFilesPush from "./main";
export interface SyncMetadata {
@ -7,7 +7,16 @@ export interface SyncMetadata {
lastKnownPath?: string;
}
export type GitServiceType = 'gitlab' | 'github';
export type GitServiceType = 'gitlab' | 'github' | 'gitea';
/**
* How symbolic links (Git blobs with mode 120000) are synced:
* - 'real': recreate a real OS symlink on desktop; on mobile (no symlink API)
* fall back to syncing the link target's content as a normal file.
* - 'follow': always sync the target file's content as a normal file.
* - 'skip': ignore symlinks entirely.
*/
export type SymlinkHandling = 'real' | 'follow' | 'skip';
export interface GitLabFilesPushSettings {
serviceType: GitServiceType;
@ -17,14 +26,34 @@ export interface GitLabFilesPushSettings {
githubToken: string;
githubOwner: string;
githubRepo: string;
giteaToken: string;
giteaBaseUrl: string;
giteaOwner: string;
giteaRepo: string;
branch: string;
syncMetadata: Record<string, SyncMetadata>;
rootPath: string;
vaultFolder: string;
symlinkHandling: SymlinkHandling;
}
export function getServiceName(settings: GitLabFilesPushSettings): string {
return settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub';
if (settings.serviceType === 'gitlab') return 'GitLab';
if (settings.serviceType === 'gitea') return 'Gitea';
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 = {
@ -35,10 +64,15 @@ export const DEFAULT_SETTINGS: GitLabFilesPushSettings = {
githubToken: '',
githubOwner: '',
githubRepo: '',
giteaToken: '',
giteaBaseUrl: '',
giteaOwner: '',
giteaRepo: '',
rootPath: "",
branch: 'main',
syncMetadata: {},
vaultFolder: ''
vaultFolder: '',
symlinkHandling: 'real'
}
export class GitLabSyncSettingTab extends PluginSettingTab {
@ -49,29 +83,53 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
this.plugin = plugin;
}
// Kept as a fallback for Obsidian < 1.13.0 (this plugin's minAppVersion),
// which don't know about getSettingDefinitions() and always call display().
display(): void {
const {containerEl} = this;
this.renderSettings(this.containerEl);
}
getSettingDefinitions(): SettingDefinitionItem[] {
return [{
name: '',
render: (_setting, group) => {
this.renderSettings(group.listEl);
}
}];
}
private refresh(): void {
if (typeof this.update === 'function') {
this.update();
} else {
this.renderSettings(this.containerEl);
}
}
private renderSettings(containerEl: HTMLElement): void {
containerEl.empty();
new Setting(containerEl)
.setName('Git service')
.setDesc('Choose between GitLab or GitHub')
.setDesc('Choose your Git hosting service')
.addDropdown(dropdown => dropdown
.addOption('gitlab', 'GitLab')
.addOption('github', 'GitHub')
.addOption('gitea', 'Gitea')
.setValue(this.plugin.settings.serviceType)
.onChange((value: string) => {
this.plugin.settings.serviceType = value as GitServiceType;
void this.plugin.saveSettings();
this.plugin.initializeGitService();
this.display();
this.refresh();
}));
new Setting(containerEl).setName('').setHeading();
if (this.plugin.settings.serviceType === 'gitlab') {
this.displayGitLabSettings(containerEl);
} else if (this.plugin.settings.serviceType === 'gitea') {
this.displayGiteaSettings(containerEl);
} else {
this.displayGitHubSettings(containerEl);
}
@ -110,6 +168,26 @@ 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(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')
.setDesc(`Verify your ${getServiceName(this.plugin.settings)} settings`)
@ -117,8 +195,18 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
.setButtonText('Test connection')
.onClick(async () => {
try {
await this.plugin.gitService.testConnection();
new Notice(`${getServiceName(this.plugin.settings)} connection successful!`);
const result = await this.plugin.gitService.testConnection(this.plugin.settings.branch);
if (!result.repoOk) {
new Notice(`Connection failed: ${result.error ?? 'could not reach the repository'}`);
} else if (!result.branchOk) {
new Notice(
`Connected, but branch "${this.plugin.settings.branch}" was not found. ` +
'Check the Branch setting, or confirm the repository has a branch with this name.',
8000
);
} else {
new Notice(`${getServiceName(this.plugin.settings)} connection successful!`);
}
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e);
new Notice(`Connection failed: ${message}`);
@ -126,18 +214,45 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
}));
}
private displayGitLabSettings(containerEl: HTMLElement): void {
// Token fields are masked like a password input (with a toggle to reveal
// them) since they're secrets that shouldn't sit in plaintext on screen
// during screen shares, recordings, or shared machines.
private addTokenSetting(containerEl: HTMLElement, name: string, desc: string, getValue: () => string, onChange: (value: string) => void): void {
let textComponent: TextComponent;
new Setting(containerEl)
.setName('GitLab personal access token')
.setDesc('Create a token in GitLab user settings > access tokens with "API" scope')
.addText(text => text
.setPlaceholder('Enter your token')
.setValue(this.plugin.settings.gitlabToken)
.onChange((value) => {
this.plugin.settings.gitlabToken = value;
void this.plugin.saveSettings();
this.plugin.initializeGitService();
}));
.setName(name)
.setDesc(desc)
.addText(text => {
textComponent = text;
text.inputEl.type = 'password';
text.setPlaceholder('Enter your token')
.setValue(getValue())
.onChange(onChange);
})
.addExtraButton(btn => {
btn.setIcon('eye')
.setTooltip('Show token')
.onClick(() => {
const revealing = textComponent.inputEl.type === 'password';
textComponent.inputEl.type = revealing ? 'text' : 'password';
btn.setIcon(revealing ? 'eye-off' : 'eye');
btn.setTooltip(revealing ? 'Hide token' : 'Show token');
});
});
}
private displayGitLabSettings(containerEl: HTMLElement): void {
this.addTokenSetting(
containerEl,
'GitLab personal access token',
'Create a token in GitLab user settings > access tokens with "API" scope',
() => this.plugin.settings.gitlabToken,
(value) => {
this.plugin.settings.gitlabToken = value;
void this.plugin.saveSettings();
this.plugin.initializeGitService();
}
);
new Setting(containerEl)
.setName('GitLab base URL')
@ -164,19 +279,69 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
}));
}
private displayGitHubSettings(containerEl: HTMLElement): void {
private displayGiteaSettings(containerEl: HTMLElement): void {
this.addTokenSetting(
containerEl,
'Gitea personal access token',
'Create a token in user settings > applications > access tokens',
() => this.plugin.settings.giteaToken,
(value) => {
this.plugin.settings.giteaToken = value;
void this.plugin.saveSettings();
this.plugin.initializeGitService();
}
);
new Setting(containerEl)
.setName('GitHub personal access token')
.setDesc('Create a token in GitHub settings > developer settings > personal access tokens with "repo" scope')
.setName('Gitea base URL')
.setDesc('URL of your Gitea instance (e.g. https://gitea.example.com)')
.addText(text => text
.setPlaceholder('Enter your token')
.setValue(this.plugin.settings.githubToken)
.setPlaceholder('https://gitea.example.com')
.setValue(this.plugin.settings.giteaBaseUrl)
.onChange((value) => {
this.plugin.settings.githubToken = value;
this.plugin.settings.giteaBaseUrl = value;
void this.plugin.saveSettings();
this.plugin.initializeGitService();
}));
new Setting(containerEl)
.setName('Repository owner')
.setDesc('Gitea username or organization name')
.addText(text => text
.setPlaceholder('Username')
.setValue(this.plugin.settings.giteaOwner)
.onChange((value) => {
this.plugin.settings.giteaOwner = value;
void this.plugin.saveSettings();
this.plugin.initializeGitService();
}));
new Setting(containerEl)
.setName('Repository name')
.setDesc('Name of the repository')
.addText(text => text
.setPlaceholder('My notes')
.setValue(this.plugin.settings.giteaRepo)
.onChange((value) => {
this.plugin.settings.giteaRepo = value;
void this.plugin.saveSettings();
this.plugin.initializeGitService();
}));
}
private displayGitHubSettings(containerEl: HTMLElement): void {
this.addTokenSetting(
containerEl,
'GitHub personal access token',
'Create a token in GitHub settings > developer settings > personal access tokens with "repo" scope',
() => this.plugin.settings.githubToken,
(value) => {
this.plugin.settings.githubToken = value;
void this.plugin.saveSettings();
this.plugin.initializeGitService();
}
);
new Setting(containerEl)
.setName('Repository owner')
.setDesc('GitHub username or organization name')

View file

@ -55,7 +55,7 @@ export class SyncConflictModal extends Modal {
.addButton(btn => btn
.setButtonText('Keep remote')
.setTooltip('Overwrite local with remote content')
.setWarning()
.setDestructive()
.onClick(() => {
this.onChoose('remote');
this.close();

View file

@ -1,11 +1,12 @@
import { ItemView, WorkspaceLeaf, TFile, Notice, Platform, setTooltip } from 'obsidian';
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';
import { renderActionBar } from './components/ActionBar';
import { renderFileItem, type FileItemCallbacks } from './components/FileListItem';
import { renderFileItem, statusMeta, type FileItemCallbacks } from './components/FileListItem';
import { ICONS } from './components/icons';
import { isBinaryPath, contentsEqual } from '../utils/path';
export const SYNC_STATUS_VIEW_TYPE = 'sync-status-view';
@ -92,12 +93,16 @@ export class SyncStatusView extends ItemView {
if (!Platform.isMobile) {
el.createSpan({ cls: 'ssv-info-sep', text: '·' });
el.createSpan({ cls: 'ssv-info-item' }).textContent = `${this.plugin.settings.branch}`;
const branchItem = el.createSpan({ cls: 'ssv-info-item' });
setIcon(branchItem.createSpan({ cls: 'ssv-info-icon' }), ICONS.branch);
branchItem.createSpan({ text: ` ${this.plugin.settings.branch}` });
}
if (this.plugin.settings.vaultFolder) {
el.createSpan({ cls: 'ssv-info-sep', text: '·' });
el.createSpan({ cls: 'ssv-info-item', text: `📁 ${this.plugin.settings.vaultFolder}` });
const folderItem = el.createSpan({ cls: 'ssv-info-item' });
setIcon(folderItem.createSpan({ cls: 'ssv-info-icon' }), ICONS.folder);
folderItem.createSpan({ text: ` ${this.plugin.settings.vaultFolder}` });
}
if (this.lastSyncTime > 0) {
@ -123,12 +128,12 @@ export class SyncStatusView extends ItemView {
'remote-only': all.filter(s => s.status === 'remote-only').length,
};
const tabs: Array<{ value: FilterValue; label: string; icon: string }> = [
{ value: 'all', label: 'All', icon: '' },
{ value: 'synced', label: 'Synced', icon: '✓' },
{ value: 'modified', label: 'Changed', icon: '⚠' },
{ value: 'unsynced', label: 'Local only', icon: '↑' },
{ value: 'remote-only', label: 'Remote', icon: '↓' },
const tabs: Array<{ value: FilterValue; label: string }> = [
{ value: 'all', label: 'All' },
{ value: 'synced', label: 'Synced' },
{ value: 'modified', label: 'Changed' },
{ value: 'unsynced', label: 'Local only' },
{ value: 'remote-only', label: 'Remote' },
];
const tabsEl = container.createDiv({ cls: 'ssv-tabs' });
@ -136,7 +141,10 @@ export class SyncStatusView extends ItemView {
const btn = tabsEl.createEl('button', {
cls: `ssv-tab${this.statusFilter === tab.value ? ' active' : ''}`
});
if (tab.icon) btn.createSpan({ text: tab.icon });
// Share the status icon set with the file list so tabs never drift.
if (tab.value !== 'all') {
setIcon(btn.createSpan(), statusMeta(tab.value).icon);
}
btn.createSpan({ cls: 'ssv-tab-label', text: ` ${tab.label}` });
const count = counts[tab.value];
if (tab.value === 'all' || count > 0) {
@ -217,7 +225,7 @@ export class SyncStatusView extends ItemView {
// ── Single-file operations ──────────────────────────────────────
private async handleLocalDelete(fileStatus: FileStatus): Promise<void> {
const confirmed = await this.showConfirmDialog(`Delete local file "${fileStatus.path}"?`);
const confirmed = await this.showConfirmDialog(`Delete local file "${fileStatus.path}"? Handled per your vault's "Deleted files" setting.`);
if (!confirmed) return;
try {
if (fileStatus.file) {
@ -225,6 +233,7 @@ export class SyncStatusView extends ItemView {
} else {
await this.app.vault.adapter.remove(fileStatus.path);
}
await this.plugin.sync.clearMetadata(fileStatus.path);
new Notice(`Deleted ${fileStatus.path}`);
this.fileStatuses.delete(fileStatus.path);
this.renderView();
@ -244,7 +253,7 @@ export class SyncStatusView extends ItemView {
await this.plugin.sync.pullFile(fileStatus.file || fileStatus.path);
}
await new Promise(r => setTimeout(r, 500));
await new Promise(r => window.setTimeout(r, 500));
await this.refreshFileStatus(fileStatus.file || fileStatus.path);
this.renderView();
} catch (e) {
@ -295,19 +304,21 @@ export class SyncStatusView extends ItemView {
private async discoverFiles() {
const allFiles = this.app.vault.getFiles();
let local = this.plugin.filterFilesByVaultFolder(allFiles);
const remoteFullPaths = await this.plugin.gitService.listFiles(this.plugin.settings.branch);
const remoteEntries = await this.plugin.gitService.listFilesDetailed(this.plugin.settings.branch);
await this.plugin.gitignoreManager.loadGitignores();
// Map remote paths to vault paths
const remoteMap = new Map<string, string>(); // vaultPath -> remoteFullPath
for (const remotePath of remoteFullPaths) {
const normalized = this.getNormalizedRemotePath(remotePath);
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);
if (normalized === null) continue; // Not under rootPath
const vaultPath = this.plugin.getVaultPath(normalized);
if (!this.plugin.gitignoreManager.isIgnored(normalized)) {
remoteMap.set(vaultPath, remotePath);
remoteMap.set(vaultPath, entry.path);
}
}
@ -414,15 +425,38 @@ export class SyncStatusView extends ItemView {
});
}
// Checks run with bounded concurrency (each file is an independent network
// request) and the view is re-rendered on a throttle rather than once per
// file, so a large vault refreshes far faster.
private static readonly STATUS_CHECK_CONCURRENCY = 8;
private static readonly RENDER_THROTTLE_MS = 150;
private async performStatusCheck(filesToCheck: Array<TFile | string>): Promise<void> {
const total = filesToCheck.length;
this.refreshProgress = { current: 0, total };
for (let i = 0; i < total; i++) {
const file = filesToCheck[i];
if (file) await this.refreshFileStatus(file);
this.refreshProgress.current = i + 1;
this.renderView();
}
let next = 0;
let lastRender = 0;
const maybeRender = (force = false): void => {
const now = Date.now();
if (force || now - lastRender >= SyncStatusView.RENDER_THROTTLE_MS) {
lastRender = now;
this.renderView();
}
};
const worker = async (): Promise<void> => {
while (next < total) {
const file = filesToCheck[next++];
if (file) await this.refreshFileStatus(file);
this.refreshProgress.current++;
maybeRender();
}
};
const workerCount = Math.min(SyncStatusView.STATUS_CHECK_CONCURRENCY, total);
await Promise.all(Array.from({ length: workerCount }, () => worker()));
maybeRender(true);
}
private async refreshFileStatus(fileOrPath: TFile | string): Promise<void> {
@ -441,8 +475,9 @@ export class SyncStatusView extends ItemView {
const { status, diff } = this.determineFileStatus(binary, localContent, remote);
this.fileStatuses.set(path, { file, path, status, localContent, remoteContent: remote.content, remoteSha: remote.sha, diff });
} catch {
} catch (e) {
const path = typeof fileOrPath === 'string' ? fileOrPath : fileOrPath.path;
logger.warn(`Failed to determine sync status for ${path}`, e);
this.fileStatuses.set(path, {
file: typeof fileOrPath === 'string' ? undefined : fileOrPath,
path,
@ -458,9 +493,18 @@ export class SyncStatusView extends ItemView {
: await this.app.vault.adapter.read(fileOrPath as string);
}
if (fileOrPath instanceof TFile) {
return binary
? await this.app.vault.readBinary(fileOrPath)
: await this.app.vault.read(fileOrPath);
try {
return binary
? await this.app.vault.readBinary(fileOrPath)
: await this.app.vault.read(fileOrPath);
} catch (e) {
// Obsidian's cached vault.read can fail for symlinked files
// (notably on mobile); fall back to reading the path directly.
logger.warn(`vault.read failed for ${fileOrPath.path}; falling back to adapter`, e);
return binary
? await this.app.vault.adapter.readBinary(fileOrPath.path)
: await this.app.vault.adapter.read(fileOrPath.path);
}
}
// This should not happen if isStr is false and fileOrPath is TFile
throw new Error('Expected TFile when isStr is false');
@ -588,10 +632,19 @@ export class SyncStatusView extends ItemView {
}
private async confirmDeletion(localCount: number, remoteCount: number): Promise<boolean> {
// Local deletes go through Obsidian's own trash handling, whose actual
// destination (vault .trash/, OS trash, or permanent) depends on the
// user's "Deleted files" setting — not something this plugin can read.
// So local wording defers to that setting rather than promising
// recoverability; remote deletes are unconditionally permanent.
let msg = '';
if (localCount > 0 && remoteCount > 0) msg = `Delete ${localCount} local + ${remoteCount} remote file(s)? Cannot be undone.`;
else if (localCount > 0) msg = `Delete ${localCount} local file(s)? Cannot be undone.`;
else msg = `Delete ${remoteCount} remote file(s)? Cannot be undone.`;
if (localCount > 0 && remoteCount > 0) {
msg = `Delete ${localCount} local file(s) (per your vault's trash setting) and ${remoteCount} remote file(s) (cannot be undone)?`;
} else if (localCount > 0) {
msg = `Delete ${localCount} local file(s)? They'll be handled per your vault's "Deleted files" setting.`;
} else {
msg = `Delete ${remoteCount} remote file(s)? This cannot be undone.`;
}
return this.showConfirmDialog(msg);
}
@ -603,6 +656,7 @@ export class SyncStatusView extends ItemView {
try {
if (s.file) await this.app.fileManager.trashFile(s.file);
else await this.app.vault.adapter.remove(s.path);
await this.plugin.sync.clearMetadata(s.path);
this.fileStatuses.delete(s.path);
this.selectedFiles.delete(s.path);
} catch { errors.push(s.path); }

View file

@ -1,4 +1,5 @@
import { setTooltip } from 'obsidian';
import { setIcon, setTooltip } from 'obsidian';
import { ICONS } from './icons';
export interface ActionBarProps {
hasFiles: boolean;
@ -24,15 +25,15 @@ export function renderActionBar(container: HTMLElement, props: ActionBarProps, c
if (props.hasFiles) {
bar.createDiv({ cls: 'ssv-bar-spacer' });
renderSelectAllRow(bar, props.allSelected, props.indeterminate, callbacks.onSelectAll);
renderLargeButton(bar, '↑', ` Push (${props.canPush})`, `Push ${props.canPush} files`, callbacks.onPush, 'push', props.canPush === 0);
renderLargeButton(bar, '↓', ` Pull (${props.canPull})`, `Pull ${props.canPull} files`, callbacks.onPull, 'pull', props.canPull === 0);
renderLargeButton(bar, '✕', ` Delete (${props.canDelete})`, `Delete ${props.canDelete} files`, callbacks.onDelete, 'danger', props.canDelete === 0);
renderLargeButton(bar, ICONS.push, ` Push (${props.canPush})`, `Push ${props.canPush} files`, callbacks.onPush, 'push', props.canPush === 0);
renderLargeButton(bar, ICONS.pull, ` Pull (${props.canPull})`, `Pull ${props.canPull} files`, callbacks.onPull, 'pull', props.canPull === 0);
renderLargeButton(bar, ICONS.delete, ` Delete (${props.canDelete})`, `Delete ${props.canDelete} files`, callbacks.onDelete, 'danger', props.canDelete === 0);
}
}
function renderRefreshButton(bar: HTMLElement, onRefresh: () => void): void {
const btn = bar.createEl('button', { cls: 'ssv-btn ssv-btn-refresh' });
btn.createSpan({ text: '↻' });
setIcon(btn.createSpan(), ICONS.refresh);
btn.createSpan({ cls: 'ssv-btn-label', text: ' Refresh' });
setTooltip(btn, 'Refresh all statuses');
btn.addEventListener('click', onRefresh);
@ -49,7 +50,7 @@ function renderSelectAllRow(bar: HTMLElement, allSelected: boolean, indeterminat
function renderLargeButton(container: HTMLElement, icon: string, label: string, tooltip: string, onClick: () => void, cls: string, disabled: boolean): void {
const btn = container.createEl('button', { cls: `ssv-btn ssv-btn-${cls}` });
btn.createSpan({ text: icon });
setIcon(btn.createSpan(), icon);
btn.createSpan({ cls: 'ssv-btn-label', text: label });
btn.disabled = disabled;
setTooltip(btn, tooltip);

View file

@ -1,6 +1,7 @@
import { setTooltip } from 'obsidian';
import { setIcon, setTooltip } from 'obsidian';
import { type FileStatus } from '../types';
import { renderDiffPanel } from './DiffPanel';
import { ICONS } from './icons';
export interface FileItemCallbacks {
onSelect: (path: string, selected: boolean) => void;
@ -9,13 +10,15 @@ export interface FileItemCallbacks {
onDelete: (fileStatus: FileStatus) => void;
}
// `icon` is a Lucide icon id (rendered via Obsidian's setIcon) so every status
// uses the same icon set and renders consistently across platforms.
export function statusMeta(status: FileStatus['status']) {
switch (status) {
case 'synced': return { icon: '✓', label: 'Synced', iconCls: 'ssv-icon-synced', badgeCls: 'ssv-badge-synced', fileCls: 'status-synced' };
case 'modified': return { icon: '⚠', label: 'Changed', iconCls: 'ssv-icon-modified', badgeCls: 'ssv-badge-modified', fileCls: 'status-modified' };
case 'unsynced': return { icon: '↑', label: 'Local only', iconCls: 'ssv-icon-unsynced', badgeCls: 'ssv-badge-unsynced', fileCls: 'status-unsynced' };
case 'remote-only': return { icon: '↓', label: 'Remote', iconCls: 'ssv-icon-remote', badgeCls: 'ssv-badge-remote', fileCls: 'status-remote' };
default: return { icon: '⟳', label: 'Checking', iconCls: 'ssv-icon-checking', badgeCls: 'ssv-badge-checking', fileCls: 'status-checking' };
case 'synced': return { icon: ICONS.synced, label: 'Synced', iconCls: 'ssv-icon-synced', badgeCls: 'ssv-badge-synced', fileCls: 'status-synced' };
case 'modified': return { icon: ICONS.modified, label: 'Changed', iconCls: 'ssv-icon-modified', badgeCls: 'ssv-badge-modified', fileCls: 'status-modified' };
case 'unsynced': return { icon: ICONS.push, label: 'Local only', iconCls: 'ssv-icon-unsynced', badgeCls: 'ssv-badge-unsynced', fileCls: 'status-unsynced' };
case 'remote-only': return { icon: ICONS.pull, label: 'Remote', iconCls: 'ssv-icon-remote', badgeCls: 'ssv-badge-remote', fileCls: 'status-remote' };
default: return { icon: ICONS.checking, label: 'Checking', iconCls: 'ssv-icon-checking', badgeCls: 'ssv-badge-checking', fileCls: 'status-checking' };
}
}
@ -33,7 +36,7 @@ export function renderFileItem(
cb.checked = isSelected;
cb.addEventListener('change', () => callbacks.onSelect(fileStatus.path, cb.checked));
row.createSpan({ cls: `ssv-file-icon ${iconCls}`, text: icon });
setIcon(row.createSpan({ cls: `ssv-file-icon ${iconCls}` }), icon);
row.createSpan({ cls: 'ssv-file-path', text: fileStatus.path });
row.createSpan({ cls: `ssv-status-badge ${badgeCls}`, text: label });
@ -50,21 +53,22 @@ function renderFileActions(fileEl: HTMLElement, fileStatus: FileStatus, callback
}
if (fileStatus.status === 'modified' || fileStatus.status === 'unsynced') {
renderActionBtn(actions, '↑', ' Push', 'Push to remote', () => callbacks.onPush(fileStatus), 'push');
renderActionBtn(actions, ICONS.push, ' Push', 'Push to remote', () => callbacks.onPush(fileStatus), 'push');
}
if (fileStatus.status === 'modified' || fileStatus.status === 'remote-only') {
renderActionBtn(actions, '↓', ' Pull', 'Pull from remote', () => callbacks.onPull(fileStatus), 'pull');
renderActionBtn(actions, ICONS.pull, ' Pull', 'Pull from remote', () => callbacks.onPull(fileStatus), 'pull');
}
if (fileStatus.status === 'unsynced') {
renderActionBtn(actions, '✕', ' Remove', 'Delete local file', () => callbacks.onDelete(fileStatus), 'danger');
renderActionBtn(actions, ICONS.delete, ' Remove', 'Delete local file', () => callbacks.onDelete(fileStatus), 'danger');
}
}
function renderDiffToggleButton(actions: HTMLElement, fileEl: HTMLElement, fileStatus: FileStatus): void {
const diffBtn = actions.createEl('button', { cls: 'ssv-action-btn diff' });
diffBtn.createSpan({ text: '≡' });
const iconEl = diffBtn.createSpan();
setIcon(iconEl, ICONS.diff);
const btnLabel = diffBtn.createSpan({ cls: 'ssv-btn-label', text: ' Diff' });
let diffEl: HTMLElement;
@ -80,16 +84,13 @@ function renderDiffToggleButton(actions: HTMLElement, fileEl: HTMLElement, fileS
const open = diffEl.hasClass('visible');
diffEl.toggleClass('visible', !open);
btnLabel.setText(open ? ' Diff' : ' Hide');
const firstChild = diffBtn.firstChild;
if (firstChild instanceof HTMLElement || firstChild instanceof Text) {
firstChild.textContent = open ? '≡' : '▴';
}
setIcon(iconEl, open ? ICONS.diff : ICONS.diffOpen);
});
}
function renderActionBtn(actions: HTMLElement, icon: string, label: string, tooltip: string, onClick: () => void, cls: string): void {
const btn = actions.createEl('button', { cls: `ssv-action-btn ${cls}` });
btn.createSpan({ text: icon });
setIcon(btn.createSpan(), icon);
btn.createSpan({ cls: 'ssv-btn-label', text: label });
setTooltip(btn, tooltip);
btn.addEventListener('click', onClick);

View file

@ -0,0 +1,21 @@
// Single source of truth for every icon used in the Sync Status view.
//
// Values are Lucide icon ids rendered through Obsidian's `setIcon`, so all
// icons share one visual style (stroke weight, size, baseline) and stay
// consistent across desktop and mobile. Reuse these constants everywhere a
// status or action icon is shown instead of inlining Unicode glyphs.
export const ICONS = {
// Status / action
synced: 'check',
modified: 'pencil',
push: 'arrow-up',
pull: 'arrow-down',
checking: 'refresh-cw',
refresh: 'refresh-cw',
delete: 'trash-2',
diff: 'file-diff',
diffOpen: 'chevron-up',
// Info strip
branch: 'git-branch',
folder: 'folder',
} as const;

View file

@ -3,4 +3,5 @@ const PREFIX = '[git-file-sync]';
export const logger = {
error: (message: string, ...args: unknown[]) => console.error(`${PREFIX} ${message}`, ...args),
warn: (message: string, ...args: unknown[]) => console.warn(`${PREFIX} ${message}`, ...args),
debug: (message: string, ...args: unknown[]) => console.debug(`${PREFIX} ${message}`, ...args),
};

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 = (window 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

@ -295,11 +295,11 @@
}
.ssv-file-icon {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
text-align: center;
flex-shrink: 0;
font-size: 0.95em;
font-weight: 600;
}
.ssv-icon-synced { color: var(--color-green); }
@ -346,6 +346,9 @@
}
.ssv-action-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
font-size: 0.76em;
border-radius: 4px;
@ -358,6 +361,29 @@
transition: background 0.1s, opacity 0.1s;
}
/* Consistent sizing for all Lucide icons used in the view */
.ssv-file-icon .svg-icon {
width: 16px;
height: 16px;
}
.ssv-btn .svg-icon,
.ssv-tab .svg-icon,
.ssv-action-btn .svg-icon {
width: 15px;
height: 15px;
}
.ssv-info-icon {
display: inline-flex;
align-items: center;
}
.ssv-info-icon .svg-icon {
width: 13px;
height: 13px;
}
.is-mobile .ssv-action-btn {
padding: 8px 15px;
font-size: 0.85em;

View file

@ -151,6 +151,62 @@ describe('SyncManager Batch Operations', () => {
});
});
describe('batch conflict detection', () => {
it('should skip (not overwrite) a push when the remote has moved on since last sync', async () => {
const path = 'conflicted.md';
mockSettings.syncMetadata = {
[path]: { lastSyncedSha: 'sha-at-last-sync', lastSyncedAt: 0, lastKnownPath: path }
};
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
vi.mocked(adapter.exists).mockResolvedValue(true);
vi.mocked(adapter.read).mockResolvedValue('local edit');
// Remote sha differs from what we last synced, and content differs too.
vi.mocked(mockGitService.getFile).mockResolvedValue({ content: 'remote edit', sha: 'sha-changed-on-remote' });
const results = await manager.pushAllFiles([path]);
expect(results.success).toBe(0);
expect(results.conflicts).toBe(1);
expect(results.failed).toBe(0);
expect(mockGitService.pushFile).not.toHaveBeenCalled();
});
it('should skip (not overwrite) a pull when the local file has diverged since last sync', async () => {
const path = 'conflicted.md';
mockSettings.syncMetadata = {
[path]: { lastSyncedSha: 'sha-at-last-sync', lastSyncedAt: 0, lastKnownPath: path }
};
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
vi.mocked(adapter.exists).mockResolvedValue(true);
vi.mocked(adapter.read).mockResolvedValue('local edit');
vi.mocked(mockGitService.getFile).mockResolvedValue({ content: 'remote edit', sha: 'sha-changed-on-remote' });
const results = await manager.pullAllFiles([path]);
expect(results.success).toBe(0);
expect(results.conflicts).toBe(1);
expect(results.failed).toBe(0);
expect(mockApp.vault.adapter.write).not.toHaveBeenCalled();
});
it('should still push normally when there is no prior sync metadata (not a conflict)', async () => {
const path = 'new-file.md';
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
vi.mocked(adapter.exists).mockResolvedValue(true);
vi.mocked(adapter.read).mockResolvedValue('local content');
vi.mocked(mockGitService.getFile).mockResolvedValue({ content: 'remote content', sha: 'some-sha' });
vi.mocked(mockGitService.pushFile).mockResolvedValue({ path, sha: 'new-sha' });
const results = await manager.pushAllFiles([path]);
expect(results.success).toBe(1);
expect(results.conflicts).toBe(0);
});
});
describe('batch push with rename detection', () => {
it('should detect and handle rename during batch push', async () => {
const oldPath = 'old.md';
@ -164,14 +220,72 @@ describe('SyncManager Batch Operations', () => {
vi.mocked(mockApp.vault.read).mockResolvedValue('content');
vi.mocked(mockApp.vault.adapter.exists as ReturnType<typeof vi.fn>).mockResolvedValue(true);
vi.mocked(mockGitService.pushFile).mockResolvedValue({ path: newPath, sha: 'new-sha' });
vi.mocked(mockGitService.getFile).mockResolvedValue({ content: 'content', sha: 'new-sha' });
vi.mocked(mockGitService.getFile).mockImplementation(async (path) => {
// Remote still has the old path with matching content: confirms a real rename.
if (path === oldPath) return { content: 'content', sha: 'sha' };
// New path does not exist on the remote yet.
return { content: '', sha: '' };
});
const results = await manager.pushAllFiles([mockFile]);
expect(results.success).toBe(1);
expect(mockGitService.pushFile).toHaveBeenCalledWith(
newPath, 'content', 'main', `Rename ${oldPath} to ${newPath}`, undefined
newPath, 'content', 'main', `Rename ${oldPath} to ${newPath}`, ''
);
});
it('should send existing sha when rename target already exists remotely (avoids 422)', async () => {
const oldPath = 'old.md';
const newPath = 'new.md';
const mockFile = Object.assign(new TFile(), { path: newPath, name: 'new.md' });
mockSettings.syncMetadata = {
[oldPath]: { lastSyncedSha: 'sha', lastSyncedAt: 0, lastKnownPath: oldPath }
};
vi.mocked(mockApp.vault.getFileByPath).mockImplementation(p => p === oldPath ? null : mockFile);
vi.mocked(mockApp.vault.read).mockResolvedValue('content');
vi.mocked(mockApp.vault.adapter.exists as ReturnType<typeof vi.fn>).mockResolvedValue(true);
vi.mocked(mockGitService.pushFile).mockResolvedValue({ path: newPath, sha: 'new-sha' });
vi.mocked(mockGitService.getFile).mockImplementation(async (path) => {
// Remote still has the old path with matching content: confirms a real rename.
if (path === oldPath) return { content: 'content', sha: 'sha' };
// A file already exists on the remote at the new path.
return { content: 'old remote content', sha: 'remote-existing-sha' };
});
const results = await manager.pushAllFiles([mockFile]);
expect(results.success).toBe(1);
expect(mockGitService.pushFile).toHaveBeenCalledWith(
newPath, 'content', 'main', `Rename ${oldPath} to ${newPath}`, 'remote-existing-sha'
);
});
it('does not misclassify an unrelated push as a rename just because an orphaned metadata entry exists', async () => {
const orphanedPath = 'deleted-unrelated-note.md';
const pushedPath = 'unrelated.md';
const mockFile = Object.assign(new TFile(), { path: pushedPath, name: 'unrelated.md' });
mockSettings.syncMetadata = {
[orphanedPath]: { lastSyncedSha: 'orphaned-sha', lastSyncedAt: 0, lastKnownPath: orphanedPath }
};
vi.mocked(mockApp.vault.getFileByPath).mockImplementation(p => p === orphanedPath ? null : mockFile);
vi.mocked(mockApp.vault.read).mockResolvedValue('unrelated content');
vi.mocked(mockApp.vault.adapter.exists as ReturnType<typeof vi.fn>).mockResolvedValue(true);
vi.mocked(mockGitService.pushFile).mockResolvedValue({ path: pushedPath, sha: 'new-sha' });
vi.mocked(mockGitService.getFile).mockImplementation(async (path) => {
if (path === orphanedPath) return { content: 'totally different content', sha: 'orphaned-sha' };
return { content: 'old content', sha: 'remote-sha' };
});
const results = await manager.pushAllFiles([mockFile]);
expect(results.success).toBe(1);
expect(mockGitService.pushFile).toHaveBeenCalledWith(
pushedPath, 'unrelated content', 'main', `Update ${mockFile.name} from Obsidian`, 'remote-sha'
);
expect(mockSettings.syncMetadata[orphanedPath]).toBeDefined();
});
});
});

View file

@ -44,10 +44,15 @@ const mockSettings: GitLabFilesPushSettings = {
githubToken: '',
githubOwner: '',
githubRepo: '',
giteaToken: '',
giteaBaseUrl: '',
giteaOwner: '',
giteaRepo: '',
branch: 'main',
rootPath: 'notes',
vaultFolder: 'Work',
syncMetadata: {},
symlinkHandling: 'real',
};
describe('SyncManager Mapping', () => {

View file

@ -51,10 +51,15 @@ const mockSettings: GitLabFilesPushSettings = {
githubToken: '',
githubOwner: '',
githubRepo: '',
giteaToken: '',
giteaBaseUrl: '',
giteaOwner: '',
giteaRepo: '',
branch: 'main',
rootPath: '',
syncMetadata: {},
vaultFolder: ''
vaultFolder: '',
symlinkHandling: 'real'
};
describe('SyncManager', () => {
@ -88,6 +93,38 @@ describe('SyncManager', () => {
);
});
it('falls back to the adapter when vault.read fails (e.g. symlinked file)', async () => {
const mockFile = Object.assign(new TFile(), { path: 'link.md', name: 'link.md' });
const readSpy = vi.spyOn(mockApp.vault, 'read').mockRejectedValue(new Error('EINVAL: symlink'));
const adapterReadSpy = vi.spyOn(mockApp.vault.adapter, 'read').mockResolvedValue('linked content');
vi.spyOn(mockGitLab, 'getFile').mockResolvedValue({ content: 'different content', sha: 'old-sha' });
const pushSpy = vi.spyOn(mockGitLab, 'pushFile').mockResolvedValue({ path: 'link.md', sha: 'new-sha' });
await manager.pushFile(mockFile);
expect(readSpy).toHaveBeenCalledWith(mockFile);
expect(adapterReadSpy).toHaveBeenCalledWith('link.md');
expect(pushSpy).toHaveBeenCalledWith(
'link.md',
'linked content',
'main',
'Update link.md from Obsidian',
'old-sha'
);
});
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' });
@ -271,6 +308,12 @@ describe('SyncManager', () => {
});
vi.spyOn(mockApp.vault, 'read').mockResolvedValue('content');
vi.spyOn(mockGitLab, 'getFile').mockImplementation(async (path) => {
// Remote still has the old path with the same content: confirms a real rename.
if (path === oldPath) return { content: 'content', sha: 'old-sha' };
// New path does not exist on the remote yet.
return { content: '', sha: '' };
});
vi.spyOn(mockGitLab, 'pushFile').mockResolvedValue({ path: newPath, sha: 'new-sha' });
await manager.pushFile(mockFile);
@ -280,11 +323,95 @@ describe('SyncManager', () => {
'content',
'main',
`Rename ${oldPath} to ${newPath}`,
undefined
''
);
expect(mockSettings.syncMetadata[oldPath]).toBeUndefined();
expect(mockSettings.syncMetadata[newPath]?.lastSyncedSha).toBe('new-sha');
});
it('should send the existing sha when the renamed-to path already exists remotely (avoids 422 "file already exists")', async () => {
const oldPath = 'old.md';
const newPath = 'new.md';
const mockFile = Object.assign(new TFile(), { path: newPath, name: 'new.md' });
mockSettings.syncMetadata[oldPath] = {
lastSyncedSha: 'old-sha',
lastSyncedAt: Date.now(),
lastKnownPath: oldPath
};
vi.spyOn(mockApp.vault, 'getFileByPath').mockImplementation((path) => {
if (path === oldPath) return null;
if (path === newPath) return mockFile;
return null;
});
vi.spyOn(mockApp.vault, 'read').mockResolvedValue('content');
vi.spyOn(mockGitLab, 'getFile').mockImplementation(async (path) => {
// Remote still has the old path with matching content: confirms a real rename.
if (path === oldPath) return { content: 'content', sha: 'old-sha' };
// A file already exists on the remote at the new path (e.g. from a prior push).
return { content: 'old remote content', sha: 'remote-existing-sha' };
});
vi.spyOn(mockGitLab, 'pushFile').mockResolvedValue({ path: newPath, sha: 'new-sha' });
await manager.pushFile(mockFile);
expect(mockGitLab.pushFile).toHaveBeenCalledWith(
newPath,
'content',
'main',
`Rename ${oldPath} to ${newPath}`,
'remote-existing-sha'
);
expect(mockSettings.syncMetadata[oldPath]).toBeUndefined();
expect(mockSettings.syncMetadata[newPath]?.lastSyncedSha).toBe('new-sha');
});
it('does not misclassify an unrelated push as a rename just because an orphaned metadata entry exists', async () => {
// Regression test: a local delete that never cleared its syncMetadata entry
// used to make detectRename treat ANY later, unrelated push as "renamed from"
// that orphaned path -- because it only checked "does the old path's file no
// longer exist in the vault", without verifying the content actually matches.
const orphanedPath = 'deleted-unrelated-note.md';
const pushedPath = 'shinyi-muyu-tutorial.md';
const mockFile = Object.assign(new TFile(), { path: pushedPath, name: 'shinyi-muyu-tutorial.md' });
mockSettings.syncMetadata[orphanedPath] = {
lastSyncedSha: 'orphaned-sha',
lastSyncedAt: Date.now(),
lastKnownPath: orphanedPath
};
// The orphaned file is gone from the vault (it was deleted, not renamed).
vi.spyOn(mockApp.vault, 'getFileByPath').mockImplementation((path) => {
if (path === orphanedPath) return null;
if (path === pushedPath) return mockFile;
return null;
});
vi.spyOn(mockApp.vault, 'read').mockResolvedValue('unrelated content');
vi.spyOn(mockGitLab, 'getFile').mockImplementation(async (path) => {
// The orphaned path's remote content is unrelated to what's being pushed now.
if (path === orphanedPath) return { content: 'totally different content', sha: 'orphaned-sha' };
// Normal push target: already exists remotely with older content.
return { content: 'old content', sha: 'remote-sha' };
});
vi.spyOn(mockGitLab, 'pushFile').mockResolvedValue({ path: pushedPath, sha: 'new-sha' });
await manager.pushFile(mockFile);
// Must be treated as a normal update, not a rename from the orphaned path.
expect(mockGitLab.pushFile).toHaveBeenCalledWith(
pushedPath,
'unrelated content',
'main',
`Update ${mockFile.name} from Obsidian`,
'remote-sha'
);
// The orphaned entry must be left alone -- it wasn't the source of this push.
expect(mockSettings.syncMetadata[orphanedPath]).toBeDefined();
});
});
describe('Error Handling', () => {
@ -309,6 +436,11 @@ describe('SyncManager', () => {
vi.spyOn(mockApp.vault, 'getFileByPath').mockImplementation(p => p === oldPath ? null : mockFile);
vi.spyOn(mockApp.vault, 'read').mockResolvedValue('c');
vi.spyOn(mockGitLab, 'getFile').mockImplementation(async (path) => {
// Remote still has the old path with matching content: confirms a real rename.
if (path === oldPath) return { content: 'c', sha: 's' };
return { content: '', sha: '' };
});
vi.spyOn(mockGitLab, 'pushFile').mockRejectedValue(new Error('Rename failed'));
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

View file

@ -30,6 +30,42 @@ describe('BaseGitService', () => {
});
});
describe('safeRequest 404 handling', () => {
it('getFile returns empty and does not log an error on 404 (e.g. missing .gitignore)', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});
vi.mocked(requestUrl).mockResolvedValue({
status: 404,
text: 'Not Found',
json: { message: 'Not Found' },
} as unknown as RequestUrlResponse);
const result = await service.getFile('missing/.gitignore', 'main');
expect(result).toEqual({ content: '', sha: '' });
// A 404 is an expected "does not exist" probe: never logged as an error…
expect(errorSpy).not.toHaveBeenCalled();
// …and logged at most once at debug level (no double-logging).
expect(debugSpy).toHaveBeenCalledTimes(1);
errorSpy.mockRestore();
debugSpy.mockRestore();
});
it('still logs non-404 failures as errors', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.mocked(requestUrl).mockResolvedValue({
status: 500,
text: 'Internal Server Error',
json: { message: 'Internal Server Error' },
} as unknown as RequestUrlResponse);
await expect(service.listFiles('main')).rejects.toThrow('500');
expect(errorSpy).toHaveBeenCalledTimes(1);
errorSpy.mockRestore();
});
});
describe('safeRequest with non-Error exception', () => {
it('should wrap non-Error throws in a new Error', async () => {
// Throw a plain string (not an Error instance) from requestUrl
@ -41,6 +77,71 @@ describe('BaseGitService', () => {
});
});
describe('parseJson with non-JSON (HTML) responses', () => {
// Simulates Obsidian's real RequestUrlResponse.json getter, which throws
// SyntaxError when the body is not valid JSON (e.g. an HTML page).
function htmlResponse(status = 200): RequestUrlResponse {
return {
status,
headers: { 'content-type': 'text/html; charset=utf-8' },
text: '<!DOCTYPE html><html><body>Login</body></html>',
get json(): unknown {
throw new SyntaxError('Unexpected token \'<\', "<!DOCTYPE "... is not valid JSON');
},
} as unknown as RequestUrlResponse;
}
it('getFile: throws a clear error when the server returns an HTML page (2xx)', async () => {
vi.mocked(requestUrl).mockResolvedValue(htmlResponse(200));
await expect(service.getFile('test.md', 'main')).rejects.toThrow(/received an HTML page/);
// The cryptic JSON parse error must not leak through.
await expect(service.getFile('test.md', 'main')).rejects.not.toThrow(/Unexpected token/);
});
it('listFiles: throws a clear error when the server returns an HTML page (2xx)', async () => {
vi.mocked(requestUrl).mockResolvedValue(htmlResponse(200));
await expect(service.listFiles('main')).rejects.toThrow(/received an HTML page/);
});
it('detects HTML by leading "<" even without an html content-type', async () => {
vi.mocked(requestUrl).mockResolvedValue({
status: 200,
headers: {},
text: ' <!DOCTYPE html>...',
get json(): unknown {
throw new SyntaxError("Unexpected token '<'");
},
} as unknown as RequestUrlResponse);
await expect(service.listFiles('main')).rejects.toThrow(/received an HTML page/);
});
it('reports a generic JSON parse failure for malformed (non-HTML) JSON', async () => {
vi.mocked(requestUrl).mockResolvedValue({
status: 200,
headers: { 'content-type': 'application/json' },
text: '{ "tree": [',
get json(): unknown {
throw new SyntaxError('Unexpected end of JSON input');
},
} as unknown as RequestUrlResponse);
await expect(service.listFiles('main')).rejects.toThrow(/Failed to parse the Git server response as JSON/);
});
it('parseErrorResponse: surfaces a clear message for an HTML error page (>=400)', async () => {
vi.mocked(requestUrl).mockResolvedValue({
status: 502,
headers: { 'content-type': 'text/html' },
text: '<!DOCTYPE html><html><body>Bad Gateway</body></html>',
get json(): unknown {
throw new SyntaxError("Unexpected token '<'");
},
} as unknown as RequestUrlResponse);
await expect(service.listFiles('main')).rejects.toThrow(/Received an HTML page instead of a JSON error/);
// The raw HTML document must not be dumped into the message.
await expect(service.listFiles('main')).rejects.not.toThrow(/DOCTYPE/);
});
});
describe('encodeContent / decodeContent round-trip', () => {
it('should correctly encode and decode UTF-8 content', async () => {
const original = 'Hello, 世界! 🌍';

View file

@ -0,0 +1,256 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GiteaService } from '../../src/services/gitea-service';
import { requestUrl, RequestUrlResponse, RequestUrlParam } from 'obsidian';
import { getLastRequestCall, mockRequest, sharedTestConnection, sharedGetFileErrorHandling } from './service-test-helpers';
describe('GiteaService', () => {
let service: GiteaService;
const baseUrl = 'https://gitea.example.com';
const token = 'test-token';
const owner = 'test-owner';
const repo = 'test-repo';
beforeEach(() => {
vi.clearAllMocks();
service = new GiteaService();
service.updateConfig(baseUrl, token, owner, repo);
});
describe('updateConfig', () => {
it('should strip trailing slash from baseUrl', async () => {
service.updateConfig('https://gitea.example.com/', token, owner, repo);
mockRequest({ status: 200, json: { content: btoa('hello'), sha: 'sha' } });
await service.getFile('test.md', 'main');
const call = getLastRequestCall();
expect(call.url).not.toContain('example.com//api');
expect(call.url).toContain('example.com/api/v1');
});
});
describe('getFile', () => {
it('should fetch and decode file content correctly', async () => {
mockRequest({ status: 200, json: { content: btoa('hello world'), sha: 'test-sha' } });
const result = await service.getFile('test.md', 'main');
expect(result.content).toBe('hello world');
expect(result.sha).toBe('test-sha');
});
it('should call the correct Gitea contents API URL', async () => {
mockRequest({ status: 200, json: { content: btoa('data'), sha: 'sha1' } });
await service.getFile('notes/hello.md', 'main');
const call = getLastRequestCall();
expect(call.url).toBe(`${baseUrl}/api/v1/repos/${owner}/${repo}/contents/notes/hello.md?ref=main`);
});
it('should use Authorization token header', async () => {
mockRequest({ status: 200, json: { content: btoa('data'), sha: 'sha1' } });
await service.getFile('test.md', 'main');
const call = getLastRequestCall();
expect(call.headers).toMatchObject({ 'Authorization': `token ${token}` });
});
it('should handle 404 correctly and return empty content', async () => {
mockRequest({ status: 404, json: { message: 'Not Found' }, text: 'Not Found' });
const result = await service.getFile('missing.md', 'main');
expect(result.content).toBe('');
expect(result.sha).toBe('');
});
it('should bypass rootPath when path starts with / (absolute repo path)', async () => {
service.updateConfig(baseUrl, token, owner, repo, 'vault');
mockRequest({ status: 200, json: { content: btoa('root content'), sha: 'root-sha' } });
await service.getFile('/.gitignore', 'main');
const call = getLastRequestCall();
expect(call.url).toContain('/contents/.gitignore');
expect(call.url).not.toContain('/contents/vault/.gitignore');
});
it('should not double-prefix when path already starts with rootPath', async () => {
service.updateConfig(baseUrl, token, owner, repo, 'src/content');
mockRequest({ status: 200, json: { content: btoa('hello'), sha: 'sha' } });
await service.getFile('src/content/index.md', 'main');
const call = getLastRequestCall();
expect(call.url).toContain('/contents/src/content/index.md');
expect(call.url).not.toContain('/contents/src/content/src/content/index.md');
});
it('should prepend rootPath when set', async () => {
service.updateConfig(baseUrl, token, owner, repo, 'vault');
mockRequest({ status: 200, json: { content: btoa('data'), sha: 'sha' } });
await service.getFile('notes.md', 'main');
const call = getLastRequestCall();
expect(call.url).toContain('/contents/vault/notes.md');
});
});
describe('pushFile', () => {
it('should create new file with POST when no sha provided', async () => {
mockRequest({ status: 201, json: { content: { path: 'new.md', sha: 'new-sha' } } });
const result = await service.pushFile('new.md', 'new content', 'main', 'create');
expect(result).toEqual({ path: 'new.md', sha: 'new-sha' });
const call = getLastRequestCall();
expect(call.method).toBe('POST');
});
it('should update existing file with PUT when sha provided', async () => {
mockRequest({ status: 200, json: { content: { path: 'existing.md', sha: 'updated-sha' } } });
const result = await service.pushFile('existing.md', 'updated content', 'main', 'update', 'old-sha');
expect(result).toEqual({ path: 'existing.md', sha: 'updated-sha' });
const call = getLastRequestCall();
expect(call.method).toBe('PUT');
expect(call.body).toContain('"sha":"old-sha"');
});
it('should send correct content URL for push', async () => {
mockRequest({ status: 201, json: { content: { path: 'notes/test.md', sha: 'sha' } } });
await service.pushFile('notes/test.md', 'content', 'main', 'commit');
const call = getLastRequestCall();
expect(call.url).toBe(`${baseUrl}/api/v1/repos/${owner}/${repo}/contents/notes/test.md`);
});
it('should base64 encode the file content', async () => {
mockRequest({ status: 201, json: { content: { path: 'test.md', sha: 'sha' } } });
await service.pushFile('test.md', 'hello world', 'main', 'add file');
const call = getLastRequestCall();
const body = JSON.parse(call.body as string) as { content: string };
expect(atob(body.content)).toContain('hello world');
});
});
describe('listFiles', () => {
const commitSha = 'abc123commit';
function mockListFiles(treeItems: { path: string; type: string }[], truncated = false): void {
vi.mocked(requestUrl)
.mockResolvedValueOnce({ status: 200, json: { commit: { id: commitSha } } } as unknown as RequestUrlResponse)
.mockResolvedValueOnce({ status: 200, json: { tree: treeItems, truncated } } as unknown as RequestUrlResponse);
}
it('should first resolve branch to commit SHA then fetch tree', async () => {
mockListFiles([]);
await service.listFiles('main');
const calls = vi.mocked(requestUrl).mock.calls;
expect(calls).toHaveLength(2);
expect((calls[0]?.[0] as RequestUrlParam).url).toBe(`${baseUrl}/api/v1/repos/${owner}/${repo}/branches/main`);
expect((calls[1]?.[0] as RequestUrlParam).url).toBe(`${baseUrl}/api/v1/repos/${owner}/${repo}/git/trees/${commitSha}?recursive=1`);
});
it('should return only blob files from tree', async () => {
mockListFiles([
{ path: 'file1.md', type: 'blob' },
{ path: 'dir/file2.md', type: 'blob' },
{ path: 'subdir', type: 'tree' },
]);
expect(await service.listFiles('main')).toEqual(['file1.md', 'dir/file2.md']);
});
it('should filter by rootPath when set', async () => {
service.updateConfig(baseUrl, token, owner, repo, 'vault');
mockListFiles([
{ path: 'vault/file1.md', type: 'blob' },
{ path: 'other/file2.md', type: 'blob' },
]);
expect(await service.listFiles('main')).toEqual(['vault/file1.md']);
});
it('should not match sibling paths with same prefix as rootPath', async () => {
service.updateConfig(baseUrl, token, owner, repo, 'src/content');
mockListFiles([
{ path: 'src/content/index.md', type: 'blob' },
{ path: 'src/content.config.ts', type: 'blob' },
{ path: 'src/contentful.ts', type: 'blob' },
]);
expect(await service.listFiles('main')).toEqual(['src/content/index.md']);
});
it('should return all files when useFilter is false regardless of rootPath', async () => {
service.updateConfig(baseUrl, token, owner, repo, 'vault');
mockListFiles([
{ path: 'vault/file1.md', type: 'blob' },
{ path: 'other/file2.md', type: 'blob' },
]);
expect(await service.listFiles('main', false)).toEqual(['vault/file1.md', 'other/file2.md']);
});
it('should log warning and return files when result is truncated', async () => {
mockListFiles([{ path: 'file1.md', type: 'blob' }], true);
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = await service.listFiles('main');
expect(result).toEqual(['file1.md']);
warnSpy.mockRestore();
});
it('should throw a message naming the branch when the branch is not found', async () => {
mockRequest({ status: 404, json: { message: 'branch does not exist' }, text: 'branch does not exist' });
await expect(service.listFiles('missing-branch')).rejects.toThrow(/Branch "missing-branch" was not found/);
});
});
describe('deleteFile', () => {
it('should first get file sha then send DELETE request', async () => {
vi.mocked(requestUrl)
.mockResolvedValueOnce({ status: 200, json: { content: btoa('content'), sha: 'file-sha' } } as unknown as RequestUrlResponse)
.mockResolvedValueOnce({ status: 200, json: {} } as unknown as RequestUrlResponse);
await service.deleteFile('test.md', 'main', 'delete test.md');
const calls = vi.mocked(requestUrl).mock.calls;
expect(calls).toHaveLength(2);
const deleteCall = calls[1]?.[0] as RequestUrlParam;
expect(deleteCall.method).toBe('DELETE');
expect(deleteCall.body).toContain('"sha":"file-sha"');
expect(deleteCall.body).toContain('"message":"delete test.md"');
expect(deleteCall.body).toContain('"branch":"main"');
});
it('should use correct delete URL', async () => {
vi.mocked(requestUrl)
.mockResolvedValueOnce({ status: 200, json: { content: btoa('content'), sha: 'sha' } } as unknown as RequestUrlResponse)
.mockResolvedValueOnce({ status: 200, json: {} } as unknown as RequestUrlResponse);
await service.deleteFile('notes/test.md', 'main', 'remove');
const calls = vi.mocked(requestUrl).mock.calls;
const deleteCall = calls[1]?.[0] as RequestUrlParam;
expect(deleteCall.url).toBe(`${baseUrl}/api/v1/repos/${owner}/${repo}/contents/notes/test.md`);
});
});
describe('testConnection', () => {
sharedTestConnection(() => service);
it('should call the correct repo API URL', async () => {
mockRequest({ status: 200, json: {} });
await service.testConnection('main');
const calls = vi.mocked(requestUrl).mock.calls;
const firstCall = calls[0]?.[0] as RequestUrlParam;
expect(firstCall.url).toBe(`${baseUrl}/api/v1/repos/${owner}/${repo}`);
});
it('should report branchOk: false when the branch is not found', async () => {
vi.mocked(requestUrl)
.mockResolvedValueOnce({ status: 200, json: {} } as unknown as RequestUrlResponse)
.mockResolvedValueOnce({ status: 404, json: { message: 'not found' }, text: 'not found' } as unknown as RequestUrlResponse);
const result = await service.testConnection('missing-branch');
expect(result).toEqual({ repoOk: true, branchOk: false });
});
});
describe('getRepoGitignores', () => {
it('should return only .gitignore paths from file list', async () => {
const items = [
{ path: '.gitignore', type: 'blob' },
{ path: 'src/main.ts', type: 'blob' },
{ path: 'sub/.gitignore', type: 'blob' },
];
vi.mocked(requestUrl)
.mockResolvedValueOnce({ status: 200, json: { commit: { id: 'sha123' } } } as unknown as RequestUrlResponse)
.mockResolvedValueOnce({ status: 200, json: { tree: items, truncated: false } } as unknown as RequestUrlResponse);
expect(await service.getRepoGitignores('main')).toEqual(['.gitignore', 'sub/.gitignore']);
});
});
describe('getFile error handling', () => {
sharedGetFileErrorHandling(() => service);
});
});

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({
@ -70,6 +113,21 @@ describe('GitHubService', () => {
expect(call.body).not.toContain('"sha":');
});
it('should omit blank sha so creating a new file does not 422', async () => {
// A 404 lookup yields sha === '' for new files; an empty sha sent to
// GitHub causes HTTP 422, so it must be dropped from the request body.
vi.mocked(requestUrl).mockResolvedValueOnce({
status: 201,
json: { content: { path: 'new.md', sha: 'new-sha' } }
} as unknown as RequestUrlResponse);
const result = await service.pushFile('new.md', 'content', 'main', 'create', '');
expect(result).toEqual({ path: 'new.md', sha: 'new-sha' });
const call = getLastRequestCall();
expect(call.body).not.toContain('"sha":');
});
it('should update existing file correctly (sha provided)', async () => {
mockRequest({ status: 200, json: { content: { path: 'existing.md', sha: 'updated-sha' } } });
@ -101,6 +159,18 @@ describe('GitHubService', () => {
expect(await service.listFiles('main')).toEqual(['vault/file1.md']);
});
it('listFilesDetailed flags symlinks (mode 120000)', async () => {
mockRequest({ status: 200, json: { tree: [
{ path: 'real.md', type: 'blob', mode: '100644' },
{ path: 'link.md', type: 'blob', mode: '120000' },
{ path: 'dir', type: 'tree', mode: '040000' },
] } });
expect(await service.listFilesDetailed('main')).toEqual([
{ path: 'real.md', symlink: false },
{ path: 'link.md', symlink: true },
]);
});
it('should not match sibling paths with same prefix as rootPath', async () => {
service.updateConfig(token, owner, repo, 'src/content');
mockRequest({ status: 200, json: { tree: [
@ -121,6 +191,11 @@ describe('GitHubService', () => {
expect(result).toEqual(['file1.md', 'file2.md']);
warnSpy.mockRestore();
});
it('should throw a message naming the branch when the branch is not found', async () => {
mockRequest({ status: 404, json: { message: 'Not Found' }, text: 'Not Found' });
await expect(service.listFiles('missing-branch')).rejects.toThrow(/Branch "missing-branch" was not found/);
});
});
describe('deleteFile', () => {
@ -141,6 +216,14 @@ describe('GitHubService', () => {
describe('testConnection', () => {
sharedTestConnection(() => service);
it('should report branchOk: false when the branch is not found', async () => {
vi.mocked(requestUrl)
.mockResolvedValueOnce({ status: 200, json: {} } as unknown as RequestUrlResponse)
.mockResolvedValueOnce({ status: 404, json: { message: 'Not Found' }, text: 'Not Found' } as unknown as RequestUrlResponse);
const result = await service.testConnection('missing-branch');
expect(result).toEqual({ repoOk: true, branchOk: false });
});
});
describe('getRepoGitignores', () => {

View file

@ -50,6 +50,14 @@ describe('GitLabService', () => {
expect(call.body).toContain(btoa('new content'));
});
it('should treat a blank sha as a new file (POST, no last_commit_id)', async () => {
mockRequest({ status: 201, json: { file_path: 'test.md' } });
await service.pushFile('test.md', 'new content', 'main', 'initial commit', '');
const call = getLastRequestCall();
expect(call.method).toBe('POST');
expect(call.body).not.toContain('last_commit_id');
});
it('should push file content correctly (PUT for existing file)', async () => {
mockRequest({ status: 200, json: { file_path: 'test.md' } });
const result = await service.pushFile('test.md', 'updated content', 'main', 'update', 'old-sha');
@ -79,6 +87,17 @@ describe('GitLabService', () => {
expect(await service.listFiles('main')).toEqual(['vault/file1.md']);
});
it('listFilesDetailed flags symlinks (mode 120000)', async () => {
mockRequest({ status: 200, json: [
{ path: 'real.md', type: 'blob', mode: '100644' },
{ path: 'link.md', type: 'blob', mode: '120000' },
] });
expect(await service.listFilesDetailed('main')).toEqual([
{ path: 'real.md', symlink: false },
{ path: 'link.md', symlink: true },
]);
});
it('should not match sibling paths with same prefix as rootPath', async () => {
service.updateConfig(baseUrl, token, projectId, 'src/content');
mockRequest({ status: 200, json: [
@ -124,6 +143,11 @@ describe('GitLabService', () => {
expect(result).toHaveLength(142);
expect(vi.mocked(requestUrl)).toHaveBeenCalledTimes(2);
});
it('should throw a message naming the branch when the branch is not found', async () => {
mockRequest({ status: 404, json: { message: '404 Branch Not Found' }, text: '404 Branch Not Found' });
await expect(service.listFiles('missing-branch')).rejects.toThrow(/Branch "missing-branch" was not found/);
});
});
describe('deleteFile', () => {
@ -138,6 +162,14 @@ describe('GitLabService', () => {
describe('testConnection', () => {
sharedTestConnection(() => service);
it('should report branchOk: false when the branch is not found', async () => {
vi.mocked(requestUrl)
.mockResolvedValueOnce({ status: 200, json: {} } as unknown as RequestUrlResponse)
.mockResolvedValueOnce({ status: 404, json: { message: 'Branch Not Found' }, text: 'Branch Not Found' } as unknown as RequestUrlResponse);
const result = await service.testConnection('missing-branch');
expect(result).toEqual({ repoOk: true, branchOk: false });
});
});
describe('getRepoGitignores', () => {

View file

@ -14,14 +14,16 @@ export function mockRequest(response: Partial<RequestUrlResponse>): void {
}
export function sharedTestConnection(getService: () => GitServiceInterface): void {
it('should return true on successful connection', async () => {
it('should report repoOk and branchOk on successful connection', async () => {
mockRequest({ status: 200, json: {} });
expect(await getService().testConnection()).toBe(true);
expect(await getService().testConnection('main')).toEqual({ repoOk: true, branchOk: true });
});
it('should return false on failed connection', async () => {
it('should report repoOk: false on failed connection', async () => {
mockRequest({ status: 401, json: { message: 'Unauthorized' }, text: 'Unauthorized' });
expect(await getService().testConnection()).toBe(false);
const result = await getService().testConnection('main');
expect(result.repoOk).toBe(false);
expect(result.branchOk).toBe(false);
});
}

View file

@ -56,6 +56,7 @@ export const App = class {
export const TFile = class {};
export const requestUrl = vi.fn();
export const setTooltip = vi.fn();
export const setIcon = vi.fn();
vi.mock('obsidian', () => ({
Plugin,
@ -69,4 +70,5 @@ vi.mock('obsidian', () => ({
TFile,
requestUrl,
setTooltip,
setIcon,
}));

View file

@ -14,11 +14,11 @@ function makeFileStatus(status: FileStatus['status'], overrides?: Partial<FileSt
describe('statusMeta', () => {
it.each([
['synced', '✓', 'Synced', 'status-synced'],
['modified', '⚠', 'Changed', 'status-modified'],
['unsynced', '↑', 'Local only', 'status-unsynced'],
['remote-only', '', 'Remote', 'status-remote'],
['checking', '', 'Checking', 'status-checking'],
['synced', 'check', 'Synced', 'status-synced'],
['modified', 'pencil', 'Changed', 'status-modified'],
['unsynced', 'arrow-up', 'Local only', 'status-unsynced'],
['remote-only', 'arrow-down', 'Remote', 'status-remote'],
['checking', 'refresh-cw', 'Checking', 'status-checking'],
] as const)('%s: returns correct icon, label, and fileCls', (status, icon, label, fileCls) => {
const meta = statusMeta(status);
expect(meta.icon).toBe(icon);

View file

@ -4,5 +4,6 @@
"1.0.5": "1.12.7",
"1.0.6": "1.12.7",
"1.1.1": "1.12.7",
"1.1.2": "1.12.7"
"1.1.2": "1.12.7",
"1.2.0": "1.13.0"
}