diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 049dbd2..7617202 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -25,14 +25,54 @@ on:
# Weekly API-drift check: same real-provider suites, no code change required to trigger them.
- cron: '0 6 * * 1'
+# ─────────────────────────────────────────────────────────────────────────────
+# CI DAG overview
+#
+# changes ──► gitea-e2e ─────┐
+# └─► provider-e2e ───┤
+# lint ───────────────────────┤
+# unit-test ──────────────────┤──► required-checks ──► package
+# build ──────────────────────┤ └────► publish (main only)
+#
+# All five validation jobs start in parallel right after the push; only the E2E
+# jobs wait on `changes` for their
+# path gate. No validation waits on E2E any more -- a lint/unit/build error now
+# surfaces in <1-2 min instead of after the real-provider matrix. The single
+# `required-checks` job is the only status branch protection needs to watch.
+# Release (package/publish) runs only after that gate passes, so a real
+# provider regression still blocks the release instead of shipping and being
+# caught after the fact.
+#
+# Whole-run concurrency (workflow level, NOT per job): a `push` to a branch
+# with an open PR fires both a `push` and a `pull_request` run for the same
+# commit. Keying concurrency per provider job (an earlier design) let the two
+# runs pick *different* winners per provider -- push cancels the PR run's
+# GitHub/GitLab legs, the PR run cancels the push run's Gitea leg -- so no
+# single run ever had all providers green and the required-checks DAG could
+# wedge. Keying at workflow level by source branch alone
+# (github.head_ref || github.ref_name) means only ONE whole CI DAG per branch
+# survives: the newest run supersedes the older one entirely. Manual
+# (workflow_dispatch) and scheduled runs are keyed by event_name+run_id, so
+# they get unique groups and never cancel -- or get cancelled by -- branch CI.
+# ─────────────────────────────────────────────────────────────────────────────
+
+concurrency:
+ group: >-
+ ci-${{
+ (github.event_name == 'push' || github.event_name == 'pull_request')
+ && (github.head_ref || github.ref_name)
+ || format('{0}-{1}', github.event_name, github.run_id)
+ }}
+ cancel-in-progress: true
+
jobs:
# `on.push.paths`/`on.pull_request.paths` would gate this *whole* workflow
- # file by path -- including the release-critical `CI` job below, which must
- # keep running for every push/PR regardless of path. This job instead
- # computes a per-job boolean so only `provider-e2e` skips on irrelevant
- # changes, while `CI`/`build-artifact` are unaffected.
+ # file by path -- including the always-must-run validation/release jobs
+ # below. This job instead computes a per-job boolean so only `provider-e2e`
+ # skips on irrelevant changes, while `lint`/`unit-test`/`build`/release are
+ # unaffected (they run on every push/PR regardless of path).
changes:
- name: Detect sync/provider-relevant changes
+ name: CI / Detect Changes
runs-on: ubuntu-latest
outputs:
e2e-relevant: ${{ steps.filter.outputs.e2e-relevant }}
@@ -45,23 +85,140 @@ jobs:
e2e-relevant:
- 'src/services/**'
- 'src/logic/sync-manager.ts'
+ - 'src/logic/sync/**'
+ - 'src/logic/source-control/**'
- 'src/utils/git-blob-sha.ts'
- 'src/utils/path.ts'
- 'src/utils/symlink.ts'
- - 'e2e/**'
+ - 'e2e-tests/**'
+ - 'vitest.e2e.config.ts'
- 'scripts/e2e-harness.sh'
- 'scripts/e2e-namespace.sh'
- 'scripts/e2e-namespace-cleanup.sh'
+ - 'scripts/e2e-suites.txt'
- 'scripts/run-e2e.sh'
- 'package.json'
- 'package-lock.json'
- '.github/workflows/ci.yml'
- # Real-provider E2E: one matrix job covering GitHub, GitLab, and Gitea (see
- # docs/testing/real-provider-e2e.md).
- provider-e2e:
- name: E2E / ${{ matrix.provider }}
+ # ── Fast checks (parallel, no E2E dependency) ──────────────────────────────
+
+ # Gitea is secretless and disposable, so it runs on a fresh GitHub-hosted
+ # VM. This is the only E2E job allowed to execute fork PR code; untrusted
+ # code must never reach the privileged self-hosted fleet below.
+ gitea-e2e:
+ name: CI / Provider E2E / gitea
needs: changes
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ timeout-minutes: 20
+ if: >-
+ (needs.changes.outputs.e2e-relevant == 'true' ||
+ github.event_name == 'workflow_dispatch' ||
+ github.event_name == 'schedule' ||
+ github.ref == 'refs/heads/main') &&
+ (github.event_name != 'workflow_dispatch' ||
+ github.event.inputs.provider == 'all' ||
+ github.event.inputs.provider == 'gitea')
+ env:
+ E2E_KEEP_BRANCH: ${{ github.event.inputs.keep_branch }}
+ E2E_PR_NUMBER: ${{ github.event.pull_request.number }}
+ E2E_SOURCE_BRANCH: ${{ github.head_ref || github.ref_name }}
+ steps:
+ - name: Compute run-scoped workdir
+ run: echo "E2E_WORKDIR=$RUNNER_TEMP/git-files-sync-e2e/${{ github.run_id }}/${{ github.run_attempt }}/gitea" >> "$GITHUB_ENV"
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
+ with:
+ node-version: '22'
+ cache: npm
+ - run: npm ci --ignore-scripts
+ - name: Run disposable Gitea E2E
+ run: scripts/run-e2e.sh --provider gitea
+
+ lint:
+ name: CI / Lint
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
+ with:
+ node-version: '22'
+ cache: npm
+ - run: npm ci --ignore-scripts
+ - run: npm run lint
+
+ unit-test:
+ name: CI / Unit Test (Node ${{ matrix.node-version }})
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ node-version: [22, 24]
+ steps:
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
+ with:
+ node-version: ${{ matrix.node-version }}
+ cache: npm
+ - run: npm ci --ignore-scripts
+ - name: Build (compatibility check)
+ run: npm run build
+ - name: Run tests with coverage
+ run: npm run test -- --coverage
+ - name: Upload coverage
+ if: matrix.node-version == 22
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: coverage-report
+ path: coverage/
+ overwrite: true
+
+ build:
+ name: CI / Build
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
+ with:
+ node-version: '22'
+ cache: npm
+ - run: npm ci --ignore-scripts
+ - run: npm run build
+ # Upload the raw built artifacts (main.js/manifest/styles.css) for ad-hoc
+ # PR install testing on non-main branches. `npm run build` already ran
+ # above as the validation (tsc -noEmit + Obsidian 1.11.0 compat typecheck +
+ # esbuild), so this upload is in the *same* job -- never a separate
+ # upload-artifact job that could drift out of sync with the build state.
+ - name: Set artifact name
+ if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/master'
+ 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/master'
+ with:
+ name: ${{ steps.artifact.outputs.name }}
+ path: |
+ main.js
+ manifest.json
+ styles.css
+ retention-days: 7
+
+ # ── Integration check: real-provider E2E ────────────────────────────────────
+
+ # Credentialed provider E2E: GitHub and GitLab remain on the self-hosted
+ # fleet. Fork PRs are rejected at job level before one can claim a runner.
+ # docs/testing/real-provider-e2e.md). It starts as soon as `changes` resolves
+ # (no preflight/E2E-gate dependency any more), then provider legs run in
+ # parallel. `fail-fast: false` so one provider failure doesn't cancel the
+ # others.
+ provider-e2e:
+ name: CI / Provider E2E / ${{ matrix.provider }}
+ needs: [changes]
runs-on: [self-hosted, linux, x64, 32gb-ram]
# Runs when sync/provider-relevant paths changed, or unconditionally on
# workflow_dispatch/schedule/a push to main (main always gets the full
@@ -73,35 +230,30 @@ jobs:
# `if:` can see it. That part is done by the "Determine whether this
# provider leg should run" step below instead, gating every later step.
if: >-
- needs.changes.outputs.e2e-relevant == 'true' ||
+ (needs.changes.outputs.e2e-relevant == 'true' ||
github.event_name == 'workflow_dispatch' ||
github.event_name == 'schedule' ||
- github.ref == 'refs/heads/main'
+ github.ref == 'refs/heads/main') &&
+ (github.event_name != 'pull_request' ||
+ github.event.pull_request.head.repo.full_name == github.repository) &&
+ (github.event_name != 'workflow_dispatch' ||
+ github.event.inputs.provider == 'all' ||
+ github.event.inputs.provider == 'github' ||
+ github.event.inputs.provider == 'gitlab')
strategy:
fail-fast: false
- max-parallel: 3
+ max-parallel: 2
matrix:
- provider: [github, gitlab, gitea]
- # One group per source branch/provider -- keyed by branch name alone
- # (github.head_ref || github.ref_name, same expression E2E_SOURCE_BRANCH
- # below uses), deliberately NOT split by event type. A `push` to a branch
- # with an open PR fires both a `push` and a `pull_request` run for the
- # same commit; keying by event type (PR number vs branch name, as an
- # earlier version of this did) put those two runs in different groups,
- # so they ran fully concurrently against the same shared provider
- # sandbox and starved each other (observed as real GitLab API timeouts
- # under that double load -- see docs/testing/real-provider-e2e.md).
- # Keying by branch name alone means the later of the two cancels the
- # earlier instead, same as a repeated push or a workflow rerun. This is
- # NOT a cleanup mechanism (see scripts/e2e-harness.sh's per-run branch
- # naming): a cancelled run's branch can still be mid-delete when the
- # next one starts, which is exactly why every run gets its own unique
- # branch regardless of cancellation. `concurrency:` at job level *does*
- # support the `matrix` context (unlike job-level `if:`, see the comment
- # below), so each matrix leg still gets its own group.
- concurrency:
- group: e2e-${{ github.head_ref || github.ref_name }}-${{ matrix.provider }}
- cancel-in-progress: true
+ provider: [github, gitlab]
+ # Concurrency is handled at WORKFLOW level (see the header comment on the
+ # `concurrency:` block above): one whole CI DAG per source branch, so a
+ # push + pull_request race for the same commit can never split provider
+ # winners across two runs. Per-provider job groups were removed for that
+ # reason -- they let each provider pick a different surviving run.
+ # This is NOT a cleanup mechanism (see scripts/e2e-harness.sh's per-run
+ # branch naming): a cancelled run's branch can still be mid-delete when
+ # the next one starts, which is exactly why every run gets its own unique
+ # branch regardless of cancellation.
env:
E2E_GITHUB_OWNER: ${{ vars.E2E_GITHUB_OWNER }}
E2E_GITHUB_REPO: ${{ vars.E2E_GITHUB_REPO }}
@@ -145,23 +297,6 @@ jobs:
id: gate
run: |
run=true
- # TODO(e2e): gitea temporarily disabled in CI -- container
- # provisioning against this runner fleet's Docker topology needs
- # more investigation (bridge-IP reachability, health-check timing)
- # than is safe to iterate on inside the shared matrix. Suite/harness
- # code is untouched and passes locally (`npm run test:e2e --
- # --provider gitea`); re-enable by deleting this block once the CI
- # runner behavior is confirmed. NOTE: gitea is also what normally
- # covers fork PRs (no secrets needed) -- while this is disabled,
- # fork PRs get no E2E coverage at all.
- if [ "${{ matrix.provider }}" = "gitea" ]; then
- run=false
- fi
- if [ "${{ github.event_name }}" = "pull_request" ] \
- && [ "${{ matrix.provider }}" != "gitea" ] \
- && [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]; then
- run=false
- fi
if [ "${{ github.event_name }}" = "workflow_dispatch" ] \
&& [ "${{ github.event.inputs.provider }}" != "all" ] \
&& [ "${{ github.event.inputs.provider }}" != "${{ matrix.provider }}" ]; then
@@ -184,29 +319,24 @@ jobs:
- run: npm ci --ignore-scripts
if: steps.gate.outputs.run == 'true'
- # Arrange/Assert/cleanup are Shell + Git (scripts/e2e-harness.sh); Act
- # stays production TypeScript (npx vitest). E2E_WORKDIR/E2E_PR_NUMBER/
- # E2E_SOURCE_BRANCH are set once at job level (see the job `env:`
- # above) so all steps below share the same run state/identity.
- - name: Provision isolated branch/container
- if: steps.gate.outputs.run == 'true'
- env:
- E2E_PROVIDER: ${{ matrix.provider }}
- run: scripts/e2e-harness.sh provision
-
- - name: Seed baseline fixture
- if: steps.gate.outputs.run == 'true'
- env:
- E2E_PROVIDER: ${{ matrix.provider }}
- run: scripts/e2e-harness.sh seed
-
+ # One entry point for the whole real-provider E2E flow: scripts/run-e2e.sh
+ # provisions the isolated branch/container, seeds the baseline fixture,
+ # runs the suites listed in scripts/e2e-suites.txt (the single source of
+ # truth — CI and local run the same command, so the suite list is never
+ # duplicated here), and cleans up via its EXIT trap. New suites are added
+ # in scripts/e2e-suites.txt only; run-e2e.sh's own forward/reverse checks
+ # fail the run if a suite file isn't registered (or vice versa).
+ # E2E_WORKDIR is set by the "Compute run-scoped workdir" step above; the
+ # job `env:` supplies the provider secrets and run identity
+ # (E2E_PR_NUMBER/E2E_SOURCE_BRANCH) that run-e2e.sh/e2e-harness.sh consume.
+ #
# Retried (not just run once): observed failures against the real
# providers include transient runner-network blips unrelated to the
# suite/product code (e.g. a bare `getaddrinfo ENOTFOUND gitlab.com`
# mid-test on 2026-08-14, run 31770197590) that a same-attempt rerun
- # simply doesn't reproduce. Safe to retry the whole step from scratch:
- # each suite's `runId`/branch paths are randomized per vitest process
- # (see e.g. e2e/suites/sync-manager.e2e.test.ts), so a failed
+ # simply doesn't reproduce. Safe to retry from scratch: run-e2e.sh
+ # re-provisions a fresh isolated branch each attempt and every suite's
+ # runId/branch paths are randomized per vitest process, so a failed
# attempt's partial remote state never collides with the retry -- a
# genuine product/test bug still fails identically every attempt and
# exhausts the retries.
@@ -219,19 +349,7 @@ jobs:
timeout_minutes: 15
max_attempts: 3
retry_wait_seconds: 15
- command: |
- set -a
- # shellcheck disable=SC1091
- source "$E2E_WORKDIR/e2e.env"
- [ -f "$E2E_WORKDIR/e2e.secrets.env" ] && source "$E2E_WORKDIR/e2e.secrets.env"
- set +a
- npx vitest run -c vitest.e2e.config.ts "e2e/suites/${{ matrix.provider }}.e2e.test.ts" e2e/suites/sync-manager.e2e.test.ts
-
- - name: Independent verification
- if: steps.gate.outputs.run == 'true'
- env:
- E2E_PROVIDER: ${{ matrix.provider }}
- run: scripts/e2e-harness.sh verify
+ command: scripts/run-e2e.sh --provider "${{ matrix.provider }}"
# `if: always()` -- cleanup is best-effort, never a prerequisite for
# the next run (see scripts/e2e-harness.sh's cmd_cleanup and
@@ -244,78 +362,110 @@ jobs:
E2E_PROVIDER: ${{ matrix.provider }}
run: scripts/e2e-harness.sh cleanup
- # Aggregates the matrix into a single required status so branch protection
- # only has to reference one check name (see docs/testing/real-provider-e2e.md
- # for the "Gitea required, GitHub/GitLab not required at branch-protection
- # level" split -- required-vs-optional per *provider* still comes from the
- # "Determine whether this provider leg should run" step above; this gate
- # only asks "did whatever ran, pass?"). A gated-off leg's steps are all
- # skipped without failing the job, so it still reports "success" here.
- # `if: always()` so a real provider-e2e failure is caught here and blocks
- # CI/release. A cancelled matrix means a newer run in the same branch/provider
- # concurrency group replaced this duplicate run; report that as neutral and
- # do not start another copy of downstream CI.
- e2e-gate:
- name: E2E gate
- needs: provider-e2e
+ # ── Final gate ──────────────────────────────────────────────────────────────
+
+ # Single required status check. Branch protection only has to reference
+ # this one job name (see docs/testing/real-provider-e2e.md's note on the
+ # `CI / gitea` required-status split). `if: always()` so a real validation
+ # failure is caught here and blocks merge/release; a cancelled matrix leg
+ # (a newer run in the same branch/provider concurrency group replaced this
+ # duplicate) is treated as a failure here too -- the surviving run is the
+ # one whose gate result GitHub uses for the latest commit, so blocking the
+ # cancelled duplicate's gate is correct, not a wedged red.
+ required-checks:
+ name: CI / Required Checks
+ needs: [lint, unit-test, build, gitea-e2e, provider-e2e]
if: always()
runs-on: ubuntu-latest
- outputs:
- run-ci: ${{ steps.check.outputs.run-ci }}
steps:
- - name: Check provider-e2e result
- id: check
+ - name: Aggregate validation results
run: |
- result="${{ needs.provider-e2e.result }}"
- echo "provider-e2e result: $result"
- echo "run-ci=true" >> "$GITHUB_OUTPUT"
- if [ "$result" = "cancelled" ]; then
- echo "run-ci=false" >> "$GITHUB_OUTPUT"
- echo "::notice::provider-e2e was replaced by a newer run in the same concurrency group."
- exit 0
- fi
- if [ "$result" != "success" ] && [ "$result" != "skipped" ]; then
- echo "::error::provider-e2e failed ($result) -- blocking CI/release."
- exit 1
- fi
+ fail=0
+ for r in "${{ needs.lint.result }}" "${{ needs.unit-test.result }}" "${{ needs.build.result }}" "${{ needs.gitea-e2e.result }}" "${{ needs.provider-e2e.result }}"; do
+ echo "result: $r"
+ case "$r" in
+ success|skipped) ;;
+ *) echo "::error::validation job reported '$r' -- blocking merge/release."; fail=1 ;;
+ esac
+ done
+ exit "$fail"
- CI:
- needs: e2e-gate
- if: needs.e2e-gate.outputs.run-ci == 'true'
- uses: firstsun-dev/.github/.github/workflows/obsidian-plugin-ci.yml@v1
- with:
- plugin-id: "git-file-sync"
- secrets:
- RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
+ # ── Release (gated on required-checks) ──────────────────────────────────────
- build-artifact:
- name: Upload build artifact
+ package:
+ name: Release / Package
+ needs: [required-checks]
+ if: needs.required-checks.result == 'success'
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
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22'
- cache: 'npm'
-
- - run: npm ci
-
+ cache: npm
+ - run: npm ci --ignore-scripts
- run: npm run build
-
- - name: Set artifact name
- id: artifact
+ - name: Create plugin package
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
+ VERSION=$(node -p "require('./manifest.json').version")
+ BRANCH_NAME=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}
+ BRANCH_NAME_SAFE=$(echo "$BRANCH_NAME" | sed 's/[^a-zA-Z0-9._-]/-/g')
+ ZIP_NAME="git-file-sync-${VERSION}-${BRANCH_NAME_SAFE}.zip"
+ zip -j "$ZIP_NAME" main.js manifest.json styles.css || zip -j "$ZIP_NAME" main.js manifest.json
+ echo "ZIP_NAME=$ZIP_NAME" >> "$GITHUB_ENV"
+ echo "PLUGIN_VERSION=$VERSION" >> "$GITHUB_ENV"
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
- name: ${{ steps.artifact.outputs.name }}
- path: |
- main.js
- manifest.json
- styles.css
+ name: plugin-build-artifact-${{ github.sha }}
+ path: ${{ env.ZIP_NAME }}
retention-days: 7
+ - name: Annotate build summary
+ run: |
+ echo "::notice title=Obsidian Plugin Build::git-file-sync v${{ env.PLUGIN_VERSION }} built (${{ env.ZIP_NAME }})"
+ {
+ echo "### Obsidian Plugin Build"
+ echo ""
+ echo "- Plugin: \`git-file-sync\`"
+ echo "- Version: \`${{ env.PLUGIN_VERSION }}\`"
+ echo "- Artifact: \`${{ env.ZIP_NAME }}\`"
+ } >> "$GITHUB_STEP_SUMMARY"
+
+ publish:
+ name: Release / Publish
+ needs: [required-checks]
+ # semantic-release only releases on main/master (see .releaserc.json's
+ # `branches`); gating the whole job on those refs skips the build/attest
+ # work on every PR run.
+ if: >-
+ needs.required-checks.result == 'success' &&
+ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ id-token: write
+ attestations: write
+ env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+ steps:
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+ - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
+ with:
+ node-version: '22'
+ cache: npm
+ - run: npm ci
+ - run: npm run build
+ - name: Attest main.js
+ uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
+ with:
+ subject-path: main.js
+ - name: Attest styles.css
+ if: hashFiles('styles.css') != ''
+ uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
+ with:
+ subject-path: styles.css
+ - env:
+ GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN || github.token }}
+ run: npx semantic-release
diff --git a/CLAUDE.md b/CLAUDE.md
index 73d4d7c..91607d5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,11 +4,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Agent Workflow
-- **Startup**: read `feature_list.json` (active/next-up work; GitHub Issues on `firstsun-dev/git-files-sync`, Project #6, is the actual source of truth — re-sync before trusting stale entries) and `progress.md` (what's open right now), then `session-handoff.md` for the previous session's exact stopping point.
+- **Startup**: read `feature_list.json` (active/next-up work; GitHub Issues on `firstsun-dev/git-files-sync`, Project #6, is the actual source of truth — re-sync before trusting stale entries) and `progress.md` (what's open right now).
- **Before editing**: run `./init.sh` (installs deps, then lint + test + build) to confirm you're starting from a green baseline.
- **Definition of done**: `npx eslint .` has 0 errors, `npm run build` passes (includes the Obsidian 1.11.0 compat typecheck), and `npx vitest run` passes, *and* evidence of that run is recorded (one line: command + result) in `progress.md` or the PR description — not just claimed.
- **Scope**: work one `feature_list.json` entry at a time; don't start the next until the current one's evidence is recorded.
-- **End of session**: overwrite `session-handoff.md` with the new stopping point, move finished items from `progress.md` into `archive/YYYY-MM.md` (current month).
+- **End of session**: move finished items from `progress.md` into `archive/YYYY-MM.md` (current month).
- Issue/PR conventions (Conventional Commits titles, Project #6 fields, English-only for this public plugin repo) are defined in the `firstsun-pm` skill, not duplicated here.
## Development Commands
diff --git a/README.md b/README.md
index 7467ca8..b54870f 100644
--- a/README.md
+++ b/README.md
@@ -1,50 +1,114 @@
# Git File Sync
-*Selective, file-by-file sync between your vault and GitHub, GitLab, or Gitea.*
+*Selective, file-by-file sync between your Obsidian vault and GitHub, GitLab, or Gitea.*
[](https://github.com/firstsun-dev/git-files-sync/actions/workflows/ci.yml)
[](https://github.com/firstsun-dev/git-files-sync/releases)
[](https://obsidian.md/plugins?id=git-file-sync)
[](LICENSE)
-**[Releases](https://github.com/firstsun-dev/git-files-sync/releases)** · **[繁體中文](USAGE_zh.md)** · **[简体中文](USAGE_zh-cn.md)** · **[Changelog](CHANGELOG.md)**
+**[Website](https://firstsun.org/en/)** · **[Releases](https://github.com/firstsun-dev/git-files-sync/releases)** · **[繁體中文](USAGE_zh.md)** · **[简体中文](USAGE_zh-cn.md)** · **[Changelog](CHANGELOG.md)**
-Push, pull, diff, and resolve conflicts — file by file, not whole-vault. Unlike full-vault sync solutions, Git File Sync gives you granular control over exactly what leaves your device, so you can keep personal notes private while sharing project files through a real Git repository.
+Review changes, choose exactly what to sync, and apply them through a clear Source Control workflow — without syncing your entire vault.
+
+Unlike full-vault sync solutions, Git File Sync gives you control over exactly which files leave your device. Keep personal notes private while selectively synchronizing project files through a real Git repository. No local `.git` repository or Git CLI is required.
-
-*The Sync Status View gives a bird's-eye view of your vault, letting you selectively push, pull, or diff modified files.*
+
+*Review repository changes, choose what to sync, and apply them through the Sync Queue.*
+
+## How it works
+
+Git File Sync uses a simple Source Control workflow:
+
+**Review → Queue → Sync**
+
+1. **Review Repository Changes** — see local changes, remote changes, renames, deletions, and conflicts in one place.
+2. **Build your Sync Queue** — select exactly which changes should be included in the next sync.
+3. **Review and Sync** — inspect one combined sync plan before anything is applied.
+
+A selected change moves from **Repository Changes** into the **Sync Queue**, so the same change is never shown in both places at once.
+
+## What you can do
+
+- **Sync only what you choose** — keep unrelated or private notes out of Git.
+- **Review everything in one Source Control view** — local changes, remote changes, renames, deletions, and conflicts.
+- **Build one Sync Queue** — mix uploads, downloads, and remote deletions in the same sync operation.
+- **Review before applying** — inspect additions, modifications, moves, downloads, and deletions before they are applied.
+- **Compare before overwriting** — built-in unified and side-by-side diffs for local and remote versions.
+- **Resolve conflicts explicitly** — choose which side wins instead of silently overwriting changes.
+- **Use the same workflow everywhere** — GitHub, GitLab, and Gitea on desktop and mobile.
+- **Use your preferred language** — English, Traditional Chinese, and Simplified Chinese are built in.
+
+## Quick start
+
+1. Install Git File Sync from Obsidian Community Plugins.
+2. Configure GitHub, GitLab, or Gitea under **Settings → Git File Sync**.
+3. Open **Source Control** from the ribbon or Command Palette.
+4. Review **Repository Changes**.
+5. Select the changes you want; they move into the **Sync Queue**.
+6. Click **Sync**, review the combined plan, then choose **Apply**.
+
+## Source Control
+
+The Source Control view separates work into two clear areas:
+
+### Repository Changes
+
+Files that need attention but are not yet part of the next sync. Use search, filters, and Tree/List view to narrow the workspace.
+
+### Sync Queue
+
+Changes selected for the next sync operation. Each queued item shows the action that will be applied:
+
+- **Upload** — apply the local version to the remote repository.
+- **Download** — bring the remote version into your vault.
+- **Delete** — mirror a local deletion to the remote repository.
+
+One **Sync** action builds a combined plan. Remote additions, modifications, moves, and deletions are committed together, while downloads are applied locally after review.
+
+### File states
-## What's inside
+| Status | Meaning |
+|---|---|
+| `A` | Added locally |
+| `M` | Modified locally |
+| `D` | Deleted locally |
+| `R` | Renamed or moved |
+| `↓` | Available remotely |
+| `↕` | Modified remotely |
+| `!` | Conflict |
+| `S` | Synced |
+
+> **Deleted locally:** adding a `D` change to the Sync Queue deletes the tracked file from the remote repository by default. Use **Download** instead if you want to restore the remote copy locally.
-- **File-by-file control** — Sync individual notes or selected files from a folder, not your entire vault. No lock-in to a single sync provider for everything.
-- **Three Git providers** — GitHub, GitLab (including self-hosted), and Gitea, all behind one consistent UI.
-- **Review before applying** — Every push, pull, and remote deletion shows a plan of additions, changes, moves, and deletions before it writes anything.
-- **Real rename support** — Renamed files and moved folders are committed as moves, without leaving duplicate files behind remotely.
-- **Visual diffing** — A built-in diff viewer compares local and remote versions before anything is overwritten; on desktop it opens in a dedicated pane.
-- **Conflict resolution** — When local and remote both changed, resolve manually with a dedicated conflict tool instead of guessing which version wins.
-- **Works on mobile** — Full support for Obsidian Mobile with a touch-friendly sync dashboard; the inline diff remains available there.
-- **Three interface languages** — English, Traditional Chinese, and Simplified Chinese. Follow Obsidian's display language or choose one in Settings.
+## Common workflows
-## Sync Status View
+| Situation | What happens |
+|---|---|
+| New local file | `A` → Queue → **Upload** |
+| Local edit | `M` → Queue → **Upload** |
+| File exists only remotely | `↓` → Queue → **Download** |
+| Remote version changed | `↕` → Queue → **Download** |
+| Local tracked file deleted | `D` → Queue → **Delete** remote |
+| Local deletion was accidental | `D` → **Download** to restore locally |
+| File renamed or moved | `R` → Queue → **Upload** as a move |
+| Both sides changed | `!` → review conflict → **Keep Local** or **Keep Remote** |
-A single dashboard shows the state of every tracked file:
+## Diff and conflict review
-- **Live status and startup refresh** — tracked files update as you edit or rename them, and the view refreshes automatically after Obsidian starts (configurable in Settings).
-- **Status filtering and search** — instantly narrow the list to modified, new, remote-only, moved, or matching paths.
-- **Tree view and folder selection** — optionally browse files as a collapsible hierarchy, select folders with tri-state checkboxes, and choose whether synced files appear in All.
-- **Safe moves** — renamed files appear as **Moved**; related folder moves collapse into one row and can be pushed or reverted together.
-- **Visual diffs** — line-by-line comparison of local vs. remote before syncing; click a path to open the local note or its remote page when available.
-- **Remote-only detection** — spot files that exist on GitHub/GitLab/Gitea but haven't been pulled into the vault yet.
+Select a changed file to inspect its local and remote versions before syncing. The diff viewer supports unified and side-by-side layouts and shows addition/deletion statistics where available.

-*The built-in diff viewer compares local and remote changes before you push or pull.*
+*Review local and remote differences before deciding which version to keep.*
+
+When both sides changed, Git File Sync keeps the conflict explicit. Choose **Keep Local** to overwrite the remote version or **Keep Remote** to accept the remote copy locally.
## Providers
@@ -54,90 +118,60 @@ A single dashboard shows the state of every tracked file:
|
| gitlab.com · self-hosted | GitLab 13.0+ |
|
| self-hosted | Gitea 1.12+ |
-> **Gitea note:** the plugin talks to the Gitea API v1 (`/api/v1`) and resolves branch names to commit SHAs before fetching the file tree, which is what makes 1.12+ the floor.
-
## Configuration

-*Pick a provider and supply its credentials in Settings > Git File Sync.*
+*Choose a provider and configure the repository under Settings → Git File Sync.*
-| | Provider | Required info | Token scope |
-|:---:|---|---|---|
-|
| **GitHub** | Personal access token, owner, repo name | `Contents: Read and write` (fine-grained); `repo` (classic) |
-|
| **GitLab** | Personal access token, project ID, base URL | `read_repository`, `write_repository` |
-|
| **Gitea** | Personal access token, base URL, owner, repo name | `write:repository` (1.19+); account-wide on older versions |
-
-> **Security tip:** scope every token as narrowly as possible — one repo, the minimum permissions, and a short expiration — and store it only in this plugin's settings. Never paste it into a note that gets synced. Rotate it immediately if it's ever exposed, and revoke tokens you're no longer using.
-
-- **GitHub token:** create a [fine-grained personal access token](https://github.com/settings/personal-access-tokens/new) (Settings → Developer settings → Personal access tokens → Fine-grained tokens) rather than a classic one. Set **Repository access** to *Only select repositories* and pick just the repo you're syncing, set an **Expiration** (90 days or less), and grant only **Contents: Read and write**. If you must use a classic token, limit the `repo` scope to that one use and set an expiration.
-- **GitLab token:** prefer a [project access token](https://docs.gitlab.com/user/project/settings/project_access_tokens/) (Project → Settings → Access tokens) over a personal one — it's scoped to a single project and can be revoked without affecting your account. The plugin only calls the repository tree/blobs/commits/branches endpoints, so grant just `read_repository` and `write_repository` (**not** `api`, which also grants issues, merge requests, CI, and account-wide access). Role **Developer** is the minimum that can push to a non-protected branch. Set an expiration date, and base URL defaults to `https://gitlab.com`; change it for self-hosted instances.
-- **Gitea token:** User settings → Applications → Access tokens. The plugin only touches repository content, branches, and git data, so on Gitea 1.19+ (which supports per-scope tokens) select just **`write:repository`** — that implies read access too — instead of "Select all". Older Gitea versions (down to the 1.12 minimum) don't support scoped tokens, so the token is account-wide by default; in that case, use a dedicated bot/service account with access to only the target repo rather than your personal account's token. Set an expiration if your instance offers one, and point the base URL at your instance (e.g. `https://gitea.example.com`).
-
-All three providers let you revoke a token instantly from its settings page — do that first if a token may have leaked, then issue a new one.
-
-Other settings: **language** (system default, English, Traditional Chinese, or Simplified Chinese); **auto-refresh Sync Status on startup**; **branch** to sync against (default `main`); **root path** prefix inside the repo; **vault folder** to scope which notes are tracked; and **symbolic link handling** (*real* — recreate the link, GitHub only; *follow* — sync the target's content; *skip*). See [Symbolic link handling](docs/symlink-handling.md) for details.
+| Provider | Required information | Recommended permission |
+|---|---|---|
+| **GitHub** | Token, owner, repository | Fine-grained token with **Contents: Read and write** |
+| **GitLab** | Token, project ID, base URL | `read_repository`, `write_repository` |
+| **Gitea** | Token, owner, repository, base URL | `write:repository` on Gitea 1.19+ |
-## Daily workflow
+Other settings include language, branch, repository root path, vault-folder scope, startup refresh, ignore patterns, and symbolic-link handling. See [Symbolic link handling](docs/symlink-handling.md) for details.
-**Pushing:**
-- One note — the cloud icon in the ribbon, or the command `Push current file to GitLab/GitHub/Gitea`.
-- Several notes — open the Sync Status View, filter to **Modified**, select files, click **Push selected**.
-- From the file tree — right-click any file and choose `Push to GitLab/GitHub/Gitea`.
-- Review the proposed changes in the plan, then choose **Apply**. Renames and folder moves are included as real moves.
+> **Security:** scope tokens to the smallest possible repository access and permissions, set an expiration where possible, and never place a token inside a note that may be synced. Revoke and rotate a token immediately if it may have been exposed.
-**Pulling:**
-1. Open the Sync Status View and click **Refresh status**.
-2. Files with remote updates show as **Modified** or **Remote only**.
-3. Select them and click **Pull selected**.
-4. Review the plan and choose **Apply**. Pulling overwrites local changes — if both sides changed, the conflict tool opens instead.
+## Mobile
-**Resolving a conflict:**
-1. The Conflict Resolution window opens automatically.
-2. Left pane is your **Local** version, right pane is **Remote**.
-3. Choose **Keep Local** (overwrite remote on next push) or **Keep Remote** (accept remote, overwrite local).
+The same Source Control model is available on Obsidian Mobile. The Sync Queue starts compact so Repository Changes remain easy to browse, and selecting a change opens a mobile-friendly detail/diff view.
-**On mobile:** swipe from the left to open the ribbon and the Sync Status View, pull before you start editing, push when you're done.
+A practical multi-device habit is still useful: refresh before editing on another device, review what changed, then sync only the files you intend to move between devices.
## Installation
### From Community Plugins (recommended)
-1. Open **Settings > Community plugins** and turn off restricted mode.
-2. Click **Browse**, search for **Git File Sync**, click **Install**, then **Enable**.
+
+1. Open **Settings → Community plugins** and turn off Restricted mode if required.
+2. Click **Browse**, search for **Git File Sync**, then **Install** and **Enable**.
### Manual
+
1. Download `main.js`, `manifest.json`, and `styles.css` from the [latest release](https://github.com/firstsun-dev/git-files-sync/releases/latest).
2. Create `/.obsidian/plugins/git-file-sync/`.
3. Copy the three files into that folder.
-4. Reload Obsidian and enable the plugin under **Settings > Community plugins**.
-
-## Quick start
-
-1. Configure a provider in **Settings > Git File Sync** (see [Configuration](#configuration)).
-2. Open the **Sync Status View** — the list icon in the ribbon, or run `Open sync status view` from the Command Palette.
-3. The view refreshes automatically after startup; click **Refresh status** whenever you want to check again.
-4. Use the status tabs, path search, or optional tree view to find files; select files or folders and choose **Push selected** or **Pull selected**.
-5. Review the sync plan, then choose **Apply**.
-
-**Commands:**
-
-| Command | What it does |
-|---|---|
-| Open sync status view | Open the sync dashboard |
-| Push current file to GitLab/GitHub/Gitea | Push the active note |
-| Pull current file to GitLab/GitHub/Gitea | Pull the active note |
-| Push all files | Review and push every tracked, changed file |
-| Pull all files | Review and pull every tracked, changed file |
+4. Reload Obsidian and enable the plugin under **Settings → Community plugins**.
## Privacy and security
-- **Local storage** — personal access tokens are stored locally in the plugin's data folder inside your vault, and are only ever sent to the Git provider you configured.
-- **No telemetry** — the plugin collects no usage data or analytics.
+- **Local token storage** — access tokens are stored locally in the plugin data inside your vault and are sent only to the Git provider you configure.
+- **No telemetry** — Git File Sync does not collect usage analytics or personal data.
+- **Selective sync** — files outside your configured scope or not selected for sync are not automatically uploaded as part of the Source Control workflow.
## Requirements
-- Obsidian **1.13.0** or later
+- Obsidian **1.11.0** or later
- Desktop and mobile supported
+## More documentation
+
+- [Traditional Chinese guide](USAGE_zh.md)
+- [Simplified Chinese guide](USAGE_zh-cn.md)
+- [Symbolic link handling](docs/symlink-handling.md)
+- [Full changelog](CHANGELOG.md)
+- [Releases](https://github.com/firstsun-dev/git-files-sync/releases)
+
## Development
```bash
@@ -156,4 +190,4 @@ MIT
---
-**Created by [ClaudiaFang](https://github.com/ClaudiaFang)**
+**Created by [ClaudiaFang](https://github.com/ClaudiaFang) · [firstsun-dev](https://github.com/firstsun-dev)**
diff --git a/USAGE_zh-cn.md b/USAGE_zh-cn.md
index 7ba6971..d40dbc2 100644
--- a/USAGE_zh-cn.md
+++ b/USAGE_zh-cn.md
@@ -7,107 +7,157 @@
**[English](README.md)** · **[繁體中文](USAGE_zh.md)** · **[更新日志](CHANGELOG.md)**
-
+Git File Sync 让你通过 GitHub、GitLab 或自建 Gitea,在桌面端与移动端 Obsidian 中**选择性同步文件**,不需要把整个 vault 都交给同一套同步服务。
-本指南介绍如何使用 Git File Sync 插件,在移动设备与桌面端之间,通过 GitLab、GitHub 或自建 Gitea 选择性同步笔记。
+你可以先检查变更、选择这次真正要同步的项目,再从“同步队列”一次确认并应用。插件直接通过 Git 服务 API 操作,不需要安装 Git CLI,也不需要在 vault 中创建本地 `.git` 仓库。
-Git File Sync 不会同步整个 vault;您可以只选择要分享、发布或备份的笔记,同时将私人笔记保留在本地。它直接连接 Git 服务的 API,不需要安装 Git、使用命令行,或在 vault 中创建本地 `.git` 仓库。
+## 新版操作模型
----
+Git File Sync 的版本控制流程可以记成:
-## 支持的 Git 服务
+**检查变更 → 加入同步队列 → 同步**
-| 服务 | 适用场景 | 最低版本 |
-| :--- | :--- | :--- |
-| **GitHub** | 公开/私有仓库 | — |
-| **GitLab** | gitlab.com 或自建实例 | GitLab 13.0+ |
-| **Gitea** | 自建 Git 服务器 | Gitea 1.12+ |
+1. **检查“仓库变更”** — 在同一个界面查看本地变更、远程变更、重命名、删除和冲突。
+2. **建立“同步队列”** — 勾选这次真正要处理的项目。
+3. **检查并同步** — 在应用前先查看完整同步计划,再执行上传、下载和删除。
----
+被选中的项目会从“仓库变更”移动到“同步队列”,同一条变更不会同时出现在两个区域。
-## 1. 初始设置
+## 主要功能
-在开始同步前,请完成以下设置:
+- **只同步你选择的文件** — 私人笔记或无关文件可以留在本地。
+- **统一的版本控制界面** — 集中查看本地、远程、移动、删除与冲突状态。
+- **一个同步队列** — 同一次操作可以包含上传、下载和远程删除。
+- **应用前先检查** — 新增、修改、移动、下载和删除都会先出现在同步计划中。
+- **内置差异对比** — 支持单栏与并排 Diff,对比本地与远程版本。
+- **明确处理冲突** — 由你决定保留本地或远程,不会静默覆盖。
+- **多平台与多服务** — 支持 GitHub、GitLab、Gitea,以及桌面端和移动端 Obsidian。
+- **三种界面语言** — English、繁體中文、简体中文。
-
-*在设置面板选择 Git 服务,并填写对应的凭据和路径。*
+## 快速开始
-1. **选择服务**:在 `设置` > `Git File Sync` 中选择 GitLab、GitHub 或 Gitea。
-2. **填写凭据**:
+1. 从 Obsidian 社区插件安装 Git File Sync。
+2. 在 **设置 → Git File Sync** 中配置 GitHub、GitLab 或 Gitea。
+3. 从侧边功能栏或命令面板打开 **版本控制(Source Control)**。
+4. 检查 **仓库变更**。
+5. 勾选要同步的项目,项目会移动到 **同步队列**。
+6. 点击 **同步**,检查同步计划后选择 **应用**。
- > **安全提示:** 请把每个令牌的权限范围缩到最小:只允许需要同步的仓库、只授予必要权限,并设置较短的有效期。令牌只应保存在本插件的设置中,不要粘贴到会被同步的笔记里。如果令牌可能泄露,请立即撤销并重新创建;不再使用的令牌也应直接撤销。
+## 版本控制界面
- - **GitHub**:建议创建 [fine-grained personal access token](https://github.com/settings/personal-access-tokens/new),而不是 classic token。在 **Repository access** 中选择 *Only select repositories*,只指定要同步的仓库;设置 **Expiration**(建议不超过 90 天),并只授予 **Contents: Read and write**。如果必须使用 classic token,则使用 `repo` scope,并为该用途设置到期时间。
- - **GitLab**:建议优先使用 [project access token](https://docs.gitlab.com/user/project/settings/project_access_tokens/)(`Project` > `Settings` > `Access tokens`),而不是个人访问令牌,使权限限制在单个项目内,也可以独立撤销。插件只会调用 repository tree、blob、commit 和 branch 相关 API,因此只需要 `read_repository` 和 `write_repository`,**不需要** `api`。如需推送到非 protected branch,最低角色为 **Developer**。请设置到期时间;服务器网址默认为 `https://gitlab.com`,自建环境请改为您的实例网址。
- - **Gitea**:在 `用户设置` > `应用程序` > `访问令牌` 中创建令牌。插件只会操作仓库内容、分支和 Git data;Gitea 1.19+ 支持 scoped token,请只选择 **`write:repository`**(已包含读取权限),不要选择全部权限。较旧版本(最低支持到 1.12)没有 scoped token,令牌默认是账号级别;这种情况下建议使用只拥有目标仓库权限的专用 bot / service account,而不是个人账号。若实例支持到期时间也请设置,并将服务器网址指向您的 Gitea 实例(例如 `https://gitea.example.com`)。
+### 仓库变更
- 三种服务都可以从各自的设置页面立即撤销令牌。如果令牌可能泄露,请先撤销,再签发新的令牌。
-3. **仓库路径**:如需把笔记存放在仓库中的特定目录(例如 `notes/`),请设置 `Root Path`。
-4. **语言和自动刷新**:可以选择跟随系统、English、繁體中文或简体中文。“Obsidian 启动时自动刷新同步状态”默认开启,也可在设置中关闭。
+显示当前需要处理、但尚未加入下一次同步的项目。可以使用搜索、筛选器与树状/列表视图快速缩小范围。
----
+### 同步队列
-## 2. 核心操作流程
+显示下一次“同步”会实际处理的项目。每一项都会标示预计执行的动作:
-### 💡 检查同步状态
+- **上传** — 将本地版本应用到远程仓库。
+- **下载** — 将远程版本带回当前 vault。
+- **删除** — 将本地已删除的跟踪文件同步删除远程版本。
-每次开始工作或切换设备时,建议先查看状态:
+点击 **同步** 后会先生成一份合并的同步计划。远程的新增、修改、移动和删除会一起提交;下载则在确认后应用到本地。
-1. 点击侧边栏的**列表图标**,或打开命令面板 (`Ctrl/Cmd + P`) 并运行 `Open sync status view`。
-2. 同步状态会在 Obsidian 启动后自动刷新;需要时仍可点击 **Refresh status**。
-3. 使用状态标签、路径搜索或可选的树状视图浏览文件。树状视图支持展开文件夹和三态复选框,可一次选中整个文件夹。
-4. 文件会显示为:
- - **Synced**:已同步(与远程一致)。
- - **Modified**:本地已修改(需要 Push)。
- - **Remote only**:远程有新文件(需要 Pull)。
- - **Moved**:文件或文件夹已重命名/移动,尚待同步;可以 Push 或撤销移动。
+### 文件状态
-
-*同步状态面板让您一目了然地确认哪些文件已经修改,并进行上传或下载。*
+| 状态 | 含义 |
+|---|---|
+| `A` | 本地新增 |
+| `M` | 本地已修改 |
+| `D` | 本地已删除 |
+| `R` | 已重命名或移动 |
+| `↓` | 远程可下载 |
+| `↕` | 远程已修改 |
+| `!` | 冲突 |
+| `S` | 已同步 |
-### ⬆️ 如何上传(Push)
+> **本地已删除:** 将 `D` 项目加入同步队列后,默认会同步删除远程的跟踪文件。如果是误删,请改用 **下载**,把远程版本恢复到本地。
-写完笔记后,您可以:
+## 常见操作
-- **单个文件**:点击左侧功能栏的云上传图标,或在文件列表中右键选择 `Push to GitLab/GitHub/Gitea`。
-- **批量上传**:在同步面板勾选多个文件,然后点击 **Push selected**。
-- **确认计划**:每次 Push 前都会列出新增、修改、移动和删除项目;确认后点击 **Apply**。文件重命名或文件夹移动会作为真正的移动提交,不会在远程留下重复文件。
+| 情况 | 操作结果 |
+|---|---|
+| 本地新增文件 | `A` → 同步队列 → **上传** |
+| 本地修改文件 | `M` → 同步队列 → **上传** |
+| 文件只存在远程 | `↓` → 同步队列 → **下载** |
+| 远程版本已修改 | `↕` → 同步队列 → **下载** |
+| 本地删除跟踪文件 | `D` → 同步队列 → **删除**远程 |
+| 本地误删 | `D` → **下载** → 恢复本地 |
+| 重命名/移动 | `R` → 同步队列 → 以移动方式**上传** |
+| 本地与远程都修改 | `!` → 检查冲突 → **保留本地**或**采用远程** |
-### ⬇️ 如何下载(Pull)
+## 差异对比与冲突
-1. 打开同步面板并点击 **Refresh status**。
-2. 找到标记为 **Remote only** 或 **Modified**(远程版本较新)的文件。
-3. 勾选后点击 **Pull selected**。
-4. 查看即将应用的同步计划,然后点击 **Apply**。
-5. **注意**:Pull 会覆盖本地内容。如果两端都有修改,会自动打开冲突解决窗口。
+选择有变更的文件后,可以在同步前查看本地与远程内容差异。Diff 支持单栏与并排布局,并在可用时显示新增/删除行数。
----
+
+*同步前先检查本地与远程的差异,再决定保留哪一侧。*
-## 3. 冲突解决(Conflict Resolution)
+如果本地与远程都修改过同一个文件,Git File Sync 会保留明确的冲突状态:
-当同一文件在本地和远程都被修改时,会显示冲突窗口:
+- **Keep Local/保留本地** — 使用本地内容覆盖远程。
+- **Keep Remote/采用远程** — 接受远程版本并覆盖本地。
-1. 左侧是**本地版本**,右侧是**远程版本**。
-2. 查看两侧的差异。
-3. 选择 **Keep Local**(保留本地版本)或 **Keep Remote**(采用远程版本)。
-4. 选择后,插件会更新文件。
+## 支持的 Git 服务
-
-*内置差异查看器可在同步前并排对比本地与远程的修改。*
+| 服务 | 适用场景 | 最低版本 |
+|---|---|---|
+| **GitHub** | github.com/GitHub Enterprise | — |
+| **GitLab** | gitlab.com/自建 | GitLab 13.0+ |
+| **Gitea** | 自建 Git 服务器 | Gitea 1.12+ |
+
+## 初始设置
+
+
+*在设置面板选择 Git 服务并配置仓库。*
+
+| 服务 | 必要信息 | 建议权限 |
+|---|---|---|
+| **GitHub** | Token、owner、repository | Fine-grained token:**Contents: Read and write** |
+| **GitLab** | Token、project ID、base URL | `read_repository`、`write_repository` |
+| **Gitea** | Token、owner、repository、base URL | Gitea 1.19+:`write:repository` |
+
+其他设置包括语言、同步分支、仓库 Root Path、vault folder 范围、启动时刷新、忽略规则,以及 symbolic link 处理方式。Symbolic link 详细行为请参考 [Symbolic link handling](docs/symlink-handling.md)。
+
+> **安全建议:** 令牌只授予必要仓库和最低权限,能设置到期时间就设置;不要把令牌写进可能被同步的笔记。如果怀疑泄露,请立即撤销并重新签发。
+
+## 移动端
+
+移动端使用相同的版本控制模型。同步队列默认保持紧凑,避免把“仓库变更”推离屏幕;选择文件后会进入适合手机操作的详情/Diff 界面。
+
+跨设备工作时,建议先刷新远程状态再开始修改;完成后只把真正要同步的项目加入同步队列。
+
+## 安装
+
+### 从社区插件安装(推荐)
+
+1. 打开 **设置 → 社区插件**,必要时关闭限制模式。
+2. 点击 **浏览**,搜索 **Git File Sync**。
+3. 点击 **安装**,完成后 **启用**。
+
+### 手动安装
-在桌面端,点击 **Diff** 会在专用窗格打开对比;移动端仍在面板内显示。可以点击文件路径打开本地笔记,或在支持的服务上打开远程文件页面。
+1. 从 [最新 Release](https://github.com/firstsun-dev/git-files-sync/releases/latest) 下载 `main.js`、`manifest.json`、`styles.css`。
+2. 创建 `/.obsidian/plugins/git-file-sync/`。
+3. 将三个文件放入该目录。
+4. 重新加载 Obsidian,并在 **设置 → 社区插件** 中启用 Git File Sync。
----
+## 隐私与安全
-## 4. 移动设备使用技巧
+- **Token 仅保存在本地** — 访问令牌保存在 vault 内的插件数据中,只会发送给你配置的 Git 服务。
+- **无遥测** — 插件不收集使用分析或个人数据。
+- **选择性同步** — 未选入同步流程的文件不会因为新版版本控制流程而自动上传。
-- **打开面板**:从屏幕左侧向右滑动,展开功能栏后即可看到同步图标。
-- **工作前先 Pull**:每次开始编辑前,先刷新状态以确认已获取最新版本。
-- **完成后及时 Push**:编辑完成后及时 Push,确保变更已保存到远程。
+## 系统要求
----
+- Obsidian **1.11.0** 或更新版本
+- 支持桌面端与移动端
-## 🔒 隐私与安全
+## 更多文档
-- 个人访问令牌只保存在本地 vault 的插件数据目录中,只会发送到您配置的 Git 服务。
-- 插件不收集使用数据或分析信息。
+- [English README](README.md)
+- [繁體中文使用指南](USAGE_zh.md)
+- [Symbolic link handling](docs/symlink-handling.md)
+- [完整更新日志](CHANGELOG.md)
+- [Releases](https://github.com/firstsun-dev/git-files-sync/releases)
diff --git a/USAGE_zh.md b/USAGE_zh.md
index 83eae03..c367983 100644
--- a/USAGE_zh.md
+++ b/USAGE_zh.md
@@ -1,117 +1,163 @@
-# Git File Sync 使用說明書
+# Git File Sync 使用指南
[](https://github.com/firstsun-dev/git-files-sync/actions/workflows/ci.yml)
[](https://github.com/firstsun-dev/git-files-sync/releases)
[](https://obsidian.md/plugins?id=git-file-sync)
[](LICENSE)
-
+**[English](README.md)** · **[简体中文](USAGE_zh-cn.md)** · **[版本紀錄](CHANGELOG.md)**
-本指南將引導您如何使用 Git File Sync 外掛,在行動裝置與桌面電腦之間,透過 GitLab、GitHub 或自架的 Gitea 選擇性同步筆記。
+Git File Sync 讓你透過 GitHub、GitLab 或自架 Gitea,在桌面版與行動版 Obsidian 中**選擇性同步檔案**,不需要把整個 vault 都交給同一套同步服務。
-**[English](README.md)** · **[简体中文](USAGE_zh-cn.md)** · **[版本紀錄](CHANGELOG.md)**
+你可以先檢查變更、選擇這次真正要同步的項目,再從「同步佇列」一次確認與套用。外掛直接透過 Git 服務 API 操作,不需要安裝 Git CLI,也不需要在 vault 中建立本機 `.git` 儲存庫。
----
+## 新版操作模型
-## 支援的 Git 服務
+Git File Sync 的版本控制流程可以記成:
-
-
-
+**檢查變更 → 加入同步佇列 → 同步**
-| | 服務 | 適用情境 | 最低版本 |
-| :---: | :--- | :--- | :--- |
-|
| **GitHub** | 公開 / 私人儲存庫 | — |
-|
| **GitLab** | gitlab.com 或自架 | GitLab 13.0+ |
-|
| **Gitea** | 自架 Git 伺服器 | Gitea 1.12+ |
+1. **檢查「儲存庫變更」** — 在同一個畫面查看本機變更、遠端變更、重新命名、刪除與衝突。
+2. **建立「同步佇列」** — 勾選這次真正要處理的項目。
+3. **檢查並同步** — 在套用前先查看完整同步計畫,再執行上傳、下載與刪除。
----
+被選取的項目會從「儲存庫變更」移到「同步佇列」,同一筆變更不會同時出現在兩個區域。
-## 1. 初始設定
+## 主要功能
-在開始同步之前,請確保您已完成以下設定:
+- **只同步你選擇的檔案** — 私人筆記或無關檔案可以留在本機。
+- **統一的版本控制畫面** — 集中查看本機、遠端、移動、刪除與衝突狀態。
+- **一個同步佇列** — 同一次操作可以包含上傳、下載與遠端刪除。
+- **套用前先檢查** — 新增、修改、移動、下載與刪除都會先出現在同步計畫中。
+- **內建差異比對** — 支援單欄與並排 Diff,比對本機與遠端版本。
+- **明確處理衝突** — 由你決定保留本機或遠端,不會靜默覆蓋。
+- **多平台與多服務** — 支援 GitHub、GitLab、Gitea,以及桌面版與行動版 Obsidian。
+- **三種介面語言** — English、繁體中文、简体中文。
-
-*在設定面板選擇您的 Git 服務並填入對應的憑證與路徑。*
+## 快速開始
-1. **選擇服務**:在 `設定` > `Git File Sync` 中選擇 GitLab、GitHub 或 Gitea。
-2. **填寫憑證**:
+1. 從 Obsidian 社群外掛安裝 Git File Sync。
+2. 在 **設定 → Git File Sync** 中設定 GitHub、GitLab 或 Gitea。
+3. 從側邊功能列或指令面板開啟 **版本控制(Source Control)**。
+4. 檢查 **儲存庫變更**。
+5. 勾選要同步的項目,項目會移到 **同步佇列**。
+6. 點擊 **同步**,檢查同步計畫後按 **套用**。
- > **安全性提示:** 請將每個權杖的範圍縮到最小:只允許需要同步的儲存庫、只授予必要權限,並設定較短的有效期限。權杖只應儲存在此外掛的設定中,不要貼到會被同步的筆記。如果權杖可能外洩,請立即撤銷並重新建立;不再使用的權杖也應直接撤銷。
+## 版本控制畫面
- - **GitHub**:建議建立 [fine-grained personal access token](https://github.com/settings/personal-access-tokens/new),而不是 classic token。在 **Repository access** 選擇 *Only select repositories*,只指定要同步的儲存庫;設定 **Expiration**(建議 90 天以內),並只授予 **Contents: Read and write**。若必須使用 classic token,則使用 `repo` scope,並為此用途設定到期日。
- - **GitLab**:建議優先使用 [project access token](https://docs.gitlab.com/user/project/settings/project_access_tokens/)(`Project` > `Settings` > `Access tokens`),而不是個人存取權杖,讓權限限制在單一專案,也能獨立撤銷。外掛只會呼叫 repository tree、blob、commit 與 branch 相關 API,因此只需要 `read_repository` 與 `write_repository`,**不需要** `api`。若要推送到非 protected branch,最低角色需為 **Developer**。請設定到期日;伺服器網址預設為 `https://gitlab.com`,自架環境請改成您的實例網址。
- - **Gitea**:在 `使用者設定` > `應用程式` > `存取權杖` 建立權杖。外掛只會操作儲存庫內容、分支與 Git data;Gitea 1.19+ 支援 scoped token,請只選 **`write:repository`**(已包含讀取權限),不要選擇全部權限。較舊版本(最低支援至 1.12)沒有 scoped token,權杖預設為帳號層級;這種情況建議使用只具備目標儲存庫權限的專用 bot / service account,而不是個人帳號。若實例支援到期日也請設定,並將伺服器網址指向您的 Gitea 實例(例如 `https://gitea.example.com`)。
+### 儲存庫變更
- 三種服務都可以從各自的設定頁立即撤銷權杖。如果權杖可能外洩,請先撤銷,再簽發新的權杖。
-3. **儲存庫路徑**:如果您想將筆記存放在儲存庫的特定資料夾(例如 `notes/`),請在 `Root Path` 中設定。
-4. **語言與自動重新整理**:可選擇跟隨系統、English、繁體中文或简体中文;「Obsidian 啟動時自動重新整理同步狀態」預設開啟,亦可在設定中關閉。
+顯示目前需要處理、但尚未加入下一次同步的項目。可以使用搜尋、篩選器與樹狀/清單檢視快速縮小範圍。
----
+### 同步佇列
-## 2. 核心操作流程
+顯示下一次「同步」會實際處理的項目。每一筆都會標示預計執行的動作:
-### 💡 檢查同步狀態
-每次開始工作或切換裝置時,建議先檢查狀態:
-1. 點擊側邊欄的 **清單圖示** 或使用指令面板 (`Ctrl/Cmd + P`) 輸入 `Open sync status view`。
-2. 同步狀態會在 Obsidian 啟動後自動重新整理;需要時仍可點擊 **Refresh status**。
-3. 使用狀態分頁、路徑搜尋,或選擇性的樹狀檢視來瀏覽檔案。樹狀檢視支援展開資料夾與三態勾選框,可一次選取整個資料夾。
-4. 您會看到檔案清單,標示為:
- - **Synced**:已同步(與雲端一致)。
- - **Modified**:本機已修改(需要 Push)。
- - **Remote only**:雲端有新檔案(需要 Pull)。
- - **Moved**:檔案或資料夾已重新命名/移動,尚待同步;可 Push 或還原移動。
+- **上傳** — 將本機版本套用到遠端儲存庫。
+- **下載** — 將遠端版本帶回目前 vault。
+- **刪除** — 將本機已刪除的追蹤檔案同步刪除遠端版本。
-
-*同步狀態面板讓您可以一目了然地確認哪些檔案已經修改,並進行上傳或下載。*
+按下 **同步** 後會先產生一份合併的同步計畫。遠端的新增、修改、移動與刪除會一起提交;下載則在確認後套用到本機。
----
+### 檔案狀態
-### ⬆️ 如何上傳(Push)
-當您寫完筆記,想備份到雲端時:
-- **單一檔案**:
- - 點擊左側功能列的 **雲端上傳圖示**。
- - 或者在檔案列表點擊右鍵,選擇 `Push to GitLab/GitHub/Gitea`。
-- **批量上傳**:
- - 在同步面板勾選多個檔案,點擊下方的 **Push selected**。
-- **確認計畫**:每次 Push 前會先列出新增、修改、移動與刪除項目;確認後點擊 **Apply**。重新命名檔案或移動資料夾會作為真正的移動同步,不會在遠端留下重複檔案。
+| 狀態 | 意義 |
+|---|---|
+| `A` | 本機新增 |
+| `M` | 本機已修改 |
+| `D` | 本機已刪除 |
+| `R` | 已重新命名或移動 |
+| `↓` | 遠端可下載 |
+| `↕` | 遠端已修改 |
+| `!` | 衝突 |
+| `S` | 已同步 |
----
+> **本機已刪除:** 將 `D` 項目加入同步佇列後,預設會同步刪除遠端的追蹤檔案。如果你是誤刪,請改用 **下載**,將遠端版本還原回本機。
-### ⬇️ 如何下載(Pull)
-當您在另一台裝置更新了筆記,想同步回目前裝置時:
-1. 打開同步面板,點擊 **Refresh status**。
-2. 找到顯示為 **Remote only** 或 **Modified**(雲端版本較新)的檔案。
-3. 勾選後點擊 **Pull selected**。
-4. 先確認即將套用的同步計畫,再點擊 **Apply**。
-5. **注意**:Pull 會覆蓋掉您本機的內容。如果有衝突,會自動開啟衝突解決視窗。
+## 常見操作
----
+| 情況 | 操作結果 |
+|---|---|
+| 本機新增檔案 | `A` → 同步佇列 → **上傳** |
+| 本機修改檔案 | `M` → 同步佇列 → **上傳** |
+| 檔案只存在遠端 | `↓` → 同步佇列 → **下載** |
+| 遠端版本已修改 | `↕` → 同步佇列 → **下載** |
+| 本機刪除追蹤檔案 | `D` → 同步佇列 → **刪除**遠端 |
+| 本機誤刪 | `D` → **下載** → 還原本機 |
+| 重新命名/移動 | `R` → 同步佇列 → 以移動方式**上傳** |
+| 本機與遠端都修改 | `!` → 檢查衝突 → **保留本機**或**採用遠端** |
-## 3. 衝突處理 (Conflict Resolution)
+## 差異比對與衝突
-如果同一個檔案在本機和雲端都被修改過,同步時會跳出衝突視窗:
-1. 左側為 **本機版本**,右側為 **雲端版本**。
-2. 您可以查看差異處。
-3. 選擇 **Keep Local**(保留本機)或 **Keep Remote**(採用雲端版本)。
-4. 選擇後系統會自動更新檔案。
+選擇有變更的檔案後,可以在同步前查看本機與遠端內容差異。Diff 支援單欄與並排版面,並在可用時顯示新增/刪除行數。

-*內建的差異比對工具 (Diff Viewer) 可讓您在同步前並排比對本機與雲端的修改差異。*
+*同步前先檢查本機與遠端的差異,再決定要保留哪一側。*
+
+如果本機與遠端都修改過同一個檔案,Git File Sync 會保留明確的衝突狀態:
+
+- **Keep Local/保留本機** — 使用本機內容覆蓋遠端。
+- **Keep Remote/採用遠端** — 接受遠端版本並覆蓋本機。
+
+## 支援的 Git 服務
+
+| 服務 | 適用情境 | 最低版本 |
+|---|---|---|
+| **GitHub** | github.com/GitHub Enterprise | — |
+| **GitLab** | gitlab.com/自架 | GitLab 13.0+ |
+| **Gitea** | 自架 Git 伺服器 | Gitea 1.12+ |
+
+## 初始設定
+
+
+*在設定面板選擇 Git 服務並設定儲存庫。*
+
+| 服務 | 必要資訊 | 建議權限 |
+|---|---|---|
+| **GitHub** | Token、owner、repository | Fine-grained token:**Contents: Read and write** |
+| **GitLab** | Token、project ID、base URL | `read_repository`、`write_repository` |
+| **Gitea** | Token、owner、repository、base URL | Gitea 1.19+:`write:repository` |
+
+其他設定包含語言、同步分支、儲存庫 Root Path、vault folder 範圍、啟動時重新整理、忽略規則,以及 symbolic link 處理方式。Symbolic link 詳細行為請參考 [Symbolic link handling](docs/symlink-handling.md)。
+
+> **安全性建議:** 權杖只授予必要的儲存庫與最低權限,能設定到期日就設定;不要把權杖寫進可能被同步的筆記。若懷疑外洩,立即撤銷並重新簽發。
+
+## 行動裝置
+
+行動版使用相同的版本控制模型。同步佇列預設保持精簡,避免把「儲存庫變更」推離畫面;選擇檔案後則進入適合手機操作的詳細/Diff 畫面。
+
+跨裝置工作時,建議先重新整理遠端狀態,再開始修改;完成後只把真正要同步的項目加入同步佇列。
+
+## 安裝
+
+### 從社群外掛安裝(建議)
+
+1. 打開 **設定 → 社群外掛**,必要時關閉限制模式。
+2. 點擊 **瀏覽**,搜尋 **Git File Sync**。
+3. 點擊 **安裝**,完成後 **啟用**。
+
+### 手動安裝
-在桌面版,點擊 **Diff** 會在專屬窗格開啟比對;行動版則維持面板內的比對。可點擊檔案路徑開啟本機筆記,或在支援的服務上開啟遠端檔案頁面。
+1. 從 [最新 Release](https://github.com/firstsun-dev/git-files-sync/releases/latest) 下載 `main.js`、`manifest.json`、`styles.css`。
+2. 建立 `/.obsidian/plugins/git-file-sync/`。
+3. 將三個檔案放入該目錄。
+4. 重新載入 Obsidian,並在 **設定 → 社群外掛** 啟用 Git File Sync。
----
+## 隱私與安全
-## 4. 行動裝置使用技巧
+- **Token 僅存本機** — 存取權杖儲存在 vault 內的外掛資料中,只會傳送給你設定的 Git 服務。
+- **無遙測** — 外掛不收集使用分析或個人資料。
+- **選擇性同步** — 未選入同步流程的檔案不會因新版版本控制流程而自動上傳。
-- **開啟面板**:從螢幕左側向右滑動,展開功能列即可看到同步圖示。
-- **工作前先 Pull**:建議每次開始寫筆記前,先點一下 Refresh 確保讀取到最新版本。
-- **完成後即 Push**:寫完後隨手 Push,確保您的變更已儲存至雲端。
+## 系統需求
----
+- Obsidian **1.11.0** 或更新版本
+- 支援桌面版與行動版
-## 🔒 隱私與安全
+## 更多文件
-- 您的存取權杖 (Token) 僅會儲存在本機 vault 的外掛資料目錄中,只會傳送到您設定的 Git 服務。
-- 本外掛不會收集任何個人資料或使用紀錄。
+- [English README](README.md)
+- [简体中文使用指南](USAGE_zh-cn.md)
+- [Symbolic link handling](docs/symlink-handling.md)
+- [完整版本紀錄](CHANGELOG.md)
+- [Releases](https://github.com/firstsun-dev/git-files-sync/releases)
diff --git a/archive/2026-08.md b/archive/2026-08.md
index 49bcb98..83c1c5b 100644
--- a/archive/2026-08.md
+++ b/archive/2026-08.md
@@ -16,3 +16,10 @@
- **PR #87**: 4x Dependabot security alerts — Being fixed via npm overrides; merge pending.
- **Issue #57**: Live-credential smoke test — Remains relevant before merging push/pull/delete work.
+
+## Late-August Additions
+
+- **PR #129 commits `2d6cf91` + `709905a`**: diff stat cache lifecycle fix (three-state ready/pending/unavailable cache, bounded background loading at 4 concurrent, per-row invalidate, create-event content read) and mobile Back scroll restoration (View-level scroll state + anchor ChangeId re-anchoring). Evidence: eslint 0 errors, build + Obsidian 1.11 compat pass, vitest 66 files / 770 tests (770 total across both commits).
+- **feat-027 / PR #140 (merged earlier)**: Gitea portability and runner trust separation — evidence preserved above in this file.
+- **PR #129 commits `05f6628` + `af376f2` (2026-08-30)**: lifecycle hardening fix-all round — mobile scroll lifecycle split (navigation restore only on Back; rerender classes never re-anchor), DiffStatProvider two-level generation guard rejecting stale in-flight results after invalidate/clear, full invalidation fingerprint (status/contents/SHA/movedFrom/isSymlink), resilient two-step A-row creation with per-path revision guard, loadVisible scoped to rendered rows (collapsed sections fire zero stat requests), one-sided diff semantics + in-flight remote-blob dedup in SyncDiffService, retryable background loader error policy. Plus legacy sync-status presentation cleanup: 81 dead i18n keys/locale removed, "Sync status" wording → "Source Control", ESLint no-restricted-imports guard vs ui/sync-status, coverage now includes src/ui/source-control/** with thresholds 70/70/70/60. Evidence: eslint 0 errors, build + Obsidian 1.11 compat pass, vitest 66 files / 788 tests, coverage 84.02/75.57/81.61/86.11. iPad regression + final CI pending at merge gate.
+- **PR #129 final-fix round (2026-08-30, commits `acd2046`/`78a78e8`/`49f3033`/`fbe0787`)**: four remaining review fixes — (1) CI whole-run workflow-level concurrency (`ci-` group; push+pull_request race can no longer split per-provider winners; dispatch/schedule get unique run-id groups; per-job e2e concurrency removed with 2 new contract tests), (2) DiffStatProvider per-request token identity + separate `physicalInFlight` counter so an old request's finally can't delete a newer request's marker and the 4-call cap counts real physical calls, (3) `handleFileModified` two-phase commit (re-read row after await, classify from current state, abandon on delete/rename) so a full refresh's remoteSha/remoteContent/isSymlink/movedFrom survive a pending modify read, (4) one-sided diff stat direction — ↓ remote-only renders +N and D local-deleted renders -N via new `addedContentStat`/`deletedContentStat`, leaving the diff-pane FileDiff sides untouched. Evidence: eslint 0 errors, build + Obsidian 1.11 compat pass on every commit, vitest 66 files / 804 tests. Final CI + iPad manual regression pending at merge gate.
diff --git a/docs/obsidian-scanner-audit.md b/docs/obsidian-scanner-audit.md
index 632649d..6a380cc 100644
--- a/docs/obsidian-scanner-audit.md
+++ b/docs/obsidian-scanner-audit.md
@@ -40,13 +40,13 @@ after the normal 1.5.7 release workflow completes.
## Phase 1 re-audit (Shell/Git E2E harness)
Real-provider E2E returned in `e2e/**` (test/real-provider-e2e), rebuilt so none of the
-previously-flagged APIs are used in any committed `.ts` file, regardless of directory —
-`scripts/e2e-harness.sh` (Shell, not TypeScript) now owns branch/container lifecycle and git
-authentication, and everything Node-only the suites still need at runtime (the real `requestUrl`
-shim, the `window` timer alias, a git-CLI-backed verifier) is generated by that script into
-`$E2E_RUNTIME_DIR` per run, never committed. See `docs/testing/real-provider-e2e.md`.
+previously-flagged APIs are used in any committed `.ts` file — `scripts/e2e-harness.sh` (Shell,
+not TypeScript) owned branch/container lifecycle and git authentication, and everything Node-only
+the suites needed at runtime (the real `requestUrl` shim, the `window` timer alias, a
+git-CLI-backed verifier) was generated by that script into `$E2E_RUNTIME_DIR` per run, never
+committed.
-Same grep-based method as the baseline above, re-run against the current tree:
+Same grep-based method as the baseline above, re-run against that tree:
| Check | Result |
| --- | --- |
@@ -56,13 +56,36 @@ Same grep-based method as the baseline above, re-run against the current tree:
| Bare `setTimeout`/`setInterval` (not `window.*`) in `e2e/**`/`src/**` | None |
| Unnecessary `as string` assertions in `e2e/config/env.ts` | Fixed — replaced with `requiredEnv()`, which throws instead of asserting |
-`e2e/**/*.ts` is back in `tsconfig.json`'s `include` and in `eslint.config.mts`'s scope
-(`npx eslint .` — 0 errors; `tsc -noEmit -skipLibCheck` — clean), since neither tool needs the
-harness to have run first: the only imports of generated (not-yet-existing-at-typecheck-time)
-files are runtime-computed dynamic `import()` calls, which `tsc` doesn't attempt to statically
-resolve.
+## Phase 2 re-audit (e2e-tests/ boundary, static runtime files)
-The actual official scanner rescan against this harness is still outstanding from this checkout
-(no access to the submission tooling here) — this section is the best available self-check in
-the meantime, per the task's own acknowledgment that the real validation is a separate,
-later step.
+The harness moved to `e2e-tests/provider/**`, and the previously-generated runtime files
+(`obsidian-request-url.ts`, `window-timers.ts`, `git-verifier.ts`) are now **committed** static
+`.ts` files under `e2e-tests/provider/runtime/` and `e2e-tests/provider/support/`, on the premise
+that the scanner's flagging is scoped to a submission's declared plugin surface
+(`manifest.json`/`main.js`), not a blanket repo-wide grep.
+
+**This premise is unverified.** Phase 1's own removal was prompted by the baseline finding above,
+which recorded these exact APIs being flagged while committed under `e2e/**` — a differently-named
+directory, not a different scoping mechanism. Re-committing them under `e2e-tests/**` instead of
+`e2e/**` changes the directory name but not, as far as this repo's own audit trail shows, the
+thing the scanner actually keys on. No official rescan has been run against this change from this
+checkout to confirm or refute that.
+
+Grep-based self-check against the current tree (same method as before, informational only — it
+was already passing under the generated-runtime design too, so it does not distinguish the two):
+
+| Check | Result |
+| --- | --- |
+| `fetch(` in `src/**` | None |
+| `globalThis` in `src/**` | None |
+| `node:crypto`/`node:child_process`/`node:util` in `src/**` | None |
+| `fetch`/`globalThis`/`node:child_process` in `e2e-tests/**` | Present (by design — see above) |
+
+`e2e-tests/**/*.ts` is in `tsconfig.json`'s `include` and in `eslint.config.mts`'s scope
+(`npx eslint .` — 0 errors; `tsc -noEmit -skipLibCheck` — clean); unlike Phase 1, these are real
+committed files, not ambient-module stand-ins for a generated target.
+
+**Follow-up required**: get an actual official scanner rescan against this directory structure
+before relying on it. If it reproduces the Phase 1 finding, revert to per-run generation into an
+uncommitted directory (git history has the Phase 1 implementation) rather than trying a third
+directory name.
diff --git a/docs/source-control-refactor/phase-1-viewmodel-foundation.md b/docs/source-control-refactor/phase-1-viewmodel-foundation.md
new file mode 100644
index 0000000..ca14c94
--- /dev/null
+++ b/docs/source-control-refactor/phase-1-viewmodel-foundation.md
@@ -0,0 +1,65 @@
+# Phase 1 — Source Control ViewModel Foundation
+
+## Goal
+
+建立 Source Control UI 與 Sync domain 之間的 ViewModel layer。
+
+本階段不修改同步行為,只整理資料流。
+
+## Scope
+
+- ChangeRepository
+- SourceControlFilter
+- SourceControlViewModel
+- ChangeTreeBuilder
+
+## Architecture
+
+```
+UI
+ |
+SourceControlViewModel
+ |
+SyncManager
+```
+
+## Modules
+
+```
+src/logic/source-control/
+├── ChangeRepository.ts
+├── SourceControlFilter.ts
+├── SourceControlViewModel.ts
+└── ChangeTreeBuilder.ts
+```
+
+## Filter
+
+Supported:
+
+- all
+- changes
+- ready-to-push
+- remote-changes
+- conflicts
+- synced
+
+## Rules
+
+UI components consume ViewModel only.
+
+No direct SyncManager access from UI.
+
+## Tests
+
+- ChangeRepository
+- SourceControlViewModel
+- ChangeTreeBuilder
+
+Cases:
+
+- local changes
+- remote changes
+- conflicts
+- ready to push
+- rename keeps ChangeId
diff --git a/docs/source-control-refactor/phase-2-action-unification.md b/docs/source-control-refactor/phase-2-action-unification.md
new file mode 100644
index 0000000..f9623bd
--- /dev/null
+++ b/docs/source-control-refactor/phase-2-action-unification.md
@@ -0,0 +1,72 @@
+# Phase 2 — Sync Action Unification
+
+## Goal
+
+統一 Source Control、Context Menu、Single File 操作的 pipeline。
+
+## Architecture
+
+```
+User Action
+ |
+SourceControlActionService
+ |
+SyncPlan
+ |
+SyncExecutor
+ |
+Git Provider
+```
+
+## New Module
+
+```
+src/logic/source-control/
+└── SourceControlActionService.ts
+```
+
+## Actions
+
+- Push
+- Pull
+- Delete Remote
+- Delete Local
+- Resolve Conflict
+
+## Rules
+
+ActionService:
+
+DO:
+- convert user intent to SyncPlan
+
+DO NOT:
+- execute git operation
+- classify changes
+
+## Flows
+
+Single file:
+
+```
+changeId
+ -> ActionService
+ -> SyncPlan
+ -> Executor
+```
+
+Batch:
+
+```
+changeIds
+ -> ActionService
+ -> SyncPlan
+```
+
+## Tests
+
+- single push
+- batch push
+- pull
+- conflict resolution
+- invalid ChangeId
diff --git a/docs/source-control-refactor/phase-3-source-control-ui.md b/docs/source-control-refactor/phase-3-source-control-ui.md
new file mode 100644
index 0000000..f929917
--- /dev/null
+++ b/docs/source-control-refactor/phase-3-source-control-ui.md
@@ -0,0 +1,82 @@
+# Phase 3 — Source Control UI
+
+## Goal
+
+建立 VS Code style Source Control workflow。
+
+## Layout
+
+```
+SourceControlView
+ |
+ + Header
+ + Filter
+ + ChangeTree
+ + DiffPanel
+```
+
+## Sections
+
+- READY TO PUSH
+- CHANGES
+- REMOTE CHANGES
+- CONFLICTS
+- SYNCED
+
+## Filter
+
+```
+All
+Changes
+Ready to Push
+Remote Changes
+Conflicts
+Synced
+```
+
+## Tree View
+
+Example:
+
+```
+▼ notes
+ M daily.md
+ A idea.md
+
+▼ projects
+ ! settings.md
+```
+
+## Components
+
+```
+SourceControlView
+SourceControlHeader
+FilterMenu
+ChangeTree
+ChangeItem
+ChangeSection
+PushButton
+OperationIndicator
+```
+
+## Responsive
+
+Desktop:
+- Tree + Diff
+
+Mobile:
+- List + Detail
+
+## Tests
+
+- SourceControlView
+- ChangeTree
+- FilterMenu
+
+Cases:
+
+- filter switching
+- selection
+- push action
+- operation status
diff --git a/docs/source-control-refactor/phase-4-legacy-cleanup.md b/docs/source-control-refactor/phase-4-legacy-cleanup.md
new file mode 100644
index 0000000..a1da710
--- /dev/null
+++ b/docs/source-control-refactor/phase-4-legacy-cleanup.md
@@ -0,0 +1,58 @@
+# Phase 4 — Legacy Cleanup
+
+## Goal
+
+移除舊 Source Control orchestration,保留同步核心能力。
+
+## Remove
+
+- old status mapping
+- duplicated action handling
+- legacy SyncStatusView logic
+
+## Final Architecture
+
+```
+UI
+ |
+ViewModel
+ |
+ActionService
+ |
+SyncPlan
+ |
+Executor
+ |
+Provider
+```
+
+## SyncManager
+
+Before:
+
+- UI state
+- classification
+- execution
+
+After:
+
+- sync facade
+
+## Test Cleanup
+
+Remove:
+
+- duplicated implementation tests
+
+Keep:
+
+- sync integration tests
+- provider tests
+- conflict tests
+
+## Acceptance
+
+- UI has no sync logic
+- no duplicate action pipeline
+- existing behavior preserved
+- architecture docs updated
diff --git a/docs/source-control-refactor/roadmap.md b/docs/source-control-refactor/roadmap.md
new file mode 100644
index 0000000..a928ff3
--- /dev/null
+++ b/docs/source-control-refactor/roadmap.md
@@ -0,0 +1,206 @@
+# Source Control Refactor — Roadmap (v2)
+
+> Supersedes `phase-1..4-*.md`. Those phase docs are kept only as historical
+> design notes; this file is the authoritative current plan, grounded in the
+> actual branch state as of 2026-08-22.
+
+## Where we actually are
+
+The committed branch `claude/source-control-foundation` (7 commits, 34 files,
++2378) delivered the **foundation** in three commits:
+
+- ✅ Phase 1 — ViewModel foundation: `ChangeRepository`, `SourceControlFilter`,
+ `SourceControlViewModel`, `ChangeTreeBuilder` (`76db082`)
+- ✅ Phase 2 — Action unification: `SourceControlActionService` over
+ `SyncWorkspace` (`70f6c9e`)
+- ✅ Phase 3 — Source Control UI skeleton: `SourceControlView` + components
+ (`7cec661`)
+
+On top of that, the **active agent worktree** carries uncommitted WIP that
+already performs **Phase A (wire new view as the only entry) and Phase E
+(delete the legacy UI) together**, and it is verified green:
+
+```
+npx eslint . -> 0 errors
+npm run build -> PASS (tsc + Obsidian 1.11.0 compat + esbuild)
+npx vitest run -> 55 files / 531 tests PASS
+```
+
+WIP contents (all uncommitted):
+
+- `src/main.ts`: registers `SourceControlItemView` under the **legacy** view
+ type string `sync-status-view` (so pinned leaves migrate cleanly), rewires
+ ribbon + `open-sync-status` command + startup refresh to
+ `activateSourceControlView()`, constructs `ChangeRepository` /
+ `PushSelectionStore` / `OperationState` / `SourceControlViewModel` /
+ `SourceControlActionService` on the plugin, subscribes
+ `sync.status` → `ChangeRepository.replace(toSyncChanges(...))`, and
+ unsubscribes in `onunload`.
+- `src/ui/source-control/SourceControlItemView.ts` (new, 78 lines): thin
+ `ItemView` host that delegates rendering to `SourceControlView` and routes
+ `onPush` / `loadDiffContent` to `plugin.sourceControlActions`.
+- `src/logic/source-control/FileStatusAdapter.ts` (new, 57 lines):
+ `toSyncChanges(statuses)` — the adapter from the existing
+ `SyncStatusService` status map into `SyncChange[]` for `ChangeRepository`.
+- Deletes: `src/ui/SyncStatusView.ts`, `src/ui/DiffView.ts`, all
+ `src/ui/components/{ActionBar,FileListItem,FolderTreeItem,StatusTree}.ts`,
+ all `src/ui/sync-status/*.ts`, and their tests.
+- `styles.css`: −547 / +174 (legacy tree styles removed).
+
+**Consequence:** the next agent must NOT redo Phase A or Phase E. They exist
+as green WIP. The next agent's job is to (1) land that WIP with manual Obsidian
+verification, then (2) move to Phase B.
+
+## Architecture (verified against source)
+
+```
+SyncChange ── FileStatusAdapter ──▶ ChangeRepository
+ │
+ SourceControlViewModel ◀── PushSelectionStore
+ │ OperationState
+ ┌───────────────┴────────────────┐
+ Filter Selection
+ └───────────────┬────────────────┘
+ ▼
+ SourceControlItemView (ItemView host, 78 lines)
+ │ delegates render
+ ▼
+ SourceControlView (render, 204 lines)
+ │ callbacks
+ ▼
+ SourceControlActionService
+ │
+ ▼
+ SyncWorkspace (push/pull/delete/diff)
+ │
+ ▼
+ SyncManager → Provider
+```
+
+Entry wiring (Phase A, done as WIP): ribbon + command + startup →
+`activateSourceControlView()` → `SOURCE_CONTROL_VIEW_TYPE` leaf →
+`SourceControlItemView`.
+
+## Phase A — Wire existing UI entry ✅ DONE (uncommitted, green WIP)
+
+See WIP contents above. Acceptance already met at the automated level:
+new view is the sole registered entry; ribbon/command/startup all route
+through it; old UI deleted.
+
+**Remaining for "done" per DoD:** manual Obsidian verification in a real vault
+(ribbon opens the new panel, tree/filter/push render, live modify/rename
+refresh, pinned leaf migration, `onunload` cleanup). Then commit the WIP.
+
+## Phase E — Legacy cleanup ✅ DONE (same WIP as Phase A)
+
+Old `SyncStatusView`, `DiffView`, `components/*`, `sync-status/*` and their
+tests deleted; `styles.css` trimmed. No duplicate action handlers remain
+(commands go through `SourceControlActionService`). Lands together with
+Phase A.
+
+## Phase B — Surface conflict as domain state ◀ NEXT (real gap)
+
+This is the largest real gap and the user's risk #2/#3. The conflict model
+**already exists** in the executor layer — it must be *surfaced*, not
+recreated:
+
+- `src/logic/sync/types.ts`: `PushResults` already carries
+ `conflicts`, `resolvedConflicts`, `skippedConflicts`, `conflictedPaths`,
+ `errors`; `SyncResult` carries `conflicts` count.
+- `src/logic/sync/ConflictResolver.ts`: `BatchPushConflict`,
+ `findStale`, `applyRemote` — full conflict lifecycle.
+- `src/logic/sync/PullCoordinator.ts`: `BatchOutcome = 'done' | 'unchanged' | 'conflict'`.
+
+The gap is entirely in the Source Control layer:
+
+1. **`OperationState`** (`src/logic/source-control/OperationState.ts`) only has
+ `OperationStatus = 'idle' | 'running' | 'success' | 'failed'`. Add
+ `'conflict'` (a.k.a. needs-resolution) — a **different lifecycle** from
+ `'failed'` (resolvable, not an error).
+2. **`SourceControlActionService.push/pull`** currently does
+ `finishAll(targets, path => failed.has(path) ? 'failed' : 'success')`
+ reading only `results.errors`. It must instead read
+ `results.conflictedPaths` (and/or `results.conflicts > 0`) and mark those
+ `'conflict'`, leaving genuine errors as `'failed'`. Reuse the executor's
+ conflict semantics — do **not** create a parallel `ConflictState.ts`.
+3. **`ExecutionResult`** (new, thin projection — *not* a new executor): batch
+ push/pull return `{ completed: ChangeId[]; conflicts: ChangeId[]; failed:
+ ChangeId[] }` so the UI can show "7 success, 3 conflict" instead of just
+ success/failed. This is a projection of `PushResults`/`SyncResult`, derived
+ in `SourceControlActionService`, not a new sync-domain type.
+4. **`SourceControlViewModel`** surfaces conflict count + the conflict item
+ list; `SourceControlFilter` already has a `'conflicts'` filter value — wire
+ it to the new `'conflict'` operation status.
+5. UI: a `CONFLICTS (n)` section listing conflicted changes with a
+ `[Resolve All]` entry point (resolution UX is Phase C).
+
+Tests first (TDD): `OperationState` conflict status; `ActionService` maps
+`conflictedPaths` → `'conflict'` and returns `ExecutionResult` counts;
+`ViewModel` exposes conflict list/count; filter `'conflicts'` resolves to the
+new status.
+
+## Phase C — Diff / conflict resolution UX
+
+Reuses the existing `SyncWorkspace.getDiff` / `SyncDiffService` path that
+`SourceControlActionService.loadDiffContent` already calls — no new diff
+logic, only layout + resolution actions.
+
+New UI:
+
+- `src/ui/source-control/ConflictPanel.ts` — the `CONFLICTS (n)` list +
+ per-item actions.
+- `src/ui/source-control/DiffLayoutSelector.ts` — Desktop: `Tree | Diff`
+ split; Mobile: `List → Diff` stack.
+
+Actions (route through `SourceControlActionService.resolveConflict`, which
+already exists for `'local' | 'remote'`):
+
+- Accept Local → `resolveConflict(id, 'local')` (push local)
+- Accept Remote → `resolveConflict(id, 'remote')` (pull remote)
+- Manual Merge → opens an editor merge path (new; scope TBD).
+
+## Phase D — Context menu migration
+
+Currently no context menu in the new UI (verified: no `contextmenu` /
+`addMenu` references in `src/ui/source-control/`). Unify right-click on a
+change row:
+
+```
+Right-click on change row
+ → changeId
+ → SourceControlActionService.{push|pull|deleteRemote|deleteLocal|resolveConflict|loadDiffContent}
+```
+
+Menu items: Push, Pull, Open Diff, Delete Remote, Delete Local, Resolve
+Conflict. No direct `SyncWorkspace`/`GitService` access from the menu — only
+through `SourceControlActionService`.
+
+## Ordering & risk notes
+
+```
+PR #127 foundation (merged)
+ │
+ ▼
+A + E ── land the green WIP: commit + manual Obsidian verify ◀ do first
+ │
+ ▼
+B ── surface executor conflict state via OperationState + ExecutionResult
+ │
+ ▼
+C ── diff / conflict resolution UX (reuses existing diff path)
+ │
+ ▼
+D ── context menu → ActionService
+```
+
+Risk notes from the review, confirmed against source:
+
+1. **`SourceControlView.ts` is 204 lines** — but the WIP already split the
+ `ItemView` host (`SourceControlItemView`, 78 lines) from the render logic.
+ Do not grow `SourceControlView` further; keep it a pure renderer over the
+ ViewModel.
+2. **Conflict ≠ failed.** `OperationState` must distinguish `'conflict'`
+ (needs-resolution, resolvable) from `'failed'` (error). Different
+ lifecycle. Phase B.
+3. **Batch needs `ExecutionResult`.** Without it the UI can only show
+ success/failed, not "7 success, 3 conflict". Phase B.
\ No newline at end of file
diff --git a/docs/test-coverage.md b/docs/test-coverage.md
index b67df82..54c9109 100644
--- a/docs/test-coverage.md
+++ b/docs/test-coverage.md
@@ -2,9 +2,11 @@
All tests are in `tests/` and run with `npm run test` (Vitest).
-## Temporary E2E status
+## Real-provider E2E
-Real-provider E2E source has been temporarily removed from the plugin repository because the Obsidian official scanner treats Node-only E2E tooling as plugin source. The long-term E2E architecture is being evaluated separately.
+Real-provider E2E (GitHub/GitLab/Gitea, against a real Git server) lives under
+`e2e-tests/provider/`, separate from the unit tests in `tests/`. See
+`docs/testing/real-provider-e2e.md` and `docs/obsidian-scanner-audit.md`.
---
diff --git a/docs/testing/real-provider-e2e.md b/docs/testing/real-provider-e2e.md
index 4e7ff78..1467022 100644
--- a/docs/testing/real-provider-e2e.md
+++ b/docs/testing/real-provider-e2e.md
@@ -23,25 +23,31 @@ GitHub Actions
This replaced an earlier Node-based harness (`e2e/provision`, `e2e/verifier`, `e2e/providers`,
`e2e/shim`, `scripts/run-e2e*.mjs`) that used `fetch`/`node:child_process`/`node:crypto` directly
-in committed `.ts` files. The Obsidian community-plugin scanner flags those APIs wherever they
-appear in the repo, regardless of directory — it doesn't matter that E2E code never ships in
-`main.js`. See `docs/obsidian-scanner-audit.md`.
-
-**The fix isn't "move it to a differently-named folder"** — it's that no committed `.ts` file
-uses those APIs at all:
-
-- `scripts/e2e-harness.sh` (Shell, not TypeScript) owns branch/container lifecycle: creating the
- isolated test branch via plain `git push :refs/heads/` (no REST branch-creation
- calls except the one GitLab numeric-project-ID resolution git genuinely can't do), and the
- Gitea Docker container lifecycle via the `docker` CLI directly — never
- `node:child_process`.
-- Everything Node-only that the suites still need at runtime (the real `requestUrl` shim
- production services import from `obsidian`, the `window.setTimeout` alias, and a small
- git-CLI-backed verifier) is **generated fresh per run** by `scripts/e2e-harness.sh provision`
- into `$E2E_RUNTIME_DIR`, not committed. Suites only import a type-only contract
- (`e2e/verifier-runtime-types.ts`) statically, and load the concrete implementation via a
- runtime-computed dynamic `import()` — so `npm run build`'s typecheck never needs the generated
- files to exist, and there's nothing scanner-visible for them to flag.
+in committed `.ts` files. The Obsidian community-plugin scanner flagged those APIs in that
+harness's committed files. See `docs/obsidian-scanner-audit.md`.
+
+`scripts/e2e-harness.sh` (Shell, not TypeScript) owns branch/container lifecycle: creating the
+isolated test branch via plain `git push :refs/heads/` (no REST branch-creation
+calls except the one GitLab numeric-project-ID resolution git genuinely can't do), and the
+Gitea Docker container lifecycle via the `docker` CLI directly — never `node:child_process`.
+
+Everything Node-only the suites need at runtime (the real `requestUrl` shim production services
+import from `obsidian`, the `window.setTimeout` alias, and a small git-CLI-backed verifier) now
+lives as **committed, static** TypeScript under `e2e-tests/provider/runtime/` and
+`e2e-tests/provider/support/git-verifier.ts`, scoped under the `e2e-tests/` directory rather than
+generated per-run into a temp dir. `vitest.e2e.config.ts`'s `alias` map points `obsidian` at the
+committed `obsidian-request-url.ts`; suites import `GitVerifier` directly by relative path — no
+`@e2e-runtime/*` ambient module, no `E2E_RUNTIME_DIR`.
+
+**Open scanner-risk caveat:** the previous harness generation was removed specifically because a
+prior committed-`.ts` version of this same code was flagged by the Obsidian scanner, and this
+repo's own audit (`docs/obsidian-scanner-audit.md`) recorded that the scanner's flagging did not
+appear to be scoped to what a submission actually bundles into `main.js`. Re-committing these
+files under `e2e-tests/` on the premise that the scanner only inspects `manifest.json`'s declared
+plugin surface is **unverified** against the real scanner as of this change — see "Known gaps"
+below. If a rescan reproduces the earlier finding, the fallback is reverting to per-run generation
+into an uncommitted directory (the previous design, preserved in git history), not a further
+directory rename.
## Layout
@@ -56,19 +62,27 @@ uses those APIs at all:
`e2e-branch-cleanup.yml`, never by the normal per-run job.
- `scripts/e2e-janitor.sh` — layer 3: TTL-based sweep of any leftover `e2e/**` branch, run by
`.github/workflows/e2e-janitor.yml` on a schedule.
-- `scripts/run-e2e.sh` — thin local-dev wrapper: provision → seed → vitest → cleanup (CI drives
- the same four steps directly as separate job steps instead).
-- `e2e/config/env.ts` — reads the env vars `provision` resolved and constructs the real,
- already-configured `GitServiceInterface` per provider (`githubContext`/`gitlabContext`/
- `giteaContext`).
-- `e2e/verifier-runtime-types.ts` — type-only `GitVerifier` contract the generated git-CLI
- verifier implements.
-- `e2e/shim/fake-vault.ts` — real in-memory Obsidian Vault/App stand-in (not a `vi.fn()` mock);
- the only thing faked, since the point of this harness is exercising real `SyncManager` +
- real provider code against a real Git server.
-- `e2e/suites/{github,gitlab,gitea}.e2e.test.ts` — provider contract suites (create/read/
- update/delete/batch/rename, plus provider-specific regressions).
-- `e2e/suites/sync-manager.e2e.test.ts` — one suite, parametrized by `E2E_PROVIDER`, covering
+- `scripts/run-e2e.sh` — the shared local/CI entry point: provision → seed → vitest → cleanup.
+ It allocates a unique temporary workdir when the caller does not supply one, so concurrent local
+ runs cannot overwrite each other's repository, runtime adapters, or credentials.
+- `e2e-tests/provider/config/env.ts` — reads the env vars `provision` resolved and constructs
+ the real, already-configured `GitServiceInterface` per provider (`githubContext`/
+ `gitlabContext`/`giteaContext`).
+- `e2e-tests/provider/runtime/obsidian-request-url.ts` — the real `requestUrl` shim (and the
+ minimal Obsidian class stand-ins production code touches), aliased in for `obsidian` by
+ `vitest.e2e.config.ts`.
+- `e2e-tests/provider/runtime/window-timers.ts` — the `window` = `globalThis` alias, loaded via
+ `vitest.e2e.config.ts`'s `setupFiles`.
+- `e2e-tests/provider/support/git-verifier.ts` — the git-CLI-backed `GitVerifier` every suite
+ imports directly; reads the clone path from `E2E_WORKDIR` at call time rather than a baked-in
+ constant.
+- `e2e-tests/provider/shim/fake-vault.ts` — real in-memory Obsidian Vault/App stand-in (not a
+ `vi.fn()` mock); the only thing faked, since the point of this harness is exercising real
+ `SyncManager` + real provider code against a real Git server.
+- `e2e-tests/provider/suites/{github,gitlab,gitea}.e2e.test.ts` — provider contract suites
+ (create/read/update/delete/batch/rename, plus provider-specific regressions).
+- `e2e-tests/provider/suites/sync-manager.e2e.test.ts` — one suite, parametrized by
+ `E2E_PROVIDER`, covering
`SyncManager.pushFiles`/`pullFile`/`trackRename`/`clearMetadata` against a real provider.
## Isolation model
@@ -110,21 +124,17 @@ killed run's branch is simply never touched by the next one.
### Concurrency and cancellation
-`.github/workflows/ci.yml`'s `provider-e2e` job carries a per-source-branch/per-provider
-concurrency group (`e2e--`, using `github.head_ref || github.ref_name` — the
-same expression as `E2E_SOURCE_BRANCH`) with `cancel-in-progress: true`, so a superseding
-push/rerun cancels its own predecessor instead of the two competing for runner/provider capacity.
-The group is keyed by branch name alone, deliberately *not* split by trigger event: a `push` to a
-branch with an open PR fires both a `push` and a `pull_request` run for the same commit, and an
-earlier version of this group keyed PR runs by number instead of branch name, putting those two
-runs in different groups — so they ran fully concurrently against the same shared provider
-sandbox and starved each other (observed as real GitLab API timeouts under that double load).
-Keying by branch name alone means the later of the two cancels the earlier instead. The two
-cleanup workflows below share this same group naming for the same branch, with
+`.github/workflows/ci.yml`'s E2E jobs carry per-source/per-provider concurrency groups with
+`cancel-in-progress: true`. Push and pull-request runs use the same branch identity, so a push to a
+branch with an open PR cancels its duplicate instead of both competing for runner/provider
+capacity. Manual dispatches and schedules use `-` instead: they must not cancel a
+normal PR's required checks or a push's provider run. The two cleanup workflows share the
+branch-based group naming used by push/PR runs, with
`cancel-in-progress: false`, so cleanup queues behind rather than races an active run.
-The cancelled duplicate's `e2e-gate` reports the replacement as neutral and sets `run-ci=false`,
-so it neither leaves a misleading aggregate failure nor starts a second copy of downstream CI.
-The surviving run remains responsible for the real provider result and release gate.
+The cancelled duplicate's `CI / Required Checks` treats the cancelled leg as a failure, but that
+run is for the superseded commit — the surviving run (the one GitHub uses for the latest commit)
+is responsible for the real provider result and release gate, so the cancelled duplicate's red
+gate is harmless and doesn't start any release work (it gates `package`/`publish`).
**Cancellation is not a cleanup mechanism.** A cancelled run's `cleanup` step may never execute, or
may be mid-delete when the runner is terminated; the next run is still safe because it always
allocates a brand-new `run--` branch rather than deleting and reusing the old
@@ -170,7 +180,7 @@ flowchart TD
The design goal is **not** "the sandbox repos are always perfectly clean" — it's that old garbage,
however it got there, can never contaminate a current run's state.
-### Self-hosted runner workspace isolation
+### Workspace isolation
`provider-e2e` runs on a persistent self-hosted fleet, so `E2E_WORKDIR` is pinned per
run/attempt/provider rather than relying on a fresh filesystem or a shared `/tmp` path:
@@ -179,11 +189,11 @@ run/attempt/provider rather than relying on a fresh filesystem or a shared `/tmp
$RUNNER_TEMP/git-files-sync-e2e////
```
-set once at job level in `ci.yml` (`env.E2E_WORKDIR`) so every step in the job shares it, and a
+set once near the start of each E2E job so every later process shares it, and a
previous killed job's leftover files under a different run-id/attempt can never leak into the
-current one. Locally, `scripts/e2e-harness.sh` falls back to a provider-namespaced (not random)
-tmp dir so sequential `npm run test:e2e` invocations in the same shell session still share state
-across its own provision/seed/vitest/cleanup steps.
+current one. Locally, `scripts/run-e2e.sh` uses `mktemp` to allocate a unique workdir per invocation
+and removes it after cleanup. `E2E_KEEP_BRANCH=1` deliberately preserves both the container/branch
+and workdir for debugging.
## Running locally
@@ -204,9 +214,11 @@ npm run test:e2e -- --provider gitlab # needs E2E_GITLAB_* below
| `E2E_GITLAB_BASE_URL` | gitlab (optional) | defaults to `https://gitlab.com` |
| `E2E_GITEA_IMAGE` | gitea (optional) | defaults to `gitea/gitea:1.22` |
| `E2E_KEEP_BRANCH` | any (optional) | `1`/`true` skips teardown (branch for GitHub/GitLab, container for Gitea) so you can inspect a failing run |
-| `E2E_WORKDIR` | any (optional) | shared scratch dir across provision/seed/vitest/cleanup; defaults to a provider-namespaced tmp dir |
+| `E2E_WORKDIR` | any (optional) | shared scratch dir across provision/seed/vitest/cleanup; the wrapper defaults to a unique temporary directory |
-Gitea needs Docker locally and nothing else.
+Gitea needs Docker locally and nothing else. Its disposable container publishes port 3000 on a
+Docker-assigned `127.0.0.1` port, so it never depends on the host's bridge subnet and parallel runs
+do not contend for a fixed port.
### Git authentication
@@ -221,15 +233,29 @@ source of truth after its container is created, so it's the one credential persi
## CI
-`.github/workflows/ci.yml` runs a `provider-e2e` matrix job (`github`, `gitlab`, `gitea`) as five
-steps per leg — provision, seed, the real vitest run, independent verify, cleanup (`if: always()`
-so cleanup runs even if an earlier step failed) — gated on relevant paths (`src/services/**`,
-`src/logic/sync-manager.ts`, `e2e/**`, `scripts/e2e-harness.sh`, `scripts/e2e-namespace.sh`, etc. —
-computed by the `changes` job, since GitHub Actions' own `on.*.paths` would gate the *entire*
-workflow file, including the always-must-run `CI`/release job). It always runs in full on
-`workflow_dispatch`, `schedule` (weekly, Monday 06:00 UTC, for API-drift detection), and pushes to
-`main`. The job carries a per-source/provider `concurrency` group (see "Isolation model" above) and
-sets `E2E_WORKDIR`/`E2E_PR_NUMBER`/`E2E_SOURCE_BRANCH` once at job level, shared by every step.
+`.github/workflows/ci.yml` runs five validation jobs **in parallel** right after a push/PR, with no
+validation waiting on E2E:
+
+- `CI / Lint` — `eslint .`
+- `CI / Unit Test (Node 22|24)` — `vitest run --coverage`, matrix `fail-fast: false`
+- `CI / Build` — `tsc -noEmit` + Obsidian 1.11.0 compat typecheck + esbuild; uploads
+ `main.js`/`manifest.json`/`styles.css` as an artifact for ad-hoc PR install (non-main branches
+ only)
+- `CI / Provider E2E / gitea` — disposable Gitea on `ubuntu-latest`
+- `CI / Provider E2E / ` — the credentialed GitHub/GitLab matrix below
+
+The E2E jobs are separated by trust boundary. Gitea runs on a fresh GitHub-hosted VM with only
+`contents: read`; it is safe for fork PRs because it receives no repository secrets and cannot
+access the persistent runner fleet. The credentialed `github`/`gitlab` matrix remains self-hosted,
+and its job-level condition rejects fork PRs before runner allocation. Both depend on the
+`changes` job's path gate (`src/services/**`, `src/logic/sync-manager.ts`, `src/logic/sync/**`,
+`src/logic/source-control/**`, `e2e-tests/**`, `vitest.e2e.config.ts`, `scripts/e2e-suites.txt`,
+`scripts/e2e-harness.sh`, `scripts/e2e-namespace.sh`, etc. —
+computed by the `CI / Detect Changes` job, since GitHub Actions' own `on.*.paths` would gate the
+*entire* workflow file, including the always-must-run validation/release jobs). It always runs in
+full on `workflow_dispatch`, `schedule` (weekly, Monday 06:00 UTC, for API-drift detection), and
+pushes to `main`. Each job carries a per-source/provider `concurrency` group (see "Isolation model"
+above) and a run/attempt/provider-scoped workdir.
Two more workflows round out the isolation model's other cleanup layers — see "Isolation model"
above for what each does and why:
@@ -248,11 +274,8 @@ above for what each does and why:
| `E2E_GITLAB_PROJECT_ID` | secret (not a variable — it's treated as sensitive here) |
| `E2E_GITLAB_TOKEN` | secret |
-**Fork PRs** only run the Gitea cell (checked in the `Determine whether this provider leg should
-run` step — GitHub Actions job-level `if:` can't reference the `matrix` context, so this can't
-live on the job itself; it gates every later step instead) — GitHub/GitLab need real credentials
-that must never be exposed to an untrusted fork's workflow run. Gitea needs no repo secrets at
-all, so it's safe to run unconditionally.
+**Fork PRs** only run the Gitea job on `ubuntu-latest`. The credentialed GitHub/GitLab job is
+rejected at job level, so untrusted code is never scheduled on the self-hosted runner fleet.
**Missing credentials are always a hard failure**, never a silent skip, for any cell that
actually runs (`scripts/e2e-harness.sh`'s `normalize_env`/`: "${VAR:?...}"` checks required env
@@ -262,20 +285,29 @@ given event; once it runs, it's expected to have what it needs.
## Release gating
```
-changes -> provider-e2e [github | gitlab | gitea, parallel] -> e2e-gate -> CI (shared workflow, includes semantic-release)
+changes ──► gitea-e2e [GitHub-hosted] ────────────────────────┐
+ └─► provider-e2e [github | gitlab, self-hosted] ──────┤
+lint ─────────────────────────────────────────────────────────┤
+unit-test (Node 22|24) ───────────────────────────────────────┤──► required-checks ──► package
+build ────────────────────────────────────────────────────────┘ └──► publish (main only)
```
-`e2e-gate` runs with `if: always()` and treats `provider-e2e`'s aggregate result as pass-through
-on `success` or `skipped` (the latter covers path-filtered-out runs), a neutral replacement on
-`cancelled` (with downstream CI suppressed for that duplicate run), and a hard failure on any
-other result. A real provider regression therefore still blocks the release instead of shipping
-and being caught after the fact.
+All five validation jobs start in parallel; a lint/unit/build error now surfaces in <1-2 min
+instead of after the real-provider matrix. `CI / Required Checks` runs with `if: always()` and
+passes only when every validation job reports `success` or `skipped` (a path-filtered-out or
+fork-gated-off `provider-e2e` leg reports `success` because its steps are skipped, not failed).
+Any other result — including a `cancelled` matrix leg replaced by a newer run in the same
+concurrency group — fails the gate; the surviving run is the one whose gate result GitHub uses
+for the latest commit. `Release / Package` and `Release / Publish` both run only after the gate
+passes, so a real provider regression still blocks the release instead of shipping and being
+caught after the fact. `Release / Publish` additionally requires `main`/`master` (semantic-release
+only releases on those branches — see `.releaserc.json`).
**Branch protection** (not something this repo checkout can change — a GitHub repo-settings
-change, left for whoever has admin access): add `E2E / gitea` as a required status check.
-GitHub/GitLab (`E2E / github`, `E2E / gitlab`) are deliberately **not** required at the
-branch-protection level, so a fork PR (which only runs Gitea) is never wedged by checks it
-structurally cannot produce.
+change, left for whoever has admin access): add `CI / Required Checks` as the single required
+status check. It aggregates Gitea, credentialed providers, lint, tests, and build while still
+allowing structurally skipped jobs. Requiring only the aggregate avoids wedging a fork PR on
+GitHub/GitLab checks it cannot produce.
## Cleanup / troubleshooting
@@ -290,27 +322,34 @@ structurally cannot produce.
- **Inspecting a failing run**: set `E2E_KEEP_BRANCH=1` before running so teardown is skipped,
then look at the branch/container directly. Remember to clean it up yourself afterward (see
above) — or just let the janitor catch it within its TTL.
-- **Gitea container port/name clashes**: each run's container is named `gfs-e2e-gitea-$$` (PID)
- and binds to a Docker-assigned host port, so concurrent local runs don't collide; a leftover
- container from an interrupted run can be removed manually (`docker rm -f `).
+- **Gitea container port/name clashes**: each name includes run ID, attempt, and PID, while Docker
+ assigns its loopback host port. Concurrent runs also use separate `mktemp` workdirs. A container
+ left by a hard-killed local process can be removed manually (`docker rm -f `).
- **`E2E_PROVIDER is not set` error**: `vitest.e2e.config.ts` refuses to run directly under
`npx vitest` — always go through `npm run test:e2e -- --provider ` (or the CI steps),
which set it.
## Known gaps
-- SyncManager E2E against GitHub/GitLab uses the same harness as Gitea (no provider-specific
- code) but has only been exercised end-to-end locally against Gitea (Docker, no external
- credentials available in this environment) — not yet actually executed against live
- GitHub/GitLab sandboxes from this checkout.
-- The `provider-e2e` matrix job targets `runs-on: [self-hosted, linux, x64, 32gb-ram]`; its
- actual execution on that fleet, and the `e2e-gate` -> `CI` dependency chain end-to-end in a
- real workflow run, are unverified from this checkout (no self-hosted runner access here).
-- Branch-protection required-check configuration (`E2E / gitea`) is a manual follow-up for
- whoever has admin access to the repo.
+- A real external fork PR has not yet exercised the fork event context end to end. Workflow
+ contracts enforce the trust split, and targeted run 33048613679 proved the equivalent
+ `Gitea success + credentialed providers skipped` path through downstream validation.
+- Branch-protection required-check configuration (`CI / Required Checks`) is a manual follow-up
+ for whoever has admin access to the repo.
- The official Obsidian community-plugin scanner rescan (as opposed to this repo's own
grep-based self-audit, `docs/obsidian-scanner-audit.md`) hasn't been re-run against this
harness from this checkout.
+- **Committed-vs-generated risk, unresolved**: `e2e-tests/provider/runtime/` and
+ `e2e-tests/provider/support/git-verifier.ts` are committed `.ts` files using
+ `fetch`/`globalThis`/`node:child_process` — the same APIs a *prior* version of this harness
+ had flagged by the scanner while committed under `e2e/`. That prior removal's own audit
+ (`docs/obsidian-scanner-audit.md`) found the scanner's flagging was not evidently scoped to
+ `manifest.json`'s declared plugin surface. This PR bets that a directory outside `e2e/` (now
+ `e2e-tests/`) resolves that, on the premise the scanner only inspects what a submission
+ bundles — that premise has not been re-verified against the actual scanner. If a rescan
+ reproduces the earlier finding, revert to per-run generation into an uncommitted
+ `$E2E_WORKDIR`-scoped directory (git history has the prior implementation), not another
+ directory rename.
- The Phase 2 isolation model (namespace scheme, per-source/provider concurrency groups,
`e2e-pr-cleanup.yml`, `e2e-branch-cleanup.yml`, `e2e-janitor.yml`) is verified by local
unit-level exercises of `scripts/e2e-namespace.sh`/`e2e-namespace-cleanup.sh`/`e2e-janitor.sh`
diff --git a/e2e/config/env.ts b/e2e-tests/provider/config/env.ts
similarity index 73%
rename from e2e/config/env.ts
rename to e2e-tests/provider/config/env.ts
index c2b2b48..ab938ab 100644
--- a/e2e/config/env.ts
+++ b/e2e-tests/provider/config/env.ts
@@ -8,10 +8,10 @@
* GitServiceInterface implementation against whatever that step already
* resolved, via the env vars it exports (see docs/testing/real-provider-e2e.md).
*/
-import { GitHubService } from '../../src/services/github-service';
-import { GitLabService } from '../../src/services/gitlab-service';
-import { GiteaService } from '../../src/services/gitea-service';
-import type { GitServiceInterface } from '../../src/services/git-service-interface';
+import { GitHubService } from '../../../src/services/github-service';
+import { GitLabService } from '../../../src/services/gitlab-service';
+import { GiteaService } from '../../../src/services/gitea-service';
+import type { GitServiceInterface } from '../../../src/services/git-service-interface';
export const SUPPORTED_PROVIDERS = ['gitea', 'gitlab', 'github'] as const;
export type E2EProvider = typeof SUPPORTED_PROVIDERS[number];
@@ -42,18 +42,6 @@ export const timeouts = {
testMs: Number(process.env.E2E_TEST_TIMEOUT_MS ?? 120_000),
};
-/** Path to the vitest-runtime adapters `scripts/e2e-harness.sh provision` generated. */
-export function runtimeDir(): string {
- const dir = process.env.E2E_RUNTIME_DIR;
- if (!dir) {
- throw new Error(
- 'E2E_RUNTIME_DIR is not set. Run "scripts/e2e-harness.sh provision" before the E2E suites — ' +
- 'it generates the vitest-only requestUrl/timer/verifier adapters this harness needs and never commits.'
- );
- }
- return dir;
-}
-
export function requiredEnv(name: string): string {
const value = process.env[name];
if (!value) {
@@ -72,21 +60,21 @@ export interface ProviderContext {
branch: string;
}
-export function githubContext(): ProviderContext {
+export function githubContext(rootPath = ''): ProviderContext {
const owner = requiredEnv('E2E_GITHUB_OWNER');
const repo = requiredEnv('E2E_GITHUB_REPO');
const token = requiredEnv('E2E_GITHUB_TOKEN');
const service = new GitHubService();
- service.updateConfig(token, owner, repo, '');
+ service.updateConfig(token, owner, repo, rootPath);
return { service, branch: testBranch() };
}
-export function gitlabContext(): ProviderContext {
+export function gitlabContext(rootPath = ''): ProviderContext {
const baseUrl = process.env.E2E_GITLAB_BASE_URL ?? 'https://gitlab.com';
const projectId = requiredEnv('E2E_GITLAB_PROJECT_ID');
const token = requiredEnv('E2E_GITLAB_TOKEN');
const service = new GitLabService();
- service.updateConfig(baseUrl, token, projectId, '');
+ service.updateConfig(baseUrl, token, projectId, rootPath);
return { service, branch: testBranch() };
}
@@ -96,7 +84,7 @@ export function gitlabContext(): ProviderContext {
* URL/credentials generically (E2E_TEST_REPO_URL/E2E_GIT_USERNAME/
* E2E_GIT_TOKEN), since there's no stable owner/repo pair to name ahead of time.
*/
-export function giteaContext(): ProviderContext {
+export function giteaContext(rootPath = ''): ProviderContext {
const repoUrl = new URL(requiredEnv('E2E_TEST_REPO_URL'));
const token = requiredEnv('E2E_GIT_TOKEN');
const [owner, repoWithGit] = repoUrl.pathname.replace(/^\//, '').split('/');
@@ -106,12 +94,19 @@ export function giteaContext(): ProviderContext {
}
const baseUrl = `${repoUrl.protocol}//${repoUrl.host}`;
const service = new GiteaService();
- service.updateConfig(baseUrl, token, owner, repo, '');
+ service.updateConfig(baseUrl, token, owner, repo, rootPath);
return { service, branch: testBranch() };
}
-export function contextFor(provider: E2EProvider): ProviderContext {
- if (provider === 'github') return githubContext();
- if (provider === 'gitlab') return gitlabContext();
- return giteaContext();
+/**
+ * `rootPath` scopes the service's own remote-tree listing to a repo
+ * subfolder — the real production mechanism, not a test-only filter. Suites
+ * that share one branch across several fixtures (e.g. multi-client E2E) pass
+ * their run's namespace here so each fixture's service only ever sees its own
+ * files, instead of every suite's files sharing one unscoped listing.
+ */
+export function contextFor(provider: E2EProvider, rootPath = ''): ProviderContext {
+ if (provider === 'github') return githubContext(rootPath);
+ if (provider === 'gitlab') return gitlabContext(rootPath);
+ return giteaContext(rootPath);
}
diff --git a/e2e-tests/provider/runtime/obsidian-request-url.ts b/e2e-tests/provider/runtime/obsidian-request-url.ts
new file mode 100644
index 0000000..d1b59ab
--- /dev/null
+++ b/e2e-tests/provider/runtime/obsidian-request-url.ts
@@ -0,0 +1,59 @@
+// Real `requestUrl` shim for E2E: production services import this from
+// `obsidian` (see vitest.e2e.config.ts's `alias`), and E2E suites need actual
+// network calls to reach the provisioned provider — the `vi.fn()` mock
+// tests/setup.ts installs for unit tests is deliberately not used here.
+import type { RequestUrlParam, RequestUrlResponse } from 'obsidian';
+
+// A stalled TCP connection on a flaky runner otherwise hangs `fetch` forever,
+// which surfaces as a silent test timeout (no error, no log) at whatever the
+// suite's own testTimeout happens to be — indistinguishable from a real
+// deadlock. Bound every request so a stall fails fast with a clear cause.
+const REQUEST_TIMEOUT_MS = 30_000;
+
+export async function requestUrl(request: RequestUrlParam | string): Promise {
+ const params: RequestUrlParam = typeof request === 'string' ? { url: request } : request;
+ const shouldThrow = params.throw ?? true;
+ const headers: Record = { ...params.headers };
+ if (params.contentType && !headers['Content-Type']) headers['Content-Type'] = params.contentType;
+ const res = await fetch(params.url, {
+ method: params.method ?? 'GET',
+ headers,
+ body: params.body,
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+ });
+ const arrayBuffer = await res.arrayBuffer();
+ const text = new TextDecoder().decode(arrayBuffer);
+ let json: unknown;
+ try { json = text ? JSON.parse(text) : undefined; } catch { json = undefined; }
+ const response: RequestUrlResponse = { status: res.status, headers: Object.fromEntries(res.headers.entries()), arrayBuffer, text, json };
+ if (shouldThrow && res.status >= 400) {
+ const error = new Error(`Request failed, status ${res.status}`);
+ (error as Error & { status: number }).status = res.status;
+ throw error;
+ }
+ return response;
+}
+
+export class Modal {
+ app: unknown;
+ constructor(app?: unknown) { this.app = app; }
+ open(): void {}
+ close(): void {}
+}
+export class PluginSettingTab { constructor(_app?: unknown, _plugin?: unknown) {} }
+export class TextComponent {}
+export class AbstractInputSuggest { constructor(_app: unknown, _inputEl: unknown) {} }
+export class TFolder { path: string; constructor(path: string) { this.path = path; } }
+export class Setting { constructor(_containerEl?: unknown) {} }
+export class TFile {
+ path: string;
+ name: string;
+ constructor(path: string) { this.path = path; this.name = path.split('/').pop() ?? path; }
+}
+export class Notice {
+ constructor(_message?: string, _timeout?: number) {}
+ setMessage(): this { return this; }
+ hide(): void {}
+}
+export const Platform = { isDesktopApp: false, isMobile: false };
+export class FileSystemAdapter { getBasePath(): string { return '/e2e/fake-vault'; } }
diff --git a/e2e-tests/provider/runtime/window-timers.ts b/e2e-tests/provider/runtime/window-timers.ts
new file mode 100644
index 0000000..dbed09d
--- /dev/null
+++ b/e2e-tests/provider/runtime/window-timers.ts
@@ -0,0 +1,5 @@
+// Minimal `window` alias so production code written for Obsidian's Electron
+// renderer (e.g. window.setTimeout) runs as-is under Node.
+if (typeof (globalThis as { window?: unknown }).window === 'undefined') {
+ (globalThis as unknown as { window: typeof globalThis }).window = globalThis;
+}
diff --git a/e2e/shim/fake-vault.ts b/e2e-tests/provider/shim/fake-vault.ts
similarity index 71%
rename from e2e/shim/fake-vault.ts
rename to e2e-tests/provider/shim/fake-vault.ts
index 553b4b4..a45cec1 100644
--- a/e2e/shim/fake-vault.ts
+++ b/e2e-tests/provider/shim/fake-vault.ts
@@ -2,7 +2,7 @@ import type { App } from 'obsidian';
/**
* Real in-memory Obsidian Vault/App stand-in for SyncManager E2E (see
- * e2e/suites/sync-manager.e2e.test.ts) — not a `vi.fn()` mock. The point of
+ * e2e-tests/provider/suites/sync-manager.e2e.test.ts) — not a `vi.fn()` mock. The point of
* SyncManager E2E is to exercise real `SyncManager` + real provider service
* code against a real Git server; the *only* thing worth faking is the
* Obsidian filesystem boundary, so this implements exactly the `vault`/
@@ -11,8 +11,8 @@ import type { App } from 'obsidian';
*
* `TFile` itself has to come from the caller rather than being imported here:
* production code does `fileOrPath instanceof TFile`, so it must be the exact
- * same class the vitest-runtime `obsidian` alias resolves to (generated by
- * `scripts/e2e-harness.sh provision`, not committed — see
+ * same class the vitest `obsidian` alias resolves to
+ * (e2e-tests/provider/runtime/obsidian-request-url.ts — see
* docs/testing/real-provider-e2e.md), not a second, unrelated class.
*/
export interface TFileLike { path: string; name: string }
@@ -40,11 +40,26 @@ export class FakeVault {
this.files.set(newPath, content);
}
+ /** Removes a local file, mirroring Obsidian's vault delete. */
+ removeLocal(path: string): void {
+ this.files.delete(path);
+ }
+
/** Constructs a real TFile handle for a path already in this vault. */
fileAt(path: string): TFileLike {
return new this.TFile(path);
}
+ /** All paths currently in this vault (the local tree, for convergence assertions). */
+ paths(): string[] {
+ return [...this.files.keys()];
+ }
+
+ /** Mirrors `vault.getFiles()` for the Source Control refresh pipeline. */
+ getFiles(): TFileLike[] {
+ return [...this.files.keys()].map(path => this.fileAt(path));
+ }
+
readonly adapter = {
exists: async (path: string): Promise => this.files.has(path),
read: async (path: string): Promise => {
@@ -66,6 +81,17 @@ export class FakeVault {
// ensureParentDirs (src/utils/vault-path.ts) tolerates mkdir failures;
// there are no real directories to create in an in-memory map.
mkdir: async (): Promise => {},
+ // SyncStatusRefreshService.discoverHiddenLocalFiles/recursiveScan list
+ // directories directly; an in-memory map has no directories, so the
+ // listing is just the root's files (its try/catch tolerates absence).
+ list: async (path: string): Promise<{ files: string[]; folders: string[] }> => (
+ path === '' || path === '/'
+ ? { files: this.paths(), folders: [] }
+ : { files: [], folders: [] }
+ ),
+ stat: async (path: string): Promise<{ type: 'file' } | null> => (
+ this.files.has(path) ? { type: 'file' } : null
+ ),
};
readonly vault = {
@@ -77,7 +103,9 @@ export class FakeVault {
modifyBinary: async (file: TFileLike, content: ArrayBuffer): Promise => {
this.files.set(file.path, content);
},
+ getFiles: (): TFileLike[] => this.getFiles(),
getFileByPath: (path: string): TFileLike | null => (this.files.has(path) ? this.fileAt(path) : null),
+ getAbstractFileByPath: (path: string): TFileLike | null => (this.files.has(path) ? this.fileAt(path) : null),
adapter: this.adapter,
};
}
diff --git a/e2e/suites/gitea.e2e.test.ts b/e2e-tests/provider/suites/gitea.e2e.test.ts
similarity index 89%
rename from e2e/suites/gitea.e2e.test.ts
rename to e2e-tests/provider/suites/gitea.e2e.test.ts
index b31c731..846c297 100644
--- a/e2e/suites/gitea.e2e.test.ts
+++ b/e2e-tests/provider/suites/gitea.e2e.test.ts
@@ -1,7 +1,7 @@
import { describe, it, expect, beforeAll } from 'vitest';
-import { giteaContext, runtimeDir } from '../config/env';
-import type { GitServiceInterface } from '../../src/services/git-service-interface';
-import type { GitVerifier as GitVerifierType } from '../verifier-runtime-types';
+import { GitVerifier } from '../support/git-verifier';
+import { giteaContext } from '../config/env';
+import type { GitServiceInterface } from '../../../src/services/git-service-interface';
// Real GiteaService against a real, freshly-provisioned Gitea instance (the
// container itself was already brought up by `scripts/e2e-harness.sh
@@ -12,7 +12,7 @@ import type { GitVerifier as GitVerifierType } from '../verifier-runtime-types';
describe('GiteaService E2E', () => {
let service: GitServiceInterface;
let branch: string;
- let verifier: GitVerifierType;
+ let verifier: GitVerifier;
const runId = Math.random().toString(36).slice(2, 10);
const path = (name: string) => `e2e-${runId}/${name}`;
@@ -20,7 +20,6 @@ describe('GiteaService E2E', () => {
const ctx = giteaContext();
service = ctx.service;
branch = ctx.branch;
- const { GitVerifier } = await import(/* @vite-ignore */ `${runtimeDir()}/verifier/git-verifier.ts`) as { GitVerifier: new () => GitVerifierType };
verifier = new GitVerifier();
});
@@ -96,7 +95,7 @@ describe('GiteaService E2E', () => {
await service.pushFile(oldPath, 'rename me', branch, 'e2e: create file for rename test');
expect(await verifier.fileMissing(oldPath, branch)).toBe(false);
- await service.commitBatch!([], [{ oldPath, newPath, content: 'rename me' }], branch, 'e2e: rename file');
+ await service.commitBatch!({ writes: [], moves: [{ oldPath, newPath, content: 'rename me' }], deletions: [] }, branch, 'e2e: rename file');
expect(await verifier.fileMissing(oldPath, branch)).toBe(true);
const remote = await verifier.getFile(newPath, branch);
diff --git a/e2e/suites/github.e2e.test.ts b/e2e-tests/provider/suites/github.e2e.test.ts
similarity index 93%
rename from e2e/suites/github.e2e.test.ts
rename to e2e-tests/provider/suites/github.e2e.test.ts
index 98ee81e..558c43d 100644
--- a/e2e/suites/github.e2e.test.ts
+++ b/e2e-tests/provider/suites/github.e2e.test.ts
@@ -1,17 +1,17 @@
import { describe, it, expect, beforeAll } from 'vitest';
-import { githubContext, runtimeDir } from '../config/env';
-import type { GitServiceInterface } from '../../src/services/git-service-interface';
-import type { GitVerifier as GitVerifierType } from '../verifier-runtime-types';
+import { GitVerifier } from '../support/git-verifier';
+import { githubContext } from '../config/env';
+import type { GitServiceInterface } from '../../../src/services/git-service-interface';
// Real GitHubService against a real GitHub sandbox repository, on the
// isolated branch `scripts/e2e-harness.sh provision` already created (see
// docs/testing/real-provider-e2e.md). Every remote assertion below goes
-// through `verifier` (plain git CLI against an independent clone, generated
-// by the harness) rather than asking `service` to read back its own writes.
+// through `verifier` (plain git CLI against an independent clone the harness
+// checked out) rather than asking `service` to read back its own writes.
describe('GitHubService E2E', () => {
let service: GitServiceInterface;
let branch: string;
- let verifier: GitVerifierType;
+ let verifier: GitVerifier;
const runId = Math.random().toString(36).slice(2, 10);
const path = (name: string) => `e2e-${runId}/${name}`;
@@ -39,7 +39,6 @@ describe('GitHubService E2E', () => {
const ctx = githubContext();
service = ctx.service;
branch = ctx.branch;
- const { GitVerifier } = await import(/* @vite-ignore */ `${runtimeDir()}/verifier/git-verifier.ts`) as { GitVerifier: new () => GitVerifierType };
verifier = new GitVerifier();
});
@@ -126,7 +125,7 @@ describe('GitHubService E2E', () => {
await service.pushFile(oldPath, 'rename me', branch, 'e2e: create file for rename test');
expect(await waitFor(() => verifier.fileMissing(oldPath, branch), missing => missing === false)).toBe(false);
- await service.commitBatch!([], [{ oldPath, newPath, content: 'rename me' }], branch, 'e2e: rename file');
+ await service.commitBatch!({ writes: [], moves: [{ oldPath, newPath, content: 'rename me' }], deletions: [] }, branch, 'e2e: rename file');
expect(await waitForMissing(oldPath, branch)).toBe(true);
const remote = await waitForContent(() => verifier.getFile(newPath, branch), 'rename me');
diff --git a/e2e/suites/gitlab.e2e.test.ts b/e2e-tests/provider/suites/gitlab.e2e.test.ts
similarity index 94%
rename from e2e/suites/gitlab.e2e.test.ts
rename to e2e-tests/provider/suites/gitlab.e2e.test.ts
index 4d80307..5de0223 100644
--- a/e2e/suites/gitlab.e2e.test.ts
+++ b/e2e-tests/provider/suites/gitlab.e2e.test.ts
@@ -1,7 +1,7 @@
import { describe, it, expect, beforeAll } from 'vitest';
-import { gitlabContext, runtimeDir } from '../config/env';
-import type { GitServiceInterface } from '../../src/services/git-service-interface';
-import type { GitVerifier as GitVerifierType } from '../verifier-runtime-types';
+import { GitVerifier } from '../support/git-verifier';
+import { gitlabContext } from '../config/env';
+import type { GitServiceInterface } from '../../../src/services/git-service-interface';
// Real GitLabService against a dedicated real GitLab sandbox project, on the
// isolated branch `scripts/e2e-harness.sh provision` already created. Every
@@ -10,7 +10,7 @@ import type { GitVerifier as GitVerifierType } from '../verifier-runtime-types';
describe('GitLabService E2E', () => {
let service: GitServiceInterface;
let branch: string;
- let verifier: GitVerifierType;
+ let verifier: GitVerifier;
const runId = Math.random().toString(36).slice(2, 10);
const path = (name: string) => `e2e-${runId}/${name}`;
@@ -18,7 +18,6 @@ describe('GitLabService E2E', () => {
const ctx = gitlabContext();
service = ctx.service;
branch = ctx.branch;
- const { GitVerifier } = await import(/* @vite-ignore */ `${runtimeDir()}/verifier/git-verifier.ts`) as { GitVerifier: new () => GitVerifierType };
verifier = new GitVerifier();
});
@@ -93,7 +92,7 @@ describe('GitLabService E2E', () => {
await service.pushFile(oldPath, 'rename me', branch, 'e2e: create file for rename test');
expect(await verifier.fileMissing(oldPath, branch)).toBe(false);
- await service.commitBatch!([], [{ oldPath, newPath, content: 'rename me' }], branch, 'e2e: rename file');
+ await service.commitBatch!({ writes: [], moves: [{ oldPath, newPath, content: 'rename me' }], deletions: [] }, branch, 'e2e: rename file');
expect(await verifier.fileMissing(oldPath, branch)).toBe(true);
const remote = await verifier.getFile(newPath, branch);
diff --git a/e2e-tests/provider/suites/source-control-flows.e2e.test.ts b/e2e-tests/provider/suites/source-control-flows.e2e.test.ts
new file mode 100644
index 0000000..6dcc188
--- /dev/null
+++ b/e2e-tests/provider/suites/source-control-flows.e2e.test.ts
@@ -0,0 +1,883 @@
+import { describe, it, expect, beforeAll, vi } from 'vitest';
+import { createSyncManagerFixture, describePushResult, type SyncManagerFixture } from '../support/sync-manager-fixture';
+import { SourceControlScenario, change } from '../support/source-control-scenarios';
+import { timeouts } from '../config/env';
+
+// Auto-confirm the plan-review + conflict modals so a push can proceed
+// without a human. vi.mock is hoisted above the fixture import, so the fixture
+// receives the mocked modules and installs their mockImplementation. Pull-side
+// SyncConflictModal stays the bare automock default (does nothing, matching
+// production: pullFile returns before the conflict modal resolves).
+vi.mock('../../../src/ui/SyncPlanModal');
+vi.mock('../../../src/ui/SyncConflictModal');
+vi.mock('../../../src/ui/BatchConflictResolutionModal');
+
+// Provider matrix: Core scenarios run on every provider; Extended scenarios
+// (rename chains, unicode, batch-scale, etc.) exercise SyncManager/model
+// behavior that's provider-agnostic. PR/branch CI runs the core tier; GitHub
+// main, schedule, manual, and local runs use the full tier. Stress (1000-file)
+// remains opt-in via E2E_STRESS=1.
+const isGitHub = process.env.E2E_PROVIDER === 'github';
+const e2eTier = process.env.E2E_TIER ?? 'full';
+const runExtended = isGitHub && e2eTier !== 'core';
+const isStress = process.env.E2E_STRESS === '1';
+
+describe('Source Control Flows E2E', () => {
+ let fixture: SyncManagerFixture;
+
+ beforeAll(async () => {
+ fixture = await createSyncManagerFixture();
+ }, timeouts.containerReadyMs + 30_000);
+
+ const path = (name: string): string => fixture.path(name);
+ const scenario = (): SourceControlScenario => new SourceControlScenario(fixture);
+
+ // ------------------------------------------------------------------
+ // Phase 2 — Rename / Move workflows
+ // ------------------------------------------------------------------
+ describe('rename and move workflows', () => {
+ it('renames and modifies a file in one commit, moving metadata to the new path', async () => {
+ const s = scenario();
+ const oldP = path('rename-modify/a.md');
+ const newP = path('rename-modify/archive/a.md');
+ await s.baseline(oldP, 'v1');
+ expect(s.metadataSha(oldP), 'baseline metadata at old path').toBeTruthy();
+
+ s.renameLocal(oldP, newP);
+ s.writeLocal(newP, 'v2');
+ await s.manager.trackRename(newP, oldP);
+
+ const headBefore = await s.head();
+ const result = await s.push([s.tfile(newP)]);
+ expect(result.success, describePushResult(result)).toBe(1);
+ expect(result.failed, describePushResult(result)).toBe(0);
+
+ await s.expectRemoteMissing(oldP);
+ await s.expectRemoteContent(newP, 'v2');
+ await s.expectSingleCommitSince(headBefore);
+ expect(s.metadataSha(newP), 'metadata moved to new path').toBeTruthy();
+ expect(s.metadata(oldP), 'old path metadata removed').toBeUndefined();
+ });
+
+ it('renames and modifies multiple files in one batch push (one commit)', async () => {
+ const s = scenario();
+ const oldA = path('multi-rename/folder/a.md');
+ const oldB = path('multi-rename/folder/b.md');
+ const newA = path('multi-rename/archive/a.md');
+ const newB = path('multi-rename/archive/b.md');
+ await s.baseline(oldA, 'a-v1');
+ await s.baseline(oldB, 'b-v1');
+
+ s.renameLocal(oldA, newA);
+ s.writeLocal(newA, 'a-v2');
+ s.renameLocal(oldB, newB);
+ s.writeLocal(newB, 'b-v2');
+ await s.manager.trackRename(newA, oldA);
+ await s.manager.trackRename(newB, oldB);
+
+ const headBefore = await s.head();
+ const result = await s.push([s.tfile(newA), s.tfile(newB)]);
+ expect(result.success, describePushResult(result)).toBe(2);
+ expect(result.failed, describePushResult(result)).toBe(0);
+
+ await s.expectRemoteMissing(oldA);
+ await s.expectRemoteMissing(oldB);
+ await s.expectRemoteContent(newA, 'a-v2');
+ await s.expectRemoteContent(newB, 'b-v2');
+ await s.expectSingleCommitSince(headBefore);
+ });
+
+ // Extended: nested move + rename chain (SyncManager/model behavior,
+ // provider-agnostic) — GitHub only.
+ it.skipIf(!runExtended)('moves files across nested directories in one commit', async () => {
+ const s = scenario();
+ const oldFlat = path('nested-move/folder/a.md');
+ const oldNested = path('nested-move/folder/nested/b.md');
+ const newFlat = path('nested-move/archive/a.md');
+ const newNested = path('nested-move/archive/nested/b.md');
+ await s.baseline(oldFlat, 'flat');
+ await s.baseline(oldNested, 'nested');
+
+ s.renameLocal(oldFlat, newFlat);
+ s.renameLocal(oldNested, newNested);
+ await s.manager.trackRename(newFlat, oldFlat);
+ await s.manager.trackRename(newNested, oldNested);
+
+ const headBefore = await s.head();
+ const result = await s.push([s.tfile(newFlat), s.tfile(newNested)]);
+ expect(result.success, describePushResult(result)).toBe(2);
+ expect(result.failed, describePushResult(result)).toBe(0);
+
+ await s.expectRemoteMissing(oldFlat);
+ await s.expectRemoteMissing(oldNested);
+ await s.expectRemoteContent(newFlat, 'flat');
+ await s.expectRemoteContent(newNested, 'nested');
+ await s.expectSingleCommitSince(headBefore);
+ });
+
+ it.skipIf(!runExtended)('collapses a rename chain (A->B->C) into a single move of the original path', async () => {
+ const s = scenario();
+ const a = path('rename-chain/a.md');
+ const b = path('rename-chain/b.md');
+ const c = path('rename-chain/c.md');
+ await s.baseline(a, 'chain');
+
+ s.renameLocal(a, b);
+ await s.manager.trackRename(b, a);
+ s.renameLocal(b, c);
+ await s.manager.trackRename(c, b);
+
+ const headBefore = await s.head();
+ const result = await s.push([s.tfile(c)]);
+ expect(result.success, describePushResult(result)).toBe(1);
+ expect(result.failed, describePushResult(result)).toBe(0);
+
+ await s.expectRemoteMissing(a);
+ await s.expectRemoteMissing(b);
+ await s.expectRemoteContent(c, 'chain');
+ await s.expectSingleCommitSince(headBefore);
+ expect(s.metadata(a), 'no stale metadata at intermediate path A').toBeUndefined();
+ expect(s.metadata(b), 'no stale metadata at intermediate path B').toBeUndefined();
+ expect(s.metadataSha(c), 'metadata landed at final path').toBeTruthy();
+ });
+ });
+
+ // ------------------------------------------------------------------
+ // Phase 3 — Conflict state transitions
+ //
+ // The current SyncPlanner only surfaces a conflict on a push when both
+ // sides diverged from a *stored* baseline (modify/modify with a base
+ // sha). No-baseline add/add, delete-side divergence, and a move whose
+ // *source* was remotely edited are NOT conflicts today — they resolve to
+ // local-wins / blind-recreate / move-drops-old-edit. These tests lock
+ // that current contract (per the agreed scope: no production behavior
+ // changed to satisfy tests) so a future change to surface those as
+ // conflicts is an intentional, test-updating decision. The one real
+ // conflict (modify/modify with baseline) is asserted as a conflict.
+ // ------------------------------------------------------------------
+ describe('conflict state transitions', () => {
+ it('detects a modify/modify conflict and leaves both sides + baseline untouched on skip', async () => {
+ const s = scenario();
+ const p = path('conflict-modify-modify/a.md');
+ await s.baseline(p, 'baseline');
+ const baselineMeta = s.metadata(p);
+
+ s.writeLocal(p, 'local edit');
+ await s.modifyRemote(p, 'remote edit');
+
+ fixture.setConflictResolver(() => 'skip');
+ const headBefore = await s.head();
+ const result = await s.push([p]);
+
+ expect(result.skippedConflicts, describePushResult(result)).toBeGreaterThanOrEqual(1);
+ expect(result.success, describePushResult(result)).toBe(0);
+ expect(result.failed, describePushResult(result)).toBe(0);
+ await s.expectRemoteContent(p, 'remote edit');
+ expect(await s.readLocal(p)).toBe('local edit');
+ expect(s.metadata(p)).toEqual(baselineMeta);
+ await s.expectNoCommitSince(headBefore);
+ });
+
+ it('does not auto-delete a remotely-modified file when its local copy is gone (current push contract)', async () => {
+ const s = scenario();
+ const gone = path('conflict-delete-modify/a.md');
+ const other = path('conflict-delete-modify/b.md');
+ await s.baseline(gone, 'baseline');
+ await s.baseline(other, 'other-baseline');
+ const baselineSha = s.metadataSha(gone);
+
+ s.deleteLocal(gone);
+ await s.modifyRemote(gone, 'remote edit');
+ s.writeLocal(other, 'other-modified');
+
+ const headBefore = await s.head();
+ const result = await s.push([other]);
+ expect(result.success, describePushResult(result)).toBe(1);
+ expect(result.failed, describePushResult(result)).toBe(0);
+
+ // pushFiles never propagates a local deletion, so the
+ // remotely-modified file survives and its baseline metadata is
+ // not advanced. No conflict is surfaced for delete/modify today.
+ await s.expectRemoteContent(gone, 'remote edit');
+ expect(s.metadataSha(gone), 'metadata not falsely advanced').toBe(baselineSha);
+ await s.expectRemoteContent(other, 'other-modified');
+ await s.expectSingleCommitSince(headBefore);
+ });
+
+ it('re-creates a remotely-deleted file from a modified local copy (current push contract)', async () => {
+ const s = scenario();
+ const p = path('conflict-modify-delete/a.md');
+ await s.baseline(p, 'baseline');
+ const baselineSha = s.metadataSha(p);
+
+ s.writeLocal(p, 'local edit');
+ await s.deleteRemoteFile(p);
+
+ const headBefore = await s.head();
+ const result = await s.push([p]);
+ expect(result.success, describePushResult(result)).toBe(1);
+ expect(result.failed, describePushResult(result)).toBe(0);
+
+ // A remote deletion + local modification classifies as
+ // 'local-only' (push-create): the remote is blindly re-created
+ // with local content and metadata advances. No conflict today.
+ await s.expectRemoteContent(p, 'local edit');
+ await s.expectSingleCommitSince(headBefore);
+ expect(s.metadataSha(p), 'metadata advanced to new sha').not.toBe(baselineSha);
+ expect(s.metadataSha(p)).toBeTruthy();
+ });
+
+ it.skipIf(!isGitHub)('a move whose source was remotely edited proceeds, dropping the old-path edit (current contract)', async () => {
+ const s = scenario();
+ const oldP = path('conflict-rename-modify/a.md');
+ const newP = path('conflict-rename-modify/archive/a.md');
+ await s.baseline(oldP, 'v1');
+
+ s.renameLocal(oldP, newP);
+ await s.manager.trackRename(newP, oldP);
+ await s.modifyRemote(oldP, 'remote edit on old path');
+
+ const headBefore = await s.head();
+ const result = await s.push([s.tfile(newP)]);
+ expect(result.success, describePushResult(result)).toBe(1);
+ expect(result.failed, describePushResult(result)).toBe(0);
+
+ // planMove only flags a conflict when the DESTINATION is occupied.
+ // A diverged source (old path remotely edited) is a plain move, so
+ // the old-path edit is dropped (old path deleted, new path created
+ // with local content). Locked here as the current contract.
+ await s.expectRemoteMissing(oldP);
+ await s.expectRemoteContent(newP, 'v1');
+ await s.expectSingleCommitSince(headBefore);
+ });
+
+ it.skipIf(!runExtended)('overwrites a remotely-created file with local content on a no-baseline add/add (current contract)', async () => {
+ const s = scenario();
+ const p = path('conflict-add-add/a.md');
+ await s.seedRemote(p, 'remote');
+ s.writeLocal(p, 'local');
+
+ const headBefore = await s.head();
+ const result = await s.push([p]);
+ expect(result.success, describePushResult(result)).toBe(1);
+ expect(result.failed, describePushResult(result)).toBe(0);
+ expect(result.skippedConflicts, describePushResult(result)).toBe(0);
+
+ // A no-baseline two-sided diff downgrades to 'local-modified' on
+ // push (classifyForOperation), so local overwrites remote with no
+ // conflict surfaced. Locked here as the current contract.
+ await s.expectRemoteContent(p, 'local');
+ await s.expectSingleCommitSince(headBefore);
+ });
+ });
+
+ // ------------------------------------------------------------------
+ // Phase 4 — Conflict resolution workflows
+ // ------------------------------------------------------------------
+ describe('conflict resolution workflows', () => {
+ it('resolves a modify/modify conflict with keep-local: remote becomes local, metadata advances', async () => {
+ const s = scenario();
+ const p = path('resolve-keep-local/a.md');
+ await s.baseline(p, 'baseline');
+ s.writeLocal(p, 'local edit');
+ await s.modifyRemote(p, 'remote edit');
+
+ fixture.setConflictResolver(() => 'keep-local');
+ const headBefore = await s.head();
+ const result = await s.push([p]);
+ expect(result.success, describePushResult(result)).toBe(1);
+ expect(result.resolvedConflicts, describePushResult(result)).toBe(1);
+ expect(result.skippedConflicts, describePushResult(result)).toBe(0);
+ expect(result.failed, describePushResult(result)).toBe(0);
+
+ await s.expectRemoteContent(p, 'local edit');
+ expect(await s.readLocal(p)).toBe('local edit');
+ const remote = await s.remoteContent(p);
+ expect(s.metadataSha(p), 'metadata = new remote sha').toBe(remote?.sha);
+ await s.expectSingleCommitSince(headBefore);
+ });
+
+ it('resolves a modify/modify conflict with keep-remote: local becomes remote, no remote mutation', async () => {
+ const s = scenario();
+ const p = path('resolve-keep-remote/a.md');
+ await s.baseline(p, 'baseline');
+ s.writeLocal(p, 'local edit');
+ await s.modifyRemote(p, 'remote edit');
+
+ fixture.setConflictResolver(() => 'keep-remote');
+ const headBefore = await s.head();
+ const result = await s.push([p]);
+ expect(result.failed, describePushResult(result)).toBe(0);
+ expect(result.resolvedConflicts, describePushResult(result)).toBe(1);
+ expect(result.skippedConflicts, describePushResult(result)).toBe(0);
+
+ await s.expectRemoteContent(p, 'remote edit');
+ expect(await s.readLocal(p)).toBe('remote edit');
+ const remote = await s.remoteContent(p);
+ expect(s.metadataSha(p), 'metadata = remote sha').toBe(remote?.sha);
+ // keep-remote is a pull, not a push — no new commit on the branch.
+ await s.expectNoCommitSince(headBefore);
+ });
+
+ it('regression: skip leaves local, remote, baseline metadata, and HEAD all untouched', async () => {
+ const s = scenario();
+ const p = path('resolve-skip/a.md');
+ await s.baseline(p, 'baseline');
+ const baselineMeta = s.metadata(p);
+
+ s.writeLocal(p, 'local edit');
+ await s.modifyRemote(p, 'remote edit');
+
+ fixture.setConflictResolver(() => 'skip');
+ const headBefore = await s.head();
+ const result = await s.push([p]);
+
+ expect(result.skippedConflicts, describePushResult(result)).toBeGreaterThanOrEqual(1);
+ expect(result.success, describePushResult(result)).toBe(0);
+ expect(result.failed, describePushResult(result)).toBe(0);
+ await s.expectRemoteContent(p, 'remote edit');
+ expect(await s.readLocal(p)).toBe('local edit');
+ expect(s.metadata(p)).toEqual(baselineMeta);
+ await s.expectNoCommitSince(headBefore);
+ });
+ });
+
+ // ------------------------------------------------------------------
+ // Phase 5 — Mixed batch operations
+ // ------------------------------------------------------------------
+ describe('mixed batch operations', () => {
+ it.skipIf(!runExtended)('pushes a create + modify + rename in one commit', async () => {
+ const s = scenario();
+ const create = path('mixed-cmr/create.md');
+ const modify = path('mixed-cmr/modify.md');
+ const oldMove = path('mixed-cmr/old.md');
+ const newMove = path('mixed-cmr/moved.md');
+ await s.baseline(modify, 'm-v1');
+ await s.baseline(oldMove, 'move-me');
+
+ s.writeLocal(create, 'create content');
+ s.writeLocal(modify, 'm-v2');
+ s.renameLocal(oldMove, newMove);
+ await s.manager.trackRename(newMove, oldMove);
+
+ const headBefore = await s.head();
+ const result = await s.push([create, modify, s.tfile(newMove)]);
+ expect(result.success, describePushResult(result)).toBe(3);
+ expect(result.failed, describePushResult(result)).toBe(0);
+
+ await s.expectRemoteContent(create, 'create content');
+ await s.expectRemoteContent(modify, 'm-v2');
+ await s.expectRemoteMissing(oldMove);
+ await s.expectRemoteContent(newMove, 'move-me');
+ await s.expectSingleCommitSince(headBefore);
+ });
+
+ it('pushes create + modify + pure rename + rename-with-modify in one commit', async () => {
+ const s = scenario();
+ const create = path('mixed-lifecycle/create.md');
+ const modify = path('mixed-lifecycle/modify.md');
+ const renameOld = path('mixed-lifecycle/rename-old.md');
+ const renameNew = path('mixed-lifecycle/rename-new.md');
+ const moveOld = path('mixed-lifecycle/move-old.md');
+ const moveNew = path('mixed-lifecycle/move-new.md');
+ await s.baseline(modify, 'm-v1');
+ await s.baseline(renameOld, 'r-v1');
+ await s.baseline(moveOld, 'mv-v1');
+
+ s.writeLocal(create, 'create content');
+ s.writeLocal(modify, 'm-v2');
+ s.renameLocal(renameOld, renameNew);
+ await s.manager.trackRename(renameNew, renameOld);
+ s.renameLocal(moveOld, moveNew);
+ s.writeLocal(moveNew, 'mv-v2');
+ await s.manager.trackRename(moveNew, moveOld);
+
+ const headBefore = await s.head();
+ const result = await s.push([create, modify, s.tfile(renameNew), s.tfile(moveNew)]);
+ expect(result.success, describePushResult(result)).toBe(4);
+ expect(result.failed, describePushResult(result)).toBe(0);
+
+ await s.expectRemoteContent(create, 'create content');
+ await s.expectRemoteContent(modify, 'm-v2');
+ await s.expectRemoteMissing(renameOld);
+ await s.expectRemoteContent(renameNew, 'r-v1');
+ await s.expectRemoteMissing(moveOld);
+ await s.expectRemoteContent(moveNew, 'mv-v2');
+ await s.expectSingleCommitSince(headBefore);
+ });
+
+ it('locks the current contract for a safe + conflict batch (safe files commit, conflict skipped)', async () => {
+ const s = scenario();
+ const safe = path('mixed-safe-conflict/a.md');
+ const conflict = path('mixed-safe-conflict/b.md');
+ const created = path('mixed-safe-conflict/c.md');
+ await s.baseline(safe, 'a-v1');
+ await s.baseline(conflict, 'b-v1');
+
+ s.writeLocal(safe, 'a-v2');
+ s.writeLocal(conflict, 'b-local');
+ await s.modifyRemote(conflict, 'b-remote');
+ s.writeLocal(created, 'c-new');
+
+ fixture.setConflictResolver(() => 'skip');
+ const headBefore = await s.head();
+ const result = await s.push([safe, conflict, created]);
+ expect(result.success, describePushResult(result)).toBe(2);
+ expect(result.failed, describePushResult(result)).toBe(0);
+ expect(result.conflicts, describePushResult(result)).toBe(1);
+ expect(result.skippedConflicts, describePushResult(result)).toBe(1);
+
+ // Current contract: safe files land in one commit; the conflict is
+ // skipped (remote stays 'b-remote'), not atomic. Locked here.
+ await s.expectRemoteContent(safe, 'a-v2');
+ await s.expectRemoteContent(conflict, 'b-remote');
+ await s.expectRemoteContent(created, 'c-new');
+ await s.expectSingleCommitSince(headBefore);
+ });
+ });
+
+ // ------------------------------------------------------------------
+ // Phase 6 — Source Control selection workflows
+ //
+ // Drives the real SourceControlActionService + SyncSelectionStore +
+ // ChangeRepository on top of the real SyncManager (via the thin
+ // BoundarySyncWorkspace), so the ChangeId -> path -> workspace.push
+ // selection filter is the real production code, not a mock.
+ // ------------------------------------------------------------------
+ describe('selection workflows', () => {
+ it('pushes only the selected subset, leaving unselected files untouched', async () => {
+ const s = scenario();
+ const a = path('subset/a.md');
+ const b = path('subset/b.md');
+ const c = path('subset/c.md');
+ await s.baseline(a, 'a-v1');
+ await s.baseline(b, 'b-v1');
+ await s.baseline(c, 'c-v1');
+ s.writeLocal(a, 'a-v2');
+ s.writeLocal(b, 'b-v2');
+ s.writeLocal(c, 'c-v2');
+
+ const ca = change(a, 'local-modified');
+ const cb = change(b, 'local-modified');
+ const cc = change(c, 'local-modified');
+ const { selection, actionService, operations } = s.selectionStack([ca, cb, cc]);
+ selection.selectForSync(ca.id);
+ selection.selectForSync(cc.id);
+
+ const headBefore = await s.head();
+ await actionService.push([ca.id, cc.id]);
+
+ expect(operations.get(ca.id)).toBe('success');
+ expect(operations.get(cc.id)).toBe('success');
+ expect(operations.get(cb.id), 'unselected change stays idle').toBe('idle');
+ await s.expectRemoteContent(a, 'a-v2');
+ await s.expectRemoteContent(c, 'c-v2');
+ await s.expectRemoteContent(b, 'b-v1');
+ await s.expectSingleCommitSince(headBefore);
+ // Current contract: the action service marks operations but does
+ // not clear selection or refresh the repository, so the selection
+ // is retained (locked here).
+ expect(selection.isIncluded(ca.id)).toBe(true);
+ expect(selection.isIncluded(cc.id)).toBe(true);
+ });
+
+ it.skipIf(!runExtended)('pushes a subset then the remaining subset as two separate commits', async () => {
+ const s = scenario();
+ const a = path('subset-then-rest/a.md');
+ const b = path('subset-then-rest/b.md');
+ const c = path('subset-then-rest/c.md');
+ await s.baseline(a, 'a-v1');
+ await s.baseline(b, 'b-v1');
+ await s.baseline(c, 'c-v1');
+ s.writeLocal(a, 'a-v2');
+ s.writeLocal(b, 'b-v2');
+ s.writeLocal(c, 'c-v2');
+
+ const ca = change(a, 'local-modified');
+ const cb = change(b, 'local-modified');
+ const cc = change(c, 'local-modified');
+ const { selection, actionService, operations } = s.selectionStack([ca, cb, cc]);
+
+ const head0 = await s.head();
+ selection.selectForSync(ca.id);
+ selection.selectForSync(cc.id);
+ await actionService.push([ca.id, cc.id]);
+ const head1 = await s.head();
+ await s.expectSingleCommitSince(head0);
+
+ selection.selectForSync(cb.id);
+ await actionService.push([cb.id]);
+ const head2 = await s.head();
+ expect(head2, 'second push is a separate commit').not.toBe(head1);
+ const [, head2Parent] = await s.listCommitShas(2);
+ expect(head2Parent).toBe(head1);
+
+ expect(operations.get(ca.id)).toBe('success');
+ expect(operations.get(cb.id)).toBe('success');
+ expect(operations.get(cc.id)).toBe('success');
+ await s.expectRemoteContent(a, 'a-v2');
+ await s.expectRemoteContent(b, 'b-v2');
+ await s.expectRemoteContent(c, 'c-v2');
+ });
+
+ it.skipIf(!runExtended)('rename yields a path-derived ChangeId; selecting the new id pushes the move', async () => {
+ const s = scenario();
+ const oldP = path('selection-rename/a.md');
+ const newP = path('selection-rename/archive/a.md');
+ await s.baseline(oldP, 'v1');
+
+ s.renameLocal(oldP, newP);
+ await s.manager.trackRename(newP, oldP);
+
+ // Current model: ChangeId is path-derived, so the moved change
+ // carries a NEW id (the new path) with previousPath set; the old
+ // path's id is gone. Locking this assumption protects the status
+ // model against an accidental path->identity regression.
+ const moved = change(newP, 'moved', oldP);
+ const { selection, actionService, operations } = s.selectionStack([moved]);
+ selection.refresh([moved.id]);
+ selection.selectForSync(moved.id);
+
+ const headBefore = await s.head();
+ await actionService.push([moved.id]);
+
+ expect(operations.get(moved.id)).toBe('success');
+ await s.expectRemoteMissing(oldP);
+ await s.expectRemoteContent(newP, 'v1');
+ await s.expectSingleCommitSince(headBefore);
+ });
+ });
+
+ // ------------------------------------------------------------------
+ // Phase 6b — Download (remote-only) action
+ //
+ // The Download button / Sync-Queue download routing both resolve to
+ // SourceControlActionService.pull, which runs the real manager.pullAllFiles
+ // through BoundarySyncWorkspace. This locks the end-to-end primitive: a
+ // remote-only change (file exists on remote, absent locally) downloads
+ // into the vault and advances metadata to the remote sha.
+ // ------------------------------------------------------------------
+ describe('download (remote-only) action', () => {
+ it('downloads a remote-only change into the vault via actionService.pull, advancing metadata', async () => {
+ const s = scenario();
+ const p = path('download-remote-only/a.md');
+ await s.seedRemote(p, 'remote-content');
+
+ expect(s.localExists(p), 'no local file before download').toBe(false);
+
+ const remote = change(p, 'remote-only');
+ const { actionService, operations } = s.selectionStack([remote]);
+
+ await actionService.pull([remote.id]);
+
+ expect(operations.get(remote.id)).toBe('success');
+ expect(s.localExists(p), 'local file created by download').toBe(true);
+ expect(await s.readLocal(p)).toBe('remote-content');
+ const remoteMeta = await s.remoteContent(p);
+ expect(s.metadataSha(p), 'metadata advances to the remote sha').toBe(remoteMeta?.sha);
+ });
+
+ it.skipIf(!runExtended)('download leaves an unrelated local-only change untouched (no cross-contamination)', async () => {
+ const s = scenario();
+ const remote = path('download-isolation/remote.md');
+ const local = path('download-isolation/local.md');
+ await s.seedRemote(remote, 'remote-content');
+ s.writeLocal(local, 'local-only-content');
+
+ const remoteChange = change(remote, 'remote-only');
+ const localChange = change(local, 'local-only');
+ const { actionService, operations } = s.selectionStack([remoteChange, localChange]);
+
+ await actionService.pull([remoteChange.id]);
+
+ expect(operations.get(remoteChange.id)).toBe('success');
+ expect(operations.get(localChange.id), 'local-only change stays idle').toBe('idle');
+ expect(await s.readLocal(remote)).toBe('remote-content');
+ expect(await s.readLocal(local)).toBe('local-only-content');
+ });
+ });
+
+ // ------------------------------------------------------------------
+ // Unified Sync Plan — one Sync selecting a mix of change kinds must
+ // land as at most one remote commit, not one commit per kind.
+ // ------------------------------------------------------------------
+ describe('unified sync plan (one commit for a mixed batch)', () => {
+ it('syncs a modified file and a locally-deleted file in exactly one remote commit', async () => {
+ const s = scenario();
+ const modifyPath = path('unified-sync/modify.md');
+ const deletePath = path('unified-sync/delete.md');
+ await s.baseline(modifyPath, 'original content');
+ await s.baseline(deletePath, 'to be removed');
+
+ s.writeLocal(modifyPath, 'updated content');
+ s.deleteLocal(deletePath);
+
+ const headBefore = await s.head();
+ const modified = change(modifyPath, 'local-modified');
+ const deleted = change(deletePath, 'local-deleted');
+ const { actionService, operations } = s.selectionStack([modified, deleted]);
+
+ await actionService.sync([modified.id, deleted.id]);
+
+ expect(operations.get(modified.id)).toBe('success');
+ expect(operations.get(deleted.id)).toBe('success');
+ await s.expectSingleCommitSince(headBefore);
+ await s.expectRemoteContent(modifyPath, 'updated content');
+ await s.expectRemoteMissing(deletePath);
+ expect(s.metadata(deletePath), 'deleted metadata cleared').toBeUndefined();
+ });
+
+ it('creates zero commits for a pure-pull sync selection', async () => {
+ const s = scenario();
+ const p = path('unified-sync/pull-only.md');
+ await s.seedRemote(p, 'remote-content');
+
+ const headBefore = await s.head();
+ const remoteOnly = change(p, 'remote-only');
+ const { actionService, operations } = s.selectionStack([remoteOnly]);
+
+ await actionService.sync([remoteOnly.id]);
+
+ expect(operations.get(remoteOnly.id)).toBe('success');
+ expect(await s.readLocal(p)).toBe('remote-content');
+ await s.expectNoCommitSince(headBefore);
+ });
+ });
+
+ // ------------------------------------------------------------------
+ // Phase 7 — Remote divergence + idempotency
+ // ------------------------------------------------------------------
+ describe('divergence and idempotency flows', () => {
+ it('pulls a remote-ahead update into a synced-baseline local, advancing metadata', async () => {
+ const s = scenario();
+ const p = path('remote-ahead/a.md');
+ await s.baseline(p, 'A');
+ await s.modifyRemote(p, 'B');
+
+ await s.pullFile(p);
+
+ expect(await s.readLocal(p)).toBe('B');
+ const remote = await s.remoteContent(p);
+ expect(s.metadataSha(p), 'metadata moves to the remote sha').toBe(remote?.sha);
+ });
+
+ it.skipIf(!isGitHub)('a remote-ahead change and an unrelated local change coexist', async () => {
+ const s = scenario();
+ const a = path('coexist/a.md');
+ const b = path('coexist/b.md');
+ await s.baseline(a, 'A');
+ await s.baseline(b, 'B');
+
+ await s.modifyRemote(a, 'A-remote');
+ s.writeLocal(b, 'B-local');
+
+ await s.pullFile(a);
+ expect(await s.readLocal(a)).toBe('A-remote');
+ // The local change on b survives the pull of a — no cross-contamination.
+ expect(await s.readLocal(b)).toBe('B-local');
+ await s.expectRemoteContent(b, 'B');
+ });
+
+ it.skipIf(!isGitHub)('a concurrent remote write surfaces as a conflict, then reconciles with no lost update', async () => {
+ const s = scenario();
+ const p = path('concurrent/a.md');
+ await s.baseline(p, 'v1');
+ s.writeLocal(p, 'local-v2');
+ await s.modifyRemote(p, 'concurrent-v2');
+
+ fixture.setConflictResolver(() => 'skip');
+ const skipped = await s.push([p]);
+ expect(skipped.skippedConflicts, describePushResult(skipped)).toBeGreaterThanOrEqual(1);
+ await s.expectRemoteContent(p, 'concurrent-v2');
+
+ // Reconcile: accept the concurrent remote, then push a fresh local edit.
+ fixture.setConflictResolver(() => 'keep-remote');
+ await s.push([p]);
+ expect(await s.readLocal(p)).toBe('concurrent-v2');
+
+ s.writeLocal(p, 'final');
+ const headBefore = await s.head();
+ const finalResult = await s.push([p]);
+ expect(finalResult.success, describePushResult(finalResult)).toBe(1);
+ await s.expectRemoteContent(p, 'final');
+ await s.expectSingleCommitSince(headBefore);
+ });
+
+ it('an all-unchanged batch reports no work and creates zero commits', async () => {
+ const s = scenario();
+ const a = path('noop-batch/a.md');
+ const b = path('noop-batch/b.md');
+ const c = path('noop-batch/c.md');
+ await s.baseline(a, 'a');
+ await s.baseline(b, 'b');
+ await s.baseline(c, 'c');
+
+ const headBefore = await s.head();
+ const result = await s.push([a, b, c]);
+ expect(result.success, describePushResult(result)).toBe(0);
+ expect(result.failed, describePushResult(result)).toBe(0);
+ expect(result.skippedConflicts, describePushResult(result)).toBe(0);
+ await s.expectNoCommitSince(headBefore);
+ });
+
+ it.skipIf(!isGitHub)('repeating the same push twice makes no second mutation and corrupts no metadata', async () => {
+ const s = scenario();
+ const p = path('repeat-push/a.md');
+ await s.baseline(p, 'v1');
+ s.writeLocal(p, 'v2');
+
+ const first = await s.push([p]);
+ expect(first.success, describePushResult(first)).toBe(1);
+ await s.expectRemoteContent(p, 'v2');
+ const shaAfterFirst = s.metadataSha(p);
+ expect(shaAfterFirst).toBeTruthy();
+ const headAfterFirst = await s.head();
+
+ const second = await s.push([p]);
+ expect(second.success, describePushResult(second)).toBe(0);
+ expect(second.failed, describePushResult(second)).toBe(0);
+ await s.expectNoCommitSince(headAfterFirst);
+ expect(s.metadataSha(p), 'metadata not corrupted by the no-op repeat').toBe(shaAfterFirst);
+ });
+
+ it.skipIf(!runExtended)('re-syncs cleanly after a skipped conflict (no stale operation state)', async () => {
+ const s = scenario();
+ const p = path('retry-after-skip/a.md');
+ await s.baseline(p, 'v1');
+ s.writeLocal(p, 'local');
+ await s.modifyRemote(p, 'remote');
+
+ fixture.setConflictResolver(() => 'skip');
+ const skipped = await s.push([p]);
+ expect(skipped.skippedConflicts, describePushResult(skipped)).toBeGreaterThanOrEqual(1);
+
+ // Resolve the skipped conflict (keep-remote), then push a fresh edit.
+ fixture.setConflictResolver(() => 'keep-remote');
+ await s.push([p]);
+ expect(await s.readLocal(p)).toBe('remote');
+
+ s.writeLocal(p, 'reconciled');
+ const headBefore = await s.head();
+ const result = await s.push([p]);
+ expect(result.success, describePushResult(result)).toBe(1);
+ expect(result.failed, describePushResult(result)).toBe(0);
+ await s.expectRemoteContent(p, 'reconciled');
+ await s.expectSingleCommitSince(headBefore);
+ });
+ });
+
+ // ------------------------------------------------------------------
+ // Phase 8 — Path edge cases + batch scale
+ // ------------------------------------------------------------------
+ describe('path edge cases and batch scale', () => {
+ it.skipIf(!runExtended)('creates, modifies, and renames a unicode-named file', async () => {
+ const s = scenario();
+ const original = path('unicode/筆記/測試文件.md');
+ const archived = path('unicode/筆記/已歸檔.md');
+ await s.baseline(original, 'unicode-v1');
+
+ s.writeLocal(original, 'unicode-v2');
+ let headBefore = await s.head();
+ let result = await s.push([original]);
+ expect(result.success, describePushResult(result)).toBe(1);
+ await s.expectRemoteContent(original, 'unicode-v2');
+ await s.expectSingleCommitSince(headBefore);
+
+ s.renameLocal(original, archived);
+ await s.manager.trackRename(archived, original);
+ headBefore = await s.head();
+ result = await s.push([s.tfile(archived)]);
+ expect(result.success, describePushResult(result)).toBe(1);
+ await s.expectRemoteMissing(original);
+ await s.expectRemoteContent(archived, 'unicode-v2');
+ await s.expectSingleCommitSince(headBefore);
+ });
+
+ it.skipIf(!runExtended)('creates and modifies a file with spaces and symbols', async () => {
+ const s = scenario();
+ const p = path('spaces/folder/my note (draft).md');
+ await s.baseline(p, 'draft-v1');
+
+ s.writeLocal(p, 'draft-v2');
+ const headBefore = await s.head();
+ const result = await s.push([p]);
+ expect(result.success, describePushResult(result)).toBe(1);
+ await s.expectRemoteContent(p, 'draft-v2');
+ await s.expectSingleCommitSince(headBefore);
+ });
+
+ it.skipIf(!runExtended)('moves and modifies a deeply nested file', async () => {
+ const s = scenario();
+ const oldP = path('deep/a/b/c/d/e/note.md');
+ const newP = path('deep/archive/x/y/z/w/note.md');
+ await s.baseline(oldP, 'deep-v1');
+
+ s.renameLocal(oldP, newP);
+ s.writeLocal(newP, 'deep-v2');
+ await s.manager.trackRename(newP, oldP);
+
+ const headBefore = await s.head();
+ const result = await s.push([s.tfile(newP)]);
+ expect(result.success, describePushResult(result)).toBe(1);
+ await s.expectRemoteMissing(oldP);
+ await s.expectRemoteContent(newP, 'deep-v2');
+ await s.expectSingleCommitSince(headBefore);
+ });
+
+ it.skipIf(!runExtended)('creates 100 files in one commit', async () => {
+ const s = scenario();
+ const paths = Array.from({ length: 100 }, (_, i) => path(`batch-100/${String(i).padStart(3, '0')}.md`));
+ for (const p of paths) s.writeLocal(p, `content ${p}`);
+
+ const headBefore = await s.head();
+ const result = await s.push(paths);
+ expect(result.success, describePushResult(result)).toBe(100);
+ expect(result.failed, describePushResult(result)).toBe(0);
+ await s.expectSingleCommitSince(headBefore);
+ await s.expectRemoteContent(paths[0]!, `content ${paths[0]}`);
+ await s.expectRemoteContent(paths[50]!, `content ${paths[50]}`);
+ await s.expectRemoteContent(paths[99]!, `content ${paths[99]}`);
+ });
+
+ it.skipIf(!runExtended)('pushes a 100-file mixed batch (modify + create + rename) in one commit', async () => {
+ const s = scenario();
+ const modifyPaths = Array.from({ length: 40 }, (_, i) => path(`mixed-100/modify/${i}.md`));
+ const createPaths = Array.from({ length: 30 }, (_, i) => path(`mixed-100/create/${i}.md`));
+ const renameOld = Array.from({ length: 30 }, (_, i) => path(`mixed-100/rename-old/${i}.md`));
+ const renameNew = Array.from({ length: 30 }, (_, i) => path(`mixed-100/rename-new/${i}.md`));
+
+ for (const p of modifyPaths) await s.baseline(p, 'v1');
+ for (const p of renameOld) await s.baseline(p, 'r-v1');
+ for (const p of modifyPaths) s.writeLocal(p, 'v2');
+ for (const p of createPaths) s.writeLocal(p, 'new');
+ for (let i = 0; i < renameOld.length; i++) {
+ s.renameLocal(renameOld[i]!, renameNew[i]!);
+ await s.manager.trackRename(renameNew[i]!, renameOld[i]!);
+ }
+
+ const headBefore = await s.head();
+ const all = [...modifyPaths, ...createPaths, ...renameNew.map(p => s.tfile(p))];
+ const result = await s.push(all);
+ expect(result.success, describePushResult(result)).toBe(100);
+ expect(result.failed, describePushResult(result)).toBe(0);
+ await s.expectSingleCommitSince(headBefore);
+
+ await s.expectRemoteContent(modifyPaths[0]!, 'v2');
+ await s.expectRemoteContent(createPaths[0]!, 'new');
+ await s.expectRemoteMissing(renameOld[0]!);
+ await s.expectRemoteContent(renameNew[0]!, 'r-v1');
+ });
+
+ it.skipIf(!isStress || !runExtended)('stress: creates 1000 files', async () => {
+ const s = scenario();
+ const paths = Array.from({ length: 1000 }, (_, i) => path(`batch-1000/${String(i).padStart(4, '0')}.md`));
+ for (const p of paths) s.writeLocal(p, `content ${p}`);
+
+ const result = await s.push(paths);
+ expect(result.success, describePushResult(result)).toBe(1000);
+ expect(result.failed, describePushResult(result)).toBe(0);
+ await s.expectRemoteContent(paths[0]!, `content ${paths[0]}`);
+ await s.expectRemoteContent(paths[999]!, `content ${paths[999]}`);
+ }, 300_000);
+ });
+});
\ No newline at end of file
diff --git a/e2e/suites/sync-manager.e2e.test.ts b/e2e-tests/provider/suites/sync-manager.e2e.test.ts
similarity index 90%
rename from e2e/suites/sync-manager.e2e.test.ts
rename to e2e-tests/provider/suites/sync-manager.e2e.test.ts
index 4299a09..d0c5406 100644
--- a/e2e/suites/sync-manager.e2e.test.ts
+++ b/e2e-tests/provider/suites/sync-manager.e2e.test.ts
@@ -1,18 +1,19 @@
import { describe, it, expect, beforeAll, vi } from 'vitest';
-import { SyncManager, BatchPushConflict, ConflictResolution } from '../../src/logic/sync-manager';
-import { SyncPlanModal, SyncPlanDirection } from '../../src/ui/SyncPlanModal';
-import { BatchConflictResolutionModal } from '../../src/ui/BatchConflictResolutionModal';
-import { ObsidianSyncInteraction } from '../../src/ui/ObsidianSyncInteraction';
+import { SyncManager, BatchPushConflict, ConflictResolution } from '../../../src/logic/sync-manager';
+import { SyncPlanModal, SyncPlanDirection } from '../../../src/ui/SyncPlanModal';
+import { BatchConflictResolutionModal } from '../../../src/ui/BatchConflictResolutionModal';
+import { ObsidianSyncInteraction } from '../../../src/ui/ObsidianSyncInteraction';
import { describePushResult } from '../support/push-result-diagnostic';
// `import type` deliberately, not a value import: src/settings.ts also
// exports settings-tab UI (GitLabSyncSettingTab -> FolderSuggest ->
// AbstractInputSuggest etc.) which pulls in far more of `obsidian` than this
-// suite's minimal generated shim provides. A type-only import is erased
+// suite's minimal runtime shim provides. A type-only import is erased
// entirely, so none of that module ever loads.
-import type { GitLabFilesPushSettings } from '../../src/settings';
+import type { GitLabFilesPushSettings } from '../../../src/settings';
+import { TFile as ObsidianTFile } from 'obsidian';
+import { GitVerifier } from '../support/git-verifier';
import { FakeVault, fakeApp, type TFileLike, type TFileCtor } from '../shim/fake-vault';
-import { currentProvider, timeouts, contextFor, runtimeDir } from '../config/env';
-import type { GitVerifier as GitVerifierType } from '../verifier-runtime-types';
+import { currentProvider, timeouts, contextFor } from '../config/env';
// Every push/pull SyncManager does shows a plan-review modal first, and any
// push-side content conflict now goes through BatchConflictResolutionModal
@@ -21,9 +22,9 @@ import type { GitVerifier as GitVerifierType } from '../verifier-runtime-types';
// for unit tests. Pull-side conflicts still go through SyncConflictModal,
// left as the bare automock default (does nothing, matching production:
// pullFile returns before the conflict modal resolves).
-vi.mock('../../src/ui/SyncPlanModal');
-vi.mock('../../src/ui/SyncConflictModal');
-vi.mock('../../src/ui/BatchConflictResolutionModal');
+vi.mock('../../../src/ui/SyncPlanModal');
+vi.mock('../../../src/ui/SyncConflictModal');
+vi.mock('../../../src/ui/BatchConflictResolutionModal');
function makeSettings(branch: string): GitLabFilesPushSettings {
return {
@@ -46,17 +47,17 @@ function makeSettings(branch: string): GitLabFilesPushSettings {
/**
* Real SyncManager + real production provider service (see
- * e2e/config/env.ts), driven against whichever provider `E2E_PROVIDER`
+ * e2e-tests/provider/config/env.ts), driven against whichever provider `E2E_PROVIDER`
* selects -- the same branch/verifier the contract suites use, so this suite
* adds no provider-specific logic of its own. Only the Obsidian filesystem
- * boundary is faked (e2e/shim/fake-vault.ts); everything else is the real
+ * boundary is faked (e2e-tests/provider/shim/fake-vault.ts); everything else is the real
* code path.
*/
describe('SyncManager E2E', () => {
const provider = currentProvider();
let service: ReturnType['service'];
let branch: string;
- let verifier: GitVerifierType;
+ let verifier: GitVerifier;
let TFile: TFileCtor;
let conflictResolver: (conflict: BatchPushConflict) => ConflictResolution;
const runId = Math.random().toString(36).slice(2, 10);
@@ -66,11 +67,8 @@ describe('SyncManager E2E', () => {
const ctx = contextFor(provider);
service = ctx.service;
branch = ctx.branch;
- const dir = runtimeDir();
- const { GitVerifier } = await import(/* @vite-ignore */ `${dir}/verifier/git-verifier.ts`) as { GitVerifier: new () => GitVerifierType };
- const obsidianShim = await import(/* @vite-ignore */ `${dir}/obsidian-request-url.ts`) as { TFile: TFileCtor };
verifier = new GitVerifier();
- TFile = obsidianShim.TFile;
+ TFile = ObsidianTFile;
conflictResolver = () => 'skip';
vi.mocked(SyncPlanModal).mockImplementation(function (
@@ -78,13 +76,11 @@ describe('SyncManager E2E', () => {
) {
onConfirm();
return this;
- } as never);
+ });
vi.mocked(BatchConflictResolutionModal).mockImplementation(function (
this: BatchConflictResolutionModal,
_app: unknown,
- _gitService: unknown,
conflicts: BatchPushConflict[],
- _totalFiles: number,
_safeCount: number,
onResolve: () => void,
_onCancel: () => void,
@@ -92,7 +88,7 @@ describe('SyncManager E2E', () => {
for (const conflict of conflicts) conflict.resolution = conflictResolver(conflict);
onResolve();
return this;
- } as never);
+ });
}, timeouts.containerReadyMs + 30_000);
function newManager(vault: FakeVault, settings: GitLabFilesPushSettings): SyncManager {
diff --git a/e2e-tests/provider/suites/two-client-sync.e2e.test.ts b/e2e-tests/provider/suites/two-client-sync.e2e.test.ts
new file mode 100644
index 0000000..4d1266b
--- /dev/null
+++ b/e2e-tests/provider/suites/two-client-sync.e2e.test.ts
@@ -0,0 +1,235 @@
+import { describe, it, expect, beforeAll, vi } from 'vitest';
+import { BatchConflictResolutionModal } from '../../../src/ui/BatchConflictResolutionModal';
+import { createSyncManagerFixture, type SyncManagerFixture } from '../support/sync-manager-fixture';
+import { TwoClientSyncScenario } from '../support/two-client-sync-scenario';
+import {
+ convergenceContext,
+ expectTwoClientConvergence,
+ expectIdempotent,
+ expectNoSilentDataLoss,
+ type ConvergenceContext,
+} from '../support/convergence-assertions';
+import type { ConflictResolution } from '../../../src/logic/sync/types';
+import { timeouts } from '../config/env';
+
+// Same modal auto-confirm pattern as the other e2e suites: plan-review and
+// push-side conflicts resolve without a human, steered per test through
+// fixture.setConflictResolver. Pull-side SyncConflictModal stays the bare
+// automock (does nothing — matching production: pullFile returns before the
+// modal resolves).
+vi.mock('../../../src/ui/SyncPlanModal');
+vi.mock('../../../src/ui/SyncConflictModal');
+vi.mock('../../../src/ui/BatchConflictResolutionModal');
+
+/**
+ * Multi-client Sync E2E — two fully independent clients (A/B: separate
+ * FakeVaults, separate syncMetadata stores, separate SyncManagers, separate
+ * Source Control stacks) syncing against ONE shared real provider branch.
+ * Validates the cross-device convergence contract: neither client may
+ * silently destroy the other's synced work, and converged state must stay
+ * converged under repeated syncs.
+ *
+ * Production-code rule for this suite: tests define the safety contract; if a
+ * RED here proves a production data-loss bug, that's a follow-up
+ * `fix(sync): ...` — never a weakened assertion.
+ */
+describe('Two-client sync E2E', () => {
+ let fixture: SyncManagerFixture;
+ let setResolver: (resolution: ConflictResolution) => void;
+
+ beforeAll(async () => {
+ fixture = await createSyncManagerFixture({ scoped: true });
+ setResolver = (resolution: ConflictResolution): void => {
+ fixture.setConflictResolver(() => resolution);
+ };
+ setResolver('skip');
+ }, timeouts.containerReadyMs + 30_000);
+
+ const scenario = (): TwoClientSyncScenario => TwoClientSyncScenario.from({
+ service: fixture.service,
+ branch: fixture.branch,
+ verifier: fixture.verifier,
+ TFile: fixture.TFile,
+ runId: fixture.runId,
+ newVault: () => fixture.createVault(),
+ newSettings: () => fixture.makeSettings(),
+ newManager: (vault, settings) => fixture.newManager(vault, settings),
+ conflictResolver: () => fixture.conflictResolver(),
+ });
+
+ // --- P0-1: normal round-trip convergence -------------------------------
+
+ it('P0-1: A→B→A round-trip converges local trees, remote tree, metadata, and stays idempotent', async () => {
+ const s = scenario();
+ const file = s.path('p0-1/round-trip.md');
+ const other = s.path('p0-1/another.md');
+ const ctx: ConvergenceContext = convergenceContext([s.a, s.b], fixture.verifier, fixture.branch, `e2e-tc-${fixture.runId}/p0-1/`);
+
+ await s.baseline(file, 'v1');
+
+ // A edits and syncs; B then pulls.
+ s.a.write(file, 'A edit v2');
+ await s.a.sync();
+ await s.b.sync();
+
+ // B edits; A pulls.
+ s.b.write(file, 'B edit v3');
+ await s.b.sync();
+ await s.a.sync();
+
+ // A creates a new file; B pulls it.
+ s.a.write(other, 'A new file');
+ await s.a.sync();
+ await s.b.sync();
+
+ await expectTwoClientConvergence(ctx);
+ await s.expectRemoteContent(file, 'B edit v3');
+ await s.expectRemoteContent(other, 'A new file');
+ await expectIdempotent(ctx);
+ await expectTwoClientConvergence(ctx);
+ });
+
+ // --- P0-2: concurrent edits on DIFFERENT files must both survive ------
+
+ it('P0-2: concurrent different-file edits merge without either client clobbering the other', async () => {
+ const s = scenario();
+ const fileA = s.path('p0-2/a.md');
+ const fileB = s.path('p0-2/b.md');
+ const ctx: ConvergenceContext = convergenceContext([s.a, s.b], fixture.verifier, fixture.branch, `e2e-tc-${fixture.runId}/p0-2/`);
+
+ await s.baseline(fileA, 'a-v1');
+ await s.baseline(fileB, 'b-v1');
+
+ // Both clients diverge on unrelated files while stale on the other's.
+ s.a.write(fileA, 'a-v2 by A');
+ s.b.write(fileB, 'b-v2 by B');
+
+ await s.a.sync();
+ await s.b.sync();
+ await s.a.sync();
+
+ // Idempotency under repeated syncs of converged state is already
+ // covered by P0-1's expectIdempotent — P0-2's own contract is that
+ // concurrent edits on different files both survive the merge.
+ await expectTwoClientConvergence(ctx);
+ await s.expectRemoteContent(fileA, 'a-v2 by A');
+ await s.expectRemoteContent(fileB, 'b-v2 by B');
+ });
+
+ // --- P0-3: same-file modify/modify conflict ----------------------------
+
+ it('P0-3: modify/modify conflict with skip keeps remote, keeps local content, and does not falsely mark synced', async () => {
+ const s = scenario();
+ const file = s.path('p0-3/note.md');
+ const ctx: ConvergenceContext = convergenceContext([s.a, s.b], fixture.verifier, fixture.branch, `e2e-tc-${fixture.runId}/p0-3/`);
+
+ await s.baseline(file, 'v1');
+ const baselineShaB = s.b.metadataSha(file);
+
+ // Both sides edit from the shared baseline.
+ s.a.write(file, 'A-v2');
+ s.b.write(file, 'B-v2');
+
+ setResolver('skip');
+ await s.a.sync(); // lands A-v2 on the remote
+ await s.b.sync(); // B must see a conflict, not a silent push or pull
+
+ // The conflict modal must actually have been shown to B.
+ expect(vi.mocked(BatchConflictResolutionModal).mock.calls.length).toBeGreaterThanOrEqual(1);
+ const lastConflictCall = vi.mocked(BatchConflictResolutionModal).mock.calls[vi.mocked(BatchConflictResolutionModal).mock.calls.length - 1];
+ const conflictedPaths = lastConflictCall?.[1]?.map(conflict => conflict.path) ?? [];
+ expect(conflictedPaths).toContain(file);
+
+ // Safety contract under 'skip': remote untouched, B keeps its local
+ // edit, B's metadata stays at the baseline (never falsely marked
+ // synced), and B's edit has not vanished (data-loss invariant).
+ await s.expectRemoteContent(file, 'A-v2');
+ expect(await s.b.read(file)).toBe('B-v2');
+ expect(s.b.metadataSha(file), 'B metadata must stay at baseline after a skipped conflict').toBe(baselineShaB);
+ await expectNoSilentDataLoss(ctx, [{ path: file, content: 'B-v2' }]);
+ });
+
+ // --- P0-4: delete vs modify must not silently lose content -------------
+
+ it('P0-4: delete on A + modify on B must not silently destroy B content (conflict or explicit outcome, never silent loss)', async () => {
+ const s = scenario();
+ const file = s.path('p0-4/a.md');
+
+ await s.baseline(file, 'v1');
+
+ // A deletes the file; B edits it. Both start from the same baseline.
+ s.a.delete(file);
+ s.b.write(file, 'B-v2 survives?');
+
+ // The whole point of this test is what production does here — do NOT
+ // steer the resolver away from its default; the safety assertion below
+ // holds for ANY resolution path (skip/keep-local/keep-remote).
+ await s.a.sync(); // deletion lands on the remote
+ await s.b.sync(); // B, whose baseline says the file exists, must surface the divergence
+
+ // SAFETY INVARIANT (not a semantic choice): whatever conflict policy
+ // production picked, B's new content must not have silently vanished.
+ // Legitimate outcomes: conflict row left pending on B, or content
+ // present remotely/locally after an explicit resolution.
+ const remote = await s.remoteContent(file);
+ const stillOnRemote = remote?.content === 'B-v2 survives?';
+ const inB = s.b.exists(file) && (await s.b.read(file)) === 'B-v2 survives?';
+ const pendingOnB = s.b.statusesNow().some(status => status.path === file && status.status !== 'synced');
+ expect(stillOnRemote || inB || pendingOnB, [
+ 'delete/modify produced silent data loss:',
+ `remote=${JSON.stringify(remote?.content)}`,
+ `B has file=${s.b.exists(file)}`,
+ `B statuses=${JSON.stringify(s.b.statusesNow().map(status => `${status.path}:${status.status}`))}`,
+ ].join(' ')).toBe(true);
+ });
+
+ // --- P0-5: rename on A vs modify of the old path on B ------------------
+
+ it('P0-5: rename a→archive/a on A vs modify of notes/a.md on B must not silently drop B edit or resurrect stale data', async () => {
+ const s = scenario();
+ const oldPath = s.path('p0-5/notes/a.md');
+ const newPath = s.path('p0-5/archive/a.md');
+
+ await s.baseline(oldPath, 'v1');
+
+ // A moves the file (unmodified content — a pure rename)
+ s.a.rename(oldPath, newPath);
+ await s.a.trackRename(newPath, oldPath);
+
+ // B modifies the file at the OLD path while still unaware of the move.
+ s.b.write(oldPath, 'B-v2');
+
+ // Interleaving under test: B syncs first (pushes B-v2 to old path),
+ // then A syncs (rename against a modified source).
+ const bSyncPushed = await (async () => {
+ await s.b.sync();
+ const remote = await s.remoteContent(oldPath);
+ return remote?.content === 'B-v2';
+ })();
+ await s.a.sync();
+
+ // SAFETY INVARIANT (not a semantic choice): B's edit must survive
+ // somewhere with its content intact. Forbidden outcomes:
+ // - old path deleted remotely AND new path carrying only stale v1/B
+ // content while B-v2 exists nowhere;
+ // - B-v2 silently reverted to v1 everywhere.
+ const remote = await s.remoteContent(oldPath);
+ const newRemote = await s.remoteContent(newPath);
+ const bEditOnOldRemote = remote?.content === 'B-v2';
+ const bEditOnNewRemote = newRemote?.content === 'B-v2';
+ const bEditInB = s.b.exists(oldPath) && (await s.b.read(oldPath)) === 'B-v2';
+ const pendingOnB = s.b.statusesNow().some(status => status.path === oldPath && status.status !== 'synced');
+ const staleResurrection = (remote === null && newRemote?.content === 'v1' && !bEditInB && !pendingOnB);
+ expect(
+ bEditOnOldRemote || bEditOnNewRemote || bEditInB || pendingOnB,
+ [
+ 'rename/modify silently dropped B edit:',
+ `bSyncPushed=${bSyncPushed}`,
+ `old remote=${JSON.stringify(remote?.content)}`,
+ `new remote=${JSON.stringify(newRemote?.content)}`,
+ `B statuses=${JSON.stringify(s.b.statusesNow().map(status => `${status.path}:${status.status}`))}`,
+ ].join(' '),
+ ).toBe(true);
+ expect(staleResurrection, 'rename produced the stale-resurrection outcome: old path deleted, new path holds stale v1, B-v2 gone').toBe(false);
+ });
+});
\ No newline at end of file
diff --git a/e2e-tests/provider/support/convergence-assertions.ts b/e2e-tests/provider/support/convergence-assertions.ts
new file mode 100644
index 0000000..d9ee134
--- /dev/null
+++ b/e2e-tests/provider/support/convergence-assertions.ts
@@ -0,0 +1,189 @@
+import { expect } from 'vitest';
+import type { GitVerifier } from './git-verifier';
+import type { TwoClient } from './two-client-sync-scenario';
+import { timed } from './timing-diagnostics';
+
+/**
+ * Multi-client safety invariants, expressed once so every two-client test
+ * asserts against the same standard instead of hand-rolling per-test checks.
+ *
+ * All remote reads go through the independent git-CLI verifier — never the
+ * provider service under test — so "remote tree" here is ground truth, not
+ * the service agreeing with itself.
+ */
+
+export interface ConvergenceContext {
+ clients: [TwoClient, TwoClient];
+ branch: string;
+ verifier: GitVerifier;
+ /** Only paths under this run's namespace. */
+ runPrefix: string;
+}
+
+/**
+ * The union of local paths across both clients (the paths that must
+ * converge), scoped to this run's namespace. A real sync pulls the whole
+ * remote tree, so an unscoped union would also pick up every other suite's
+ * fixtures on the shared disposable-provider branch.
+ */
+export async function trackedPaths(context: ConvergenceContext): Promise {
+ const paths = new Set();
+ for (const client of context.clients) {
+ for (const path of client.vault.paths()) {
+ if (path.startsWith(context.runPrefix)) paths.add(path);
+ }
+ }
+ return [...paths].sort((a, b) => a.localeCompare(b));
+}
+
+/** A file's remote content/sha, or `null` if it doesn't exist remotely. */
+export type RemoteFile = { content: string; sha: string } | null;
+
+/**
+ * One read of "everything a convergence check needs from the remote", so
+ * `expectConverged` + `expectMetadataConsistent` don't each independently
+ * re-fetch the same files — every extra round trip is real wall-clock time
+ * against the real provider API.
+ */
+export interface RemoteSnapshot {
+ /** Tracked path -> remote file (or null if absent), one fetch per path. */
+ files: Map;
+ /** All remote paths under this run's namespace, one `listFiles` call. */
+ remotePaths: string[];
+}
+
+export async function captureRemoteSnapshot(context: ConvergenceContext, paths?: string[]): Promise {
+ return timed('remote snapshot (verifier)', async () => {
+ const trackedPathList = paths ?? (await trackedPaths(context));
+ const files = new Map();
+ for (const path of trackedPathList) {
+ files.set(path, await context.verifier.getFile(path, context.branch));
+ }
+ const remotePaths = (await context.verifier.listFiles(context.branch))
+ .filter(path => path.startsWith(context.runPrefix))
+ .sort((a, b) => a.localeCompare(b));
+ return { files, remotePaths };
+ });
+}
+
+/**
+ * Invariant A — Convergence: after a complete sync cycle,
+ * A local tree == B local tree == remote tree for every tracked path
+ * (existence, content, and absence all agree). Reuses `snapshot` if given
+ * (see `captureRemoteSnapshot`) instead of re-fetching from the remote.
+ */
+export async function expectConverged(context: ConvergenceContext, snapshot?: RemoteSnapshot): Promise {
+ const [clientA, clientB] = context.clients;
+ const paths = await trackedPaths(context);
+ const remote = snapshot ?? await captureRemoteSnapshot(context, paths);
+ for (const path of paths) {
+ const remoteFile = remote.files.get(path) ?? null;
+ const aHas = clientA.exists(path);
+ const bHas = clientB.exists(path);
+ expect(aHas, `convergence: ${path} existence A vs B (${aHas} vs ${bHas})`).toBe(bHas);
+ const expectedMessage = `convergence: ${path} local vs remote`;
+ if (!aHas) {
+ expect(remoteFile, expectedMessage).toBeNull();
+ continue;
+ }
+ expect(remoteFile, expectedMessage).not.toBeNull();
+ expect(await clientA.read(path), `convergence: ${path} A vs B`).toBe(await clientB.read(path));
+ expect(await clientA.read(path), `convergence: ${path} A vs remote`).toBe(remoteFile!.content);
+ }
+ // Nothing in the run's remote namespace should exist without existing in
+ // both local vaults either (catches remote-only surprises like a dropped
+ // rename source that left a stale blob behind).
+ expect(remote.remotePaths).toEqual(paths.filter(path => clientA.exists(path)));
+}
+
+/**
+ * Invariant B — Metadata consistency: every path that exists locally in a
+ * client (i.e. was synced, not deliberately local-only) must carry a
+ * lastSyncedSha equal to the current remote blob sha — on BOTH clients.
+ * Catches "file looks identical but baselines diverged" — the source of the
+ * next false conflict or silent overwrite. Reuses `snapshot` if given.
+ */
+export async function expectMetadataConsistent(context: ConvergenceContext, snapshot?: RemoteSnapshot): Promise {
+ const paths = await trackedPaths(context);
+ const remote = snapshot ?? await captureRemoteSnapshot(context, paths);
+ for (const path of paths) {
+ const remoteFile = remote.files.get(path);
+ if (!remoteFile) continue;
+ for (const client of context.clients) {
+ if (!client.exists(path)) continue;
+ const meta = client.metadata(path);
+ expect(meta?.lastSyncedSha, `metadata: ${client.name} ${path} lastSyncedSha vs remote blob sha`).toBe(remoteFile.sha);
+ }
+ }
+}
+
+/**
+ * Invariant C — Clean state: each client's last refresh projects zero pending
+ * changes (no modified / unsynced / local-deleted / remote-only / moved /
+ * checking rows). Mirrors the Source Control view being empty.
+ */
+export function expectClean(...clients: TwoClient[]): void {
+ for (const client of clients) {
+ const pending = client.statusesNow().filter(status => status.status !== 'synced');
+ expect(pending, `${client.name} pending changes after convergence`).toEqual([]);
+ }
+}
+
+/**
+ * Invariant D — Idempotency: after convergence, A sync -> B sync -> A sync
+ * must produce zero new remote commits. The core anti-ping-pong regression:
+ * a stale or diverging baseline would make some client keep "fixing" the
+ * remote and rack up commits forever.
+ */
+export async function expectIdempotent(context: ConvergenceContext): Promise {
+ const headBefore = await context.verifier.listCommitShas(context.branch, 1).then(shas => shas[0]!);
+ const [clientA, clientB] = context.clients;
+ await clientA.sync();
+ await clientB.sync();
+ await clientA.sync();
+ const [headAfter] = await context.verifier.listCommitShas(context.branch, 1);
+ expect(headAfter, 'idempotency: repeated syncs must not create commits').toBe(headBefore);
+}
+
+/**
+ * Invariant E — No silent data loss: asserts that some expected non-baseline
+ * content SURVIVED the sync run. `survivors` maps path -> content that some
+ * local client held after divergence; each entry must exist somewhere at the
+ * end — either as that exact content remotely, or in one of the clients'
+ * local vaults (a conflict keeping it locally counts; silent disappearance
+ * does not).
+ */
+export async function expectNoSilentDataLoss(
+ context: ConvergenceContext,
+ survivors: Array<{ path: string; content: string }>,
+): Promise {
+ for (const expected of survivors) {
+ const remote = await context.verifier.getFile(expected.path, context.branch);
+ const inA = context.clients[0].exists(expected.path) && (await context.clients[0].read(expected.path)) === expected.content;
+ const inB = context.clients[1].exists(expected.path) && (await context.clients[1].read(expected.path)) === expected.content;
+ const onRemote = remote?.content === expected.content;
+ expect(
+ inA || inB || onRemote,
+ `data loss: content "${expected.content}" for ${expected.path} vanished — not on remote, not in A, not in B`,
+ ).toBe(true);
+ }
+}
+
+/** Full post-sync convergence gate used by the P0 suite: A + B + remote together. */
+export async function expectTwoClientConvergence(context: ConvergenceContext): Promise {
+ const paths = await trackedPaths(context);
+ const snapshot = await captureRemoteSnapshot(context, paths);
+ await expectConverged(context, snapshot);
+ await expectMetadataConsistent(context, snapshot);
+ expectClean(...context.clients);
+}
+
+/** Convenience: builds the assertion context from a scenario + run prefix. */
+export function convergenceContext(
+ clients: [TwoClient, TwoClient],
+ verifier: GitVerifier,
+ branch: string,
+ runPrefix: string,
+): ConvergenceContext {
+ return { clients, branch, verifier, runPrefix };
+}
\ No newline at end of file
diff --git a/e2e-tests/provider/support/git-verifier.ts b/e2e-tests/provider/support/git-verifier.ts
new file mode 100644
index 0000000..58c27c7
--- /dev/null
+++ b/e2e-tests/provider/support/git-verifier.ts
@@ -0,0 +1,101 @@
+import { execFileSync } from 'node:child_process';
+
+const GIT_TIMEOUT_MS = 30_000;
+
+/**
+ * Independent verifier backed by plain git CLI against the isolated clone
+ * `scripts/e2e-harness.sh` already checked out at `$E2E_WORKDIR/repo` --
+ * never the service under test reading back its own writes.
+ *
+ * A suite must never call `service.getFile()` to confirm `service.pushFile()`
+ * worked — that only proves the service agrees with itself, not that the
+ * remote actually changed. Every remote assertion in an E2E suite goes
+ * through one of these methods instead.
+ */
+export class GitVerifier {
+ constructor(private readonly repoDir: string = defaultRepoDir()) {}
+
+ private git(args: string[]): string {
+ const command = `git ${args.join(' ')}`;
+ try {
+ return execFileSync('git', ['-C', this.repoDir, ...args], {
+ encoding: 'utf-8',
+ stdio: ['pipe', 'pipe', 'pipe'],
+ timeout: GIT_TIMEOUT_MS,
+ killSignal: 'SIGTERM',
+ });
+ } catch (error) {
+ const stderr = error && typeof error === 'object' && 'stderr' in error
+ ? String(error.stderr).trim()
+ : '';
+ const message = stderr || String(error);
+ throw new Error(`${command} timed out after ${GIT_TIMEOUT_MS}ms: ${message}`);
+ }
+ }
+
+ private fetch(ref: string): void {
+ this.git(['fetch', 'origin', ref]);
+ }
+
+ async getFile(path: string, ref: string): Promise<{ content: string; sha: string } | null> {
+ this.fetch(ref);
+ try {
+ const sha = this.git(['rev-parse', `origin/${ref}:${path}`]).trim();
+ const content = this.git(['show', `origin/${ref}:${path}`]);
+ return { content, sha };
+ } catch {
+ return null;
+ }
+ }
+
+ async listFiles(ref: string): Promise {
+ this.fetch(ref);
+ return this.git(['ls-tree', '-r', '--name-only', `origin/${ref}`])
+ .split('\n')
+ .filter(Boolean);
+ }
+
+ async fileMissing(path: string, ref: string): Promise {
+ return (await this.getFile(path, ref)) === null;
+ }
+
+ async listCommitShas(ref: string, perPage = 30): Promise {
+ this.fetch(ref);
+ return this.git(['log', '--format=%H', '-n', String(perPage), `origin/${ref}`])
+ .split('\n')
+ .filter(Boolean);
+ }
+
+ /** Git tree mode at path (e.g. "120000" for a symlink). */
+ async getBlobMode(path: string, ref: string): Promise {
+ this.fetch(ref);
+ const line = this.git(['ls-tree', `origin/${ref}`, '--', path]).trim();
+ if (!line) return null;
+ return line.split(/\s+/)[0] ?? null;
+ }
+
+ async getCommitMessage(sha: string): Promise {
+ return this.git(['log', '-1', '--format=%B', sha]).trim();
+ }
+
+ /** Last commit sha that touched path -- GitLab's optimistic-locking "revision". */
+ async getRevision(path: string, ref: string): Promise {
+ this.fetch(ref);
+ const sha = this.git(['log', '-1', '--format=%H', `origin/${ref}`, '--', path]).trim();
+ return sha || null;
+ }
+}
+
+// `scripts/run-e2e.sh` always exports E2E_WORKDIR (from provision's
+// e2e.env) into the vitest process before suites run; the clone this
+// verifier reads lives at `$E2E_WORKDIR/repo` (see e2e-harness.sh's
+// clone_dir()).
+function defaultRepoDir(): string {
+ const workdir = process.env.E2E_WORKDIR;
+ if (!workdir) {
+ throw new Error(
+ 'E2E_WORKDIR is not set -- GitVerifier must run via scripts/run-e2e.sh (or the CI steps), not npx vitest directly.',
+ );
+ }
+ return `${workdir}/repo`;
+}
diff --git a/e2e/support/push-result-diagnostic.ts b/e2e-tests/provider/support/push-result-diagnostic.ts
similarity index 100%
rename from e2e/support/push-result-diagnostic.ts
rename to e2e-tests/provider/support/push-result-diagnostic.ts
diff --git a/e2e-tests/provider/support/source-control-scenarios.ts b/e2e-tests/provider/support/source-control-scenarios.ts
new file mode 100644
index 0000000..f467387
--- /dev/null
+++ b/e2e-tests/provider/support/source-control-scenarios.ts
@@ -0,0 +1,262 @@
+import { expect } from 'vitest';
+import type { TFile } from 'obsidian';
+import type { GitServiceInterface } from '../../../src/services/git-service-interface';
+import type { SyncManager } from '../../../src/logic/sync-manager';
+import type { BatchPushConflict, ConflictResolution, PushResults } from '../../../src/logic/sync/types';
+import type { GitLabFilesPushSettings } from '../../../src/settings';
+import type { FakeVault, TFileLike } from '../shim/fake-vault';
+import type { SyncManagerFixture } from './sync-manager-fixture';
+import type { GitVerifier } from './git-verifier';
+import { ChangeRepository } from '../../../src/logic/source-control/ChangeRepository';
+import { OperationState } from '../../../src/logic/source-control/OperationState';
+import { SyncSelectionStore } from '../../../src/logic/source-control/SyncSelectionStore';
+import { SourceControlActionService } from '../../../src/logic/source-control/SourceControlActionService';
+import { BoundarySyncWorkspace } from '../../../src/logic/sync/SyncWorkspace';
+import { toChangeId, type SyncChange } from '../../../src/logic/source-control/types';
+import type { SyncStatusRefreshResult } from '../../../src/logic/sync/SyncStatusRefreshService';
+import type { RemoteDeleteResult } from '../../../src/logic/sync/RemoteDeleteExecutor';
+import type { FileDiff } from '../../../src/logic/sync/types';
+import type { GitTreeEntry } from '../../../src/services/git-service-interface';
+
+/**
+ * High-level scenario wrapper around a {@link SyncManagerFixture}: owns one
+ * FakeVault + settings + real SyncManager for a test, and exposes the
+ * seed/modify/assert verbs the source-control-flow suites use, so a test reads
+ * as `seed → modify local → modify remote → push → expect` instead of 50 lines
+ * of setup. Remote assertions always go through the fixture's independent
+ * git-CLI verifier, never the service under test.
+ *
+ * One scenario per test; paths are supplied by the caller (via
+ * `fixture.path`) so two scenarios can share a remote path when a test needs a
+ * fresh manager against pre-seeded remote state.
+ */
+export class SourceControlScenario {
+ readonly vault: FakeVault;
+ readonly settings: GitLabFilesPushSettings;
+ readonly manager: SyncManager;
+ private readonly service: GitServiceInterface;
+ private readonly verifier: GitVerifier;
+ private readonly branch: string;
+ /**
+ * Memoizes remote reads (each of which is a real `git fetch` round trip)
+ * between remote mutations. Invalidated by `invalidatingProxy` below
+ * whenever `manager.pushFiles`/`pullFile`/`commitResolvedBatch` or
+ * `service.pushFile`/`deleteFile` is called on the wrapped instances this
+ * scenario hands out — including indirectly, e.g. via the selection
+ * stack's `actionService.push`, which calls `manager.pushFiles` through
+ * `BoundarySyncWorkspace` rather than through this class's own `push()`,
+ * and `actionService.sync`, which commits pushes/moves/deletions through
+ * `manager.commitResolvedBatch` instead of `pushFiles`. Wrapping the
+ * instances themselves (instead of only this class's wrapper methods) is
+ * what makes those indirect paths safe to cache too.
+ */
+ private readonly remoteCache = new Map();
+
+ constructor(fixture: SyncManagerFixture) {
+ this.vault = fixture.createVault();
+ this.settings = fixture.makeSettings();
+ const invalidate = (): void => this.remoteCache.clear();
+ this.manager = invalidatingProxy(fixture.newManager(this.vault, this.settings), ['pushFiles', 'pullFile', 'commitResolvedBatch'], invalidate);
+ this.service = invalidatingProxy(fixture.service, ['pushFile', 'deleteFile'], invalidate);
+ this.verifier = fixture.verifier;
+ this.branch = fixture.branch;
+ }
+
+ /** Runs `fn` once and memoizes it under `key` until the next remote mutation. */
+ private async cachedRemote(key: string, fn: () => Promise): Promise {
+ if (this.remoteCache.has(key)) return this.remoteCache.get(key) as T;
+ const value = await fn();
+ this.remoteCache.set(key, value);
+ return value;
+ }
+
+ // --- local vault ops -------------------------------------------------
+
+ writeLocal(path: string, content: string | ArrayBuffer): void {
+ this.vault.writeLocal(path, content);
+ }
+
+ deleteLocal(path: string): void {
+ this.vault.removeLocal(path);
+ }
+
+ renameLocal(oldPath: string, newPath: string): void {
+ this.vault.renameLocal(oldPath, newPath);
+ }
+
+ /** Real TFile handle for a path in this vault (needed so push rename-detection runs). */
+ tfile(path: string): TFileLike {
+ return this.vault.fileAt(path);
+ }
+
+ localExists(path: string): boolean {
+ return this.vault.has(path);
+ }
+
+ async readLocal(path: string): Promise {
+ return this.vault.adapter.read(path);
+ }
+
+ // --- remote ops (via the real production service) --------------------
+
+ /** Seeds the remote directly, bypassing SyncManager — no local file, no metadata. */
+ async seedRemote(path: string, content: string | ArrayBuffer): Promise {
+ await this.service.pushFile(path, content, this.branch, 'e2e: seed remote');
+ }
+
+ /** Overwrites the remote path with new content, reading the current sha first (like another client pushing). */
+ async modifyRemote(path: string, content: string | ArrayBuffer): Promise {
+ const current = await this.verifier.getFile(path, this.branch);
+ await this.service.pushFile(path, content, this.branch, 'e2e: modify remote', current?.sha);
+ }
+
+ async deleteRemoteFile(path: string): Promise {
+ await this.service.deleteFile(path, this.branch, 'e2e: delete remote');
+ }
+
+ // --- baseline (push through the manager to establish synced metadata) ---
+
+ /** Writes locally and pushes via the manager, establishing a synced baseline (local == remote + metadata). */
+ async baseline(path: string, content: string | ArrayBuffer): Promise {
+ this.writeLocal(path, content);
+ return this.manager.pushFiles([path]);
+ }
+
+ // --- sync actions ----------------------------------------------------
+
+ /** Pushes via the real manager. Accepts TFile handles (for rename detection) or plain paths. */
+ async push(files: (TFileLike | string)[]): Promise {
+ return this.manager.pushFiles(files as unknown as (TFile | string)[]);
+ }
+
+ async pullFile(path: string): Promise {
+ await this.manager.pullFile(path);
+ }
+
+ // --- independent remote assertions (via the git-CLI verifier) --------
+
+ async remoteContent(path: string): Promise<{ content: string; sha: string } | null> {
+ return this.cachedRemote(`file:${path}`, () => this.verifier.getFile(path, this.branch));
+ }
+
+ async expectRemoteContent(path: string, expected: string): Promise {
+ const remote = await this.cachedRemote(`file:${path}`, () => this.verifier.getFile(path, this.branch));
+ expect(remote?.content, `remote content for ${path}`).toBe(expected);
+ }
+
+ async expectRemoteMissing(path: string): Promise {
+ expect(await this.cachedRemote(`missing:${path}`, () => this.verifier.fileMissing(path, this.branch)), `expected ${path} missing on remote`).toBe(true);
+ }
+
+ async expectRemoteExists(path: string): Promise {
+ expect(await this.cachedRemote(`missing:${path}`, () => this.verifier.fileMissing(path, this.branch)), `expected ${path} present on remote`).toBe(false);
+ }
+
+ /** Current branch tip sha. */
+ async head(): Promise {
+ const [tip] = await this.cachedRemote('shas:2', () => this.verifier.listCommitShas(this.branch, 2));
+ return tip!;
+ }
+
+ /** Newest-first commit shas on the branch (independent of the service). */
+ async listCommitShas(count: number): Promise {
+ if (count <= 2) {
+ const shas = await this.cachedRemote('shas:2', () => this.verifier.listCommitShas(this.branch, 2));
+ return shas.slice(0, count);
+ }
+ return this.verifier.listCommitShas(this.branch, count);
+ }
+
+ /** Asserts exactly one new commit landed since `headBefore` (the new commit's parent is `headBefore`). */
+ async expectSingleCommitSince(headBefore: string): Promise {
+ const [headAfter, headAfterParent] = await this.cachedRemote('shas:2', () => this.verifier.listCommitShas(this.branch, 2));
+ expect(headAfter, 'expected a new commit on the branch').not.toBe(headBefore);
+ expect(headAfterParent, 'expected exactly one new commit since baseline').toBe(headBefore);
+ }
+
+ /** Asserts no new commit landed since `headBefore`. */
+ async expectNoCommitSince(headBefore: string): Promise {
+ expect(await this.head(), 'expected no new commit').toBe(headBefore);
+ }
+
+ async commitMessage(sha: string): Promise {
+ return this.verifier.getCommitMessage(sha);
+ }
+
+ // --- metadata --------------------------------------------------------
+
+ metadata(path: string) {
+ return this.settings.syncMetadata[path];
+ }
+
+ metadataSha(path: string): string | undefined {
+ return this.settings.syncMetadata[path]?.lastSyncedSha;
+ }
+
+ // --- Source Control selection stack (Phase 6) -----------------------
+
+ /**
+ * Wires the real Source Control selection layer (ChangeRepository +
+ * SyncSelectionStore + OperationState + SourceControlActionService) on top
+ * of this scenario's real SyncManager, via the thin BoundarySyncWorkspace.
+ * `push`/`pull`/`deleteRemote` go through the real manager/provider; the
+ * selection filter (ChangeId -> path -> workspace call) is the real
+ * production code under test.
+ */
+ selectionStack(changes: SyncChange[]): SelectionStack {
+ const repository = new ChangeRepository();
+ repository.replace(changes);
+ const selection = new SyncSelectionStore();
+ const operations = new OperationState();
+ const workspace = new BoundarySyncWorkspace(
+ () => this.manager,
+ {
+ refresh: (): Promise => Promise.resolve({
+ localCount: 0, remoteCount: 0, remoteEntries: [] as GitTreeEntry[],
+ }),
+ deleteRemote: (): Promise => Promise.resolve({ deletedPaths: [], errors: [] }),
+ getDiff: (): Promise => Promise.resolve({ path: '', kind: 'text' } as FileDiff),
+ },
+ );
+ const actionService = new SourceControlActionService(repository, operations, workspace);
+ return { repository, selection, operations, actionService, workspace };
+ }
+}
+
+export interface SelectionStack {
+ readonly repository: ChangeRepository;
+ readonly selection: SyncSelectionStore;
+ readonly operations: OperationState;
+ readonly actionService: SourceControlActionService;
+ readonly workspace: BoundarySyncWorkspace;
+}
+
+/**
+ * Wraps `target` so that calling any method named in `mutatingMethods` still
+ * behaves exactly as before, but also invokes `onMutation` once the call
+ * resolves. Every other property/method passes through untouched. Used to
+ * invalidate SourceControlScenario's remote-read cache on every path that
+ * can mutate the remote — including ones this file doesn't call directly
+ * (e.g. BoundarySyncWorkspace invoking `manager.pushFiles`).
+ */
+function invalidatingProxy(target: T, mutatingMethods: (keyof T)[], onMutation: () => void): T {
+ return new Proxy(target, {
+ get(obj, prop, receiver): unknown {
+ const value: unknown = Reflect.get(obj, prop, receiver);
+ if (typeof value !== 'function') return value;
+ if (!mutatingMethods.includes(prop as keyof T)) return value.bind(obj);
+ return async (...args: unknown[]) => {
+ const result: unknown = await (value as (...a: unknown[]) => unknown).apply(obj, args);
+ onMutation();
+ return result;
+ };
+ },
+ });
+}
+
+/** Builds a SyncChange with a path-derived ChangeId (mirrors FileStatusAdapter). */
+export function change(path: string, kind: SyncChange['kind'], previousPath?: string): SyncChange {
+ return { id: toChangeId(path), path, kind, previousPath };
+}
+
+export type { ConflictResolution, BatchPushConflict };
\ No newline at end of file
diff --git a/e2e-tests/provider/support/sync-manager-fixture.ts b/e2e-tests/provider/support/sync-manager-fixture.ts
new file mode 100644
index 0000000..3a416f4
--- /dev/null
+++ b/e2e-tests/provider/support/sync-manager-fixture.ts
@@ -0,0 +1,161 @@
+import { vi } from 'vitest';
+import { SyncManager } from '../../../src/logic/sync-manager';
+import type { BatchPushConflict, ConflictResolution, PushResults } from '../../../src/logic/sync/types';
+import { SyncPlanModal, type SyncPlanDirection } from '../../../src/ui/SyncPlanModal';
+import { BatchConflictResolutionModal } from '../../../src/ui/BatchConflictResolutionModal';
+import { ObsidianSyncInteraction } from '../../../src/ui/ObsidianSyncInteraction';
+// `import type` deliberately: settings.ts re-exports the settings-tab UI
+// (GitLabSyncSettingTab -> FolderSuggest -> AbstractInputSuggest) which pulls
+// in far more of `obsidian` than this suite's runtime shim provides. A
+// type-only import is erased entirely, so none of that module ever loads.
+import type { GitLabFilesPushSettings } from '../../../src/settings';
+import { TFile as ObsidianTFile } from 'obsidian';
+import { GitVerifier } from './git-verifier';
+import { FakeVault, fakeApp, type TFileCtor } from '../shim/fake-vault';
+import { currentProvider, contextFor } from '../config/env';
+import type { GitServiceInterface } from '../../../src/services/git-service-interface';
+
+const TFile: TFileCtor = ObsidianTFile;
+
+/**
+ * Reusable real-provider E2E fixture for SyncManager workflows. Owns the
+ * once-per-suite wiring the old `e2e/suites/sync-manager.e2e.test.ts` kept in
+ * its `beforeAll`: resolving the real production provider service + isolated
+ * branch, loading the git-CLI verifier + TFile shim, and installing
+ * plan-review/conflict modals that auto-confirm (so a push can proceed without
+ * a human clicking through). Per-test conflict outcomes are steered through
+ * {@link setConflictResolver}.
+ *
+ * Only the Obsidian filesystem boundary is faked
+ * (e2e-tests/provider/shim/fake-vault.ts);
+ * everything else — SyncManager, PushCoordinator, the provider service — is
+ * the real production code path against a real Git server.
+ */
+export interface SyncManagerFixture {
+ /** Real production provider service for the selected `E2E_PROVIDER`. */
+ readonly service: GitServiceInterface;
+ /** Isolated branch `scripts/e2e-harness.sh provision` created for this run. */
+ readonly branch: string;
+ /** Independent git-CLI verifier (e2e-tests/provider/support/git-verifier.ts). */
+ readonly verifier: GitVerifier;
+ /** The exact TFile class the vitest-runtime `obsidian` alias resolves to. */
+ readonly TFile: TFileCtor;
+ /** Per-suite run id, so every test's remote paths are namespaced apart. */
+ readonly runId: string;
+ /** Namespaced remote path: `path('note.md') -> e2e-sc-/note.md`. */
+ path(name: string): string;
+ /** Fresh settings object pointing at the isolated branch, empty metadata. */
+ makeSettings(branch?: string): GitLabFilesPushSettings;
+ /** A fresh in-memory vault (the only faked boundary). */
+ createVault(): FakeVault;
+ /** A real SyncManager wired to `vault` + `settings` + the real service. */
+ newManager(vault: FakeVault, settings: GitLabFilesPushSettings): SyncManager;
+ /** Steers how the auto-confirming conflict modal resolves each conflict. */
+ setConflictResolver(resolver: (conflict: BatchPushConflict) => ConflictResolution): void;
+ /** Reads the currently installed conflict resolver (for multi-client scenario wiring). */
+ conflictResolver(): (conflict: BatchPushConflict) => ConflictResolution;
+}
+
+export interface SyncManagerFixtureOptions {
+ /**
+ * Scopes both the service's remote-tree listing (`rootPath`) and local
+ * vault discovery (`vaultFolder`) to this fixture's own `e2e-tc-`
+ * namespace, via the real production rootPath/vaultFolder model. Needed
+ * by multi-client suites where several independent fixtures/clients share
+ * one branch and must never see each other's remote files — unscoped
+ * (the default) is fine for single-fixture suites, where extra remote
+ * entries from other suites are harmless (they never match a local file).
+ */
+ readonly scoped?: boolean;
+}
+
+export async function createSyncManagerFixture(options: SyncManagerFixtureOptions = {}): Promise {
+ const provider = currentProvider();
+
+ // Test-only namespace disambiguator (avoids path collisions between
+ // concurrent e2e runs against the same shared remote) — no security
+ // context, so a non-cryptographic PRNG is intentional here.
+ const runId = Math.random().toString(36).slice(2, 10); // NOSONAR typescript:S2245
+ const scopePath = options.scoped ? `e2e-tc-${runId}` : '';
+
+ const ctx = contextFor(provider, scopePath);
+ const service = ctx.service;
+ const branch = ctx.branch;
+
+ const verifier: GitVerifier = new GitVerifier();
+
+ let conflictResolver: (conflict: BatchPushConflict) => ConflictResolution = () => 'skip';
+
+ // Auto-confirm the plan-review modal (production shows it before every
+ // push/pull). Same pattern as tests/logic/sync-manager-batch.test.ts.
+ vi.mocked(SyncPlanModal).mockImplementation(function (
+ this: SyncPlanModal, _app: unknown, _plan: unknown, _direction: SyncPlanDirection, onConfirm: () => void
+ ) {
+ onConfirm();
+ return this;
+ });
+
+ // Every push-side content conflict goes through BatchConflictResolutionModal
+ // (even a single-file batch). Auto-resolve using the current resolver.
+ vi.mocked(BatchConflictResolutionModal).mockImplementation(function (
+ this: BatchConflictResolutionModal,
+ _app: unknown,
+ conflicts: BatchPushConflict[],
+ _safeCount: number,
+ onResolve: () => void,
+ _onCancel: () => void,
+ ) {
+ for (const conflict of conflicts) conflict.resolution = conflictResolver(conflict);
+ onResolve();
+ return this;
+ });
+
+ function path(name: string): string {
+ return scopePath ? `${scopePath}/${name}` : `e2e-sc-${runId}/${name}`;
+ }
+
+ function makeSettings(branchOverride?: string): GitLabFilesPushSettings {
+ return {
+ serviceType: 'gitea',
+ gitlabToken: '', gitlabBaseUrl: '', projectId: '',
+ githubToken: '', githubOwner: '', githubRepo: '',
+ giteaToken: '', giteaBaseUrl: '', giteaOwner: '', giteaRepo: '',
+ branch: branchOverride ?? branch,
+ syncMetadata: {},
+ rootPath: scopePath,
+ vaultFolder: scopePath,
+ symlinkHandling: 'skip',
+ ignorePatterns: '',
+ lastSeenVersion: '',
+ bannerDismissedVersion: '',
+ language: 'system',
+ autoRefreshOnStartup: true,
+ };
+ }
+
+ function createVault(): FakeVault {
+ return new FakeVault(TFile);
+ }
+
+ function newManager(vault: FakeVault, settings: GitLabFilesPushSettings): SyncManager {
+ const app = fakeApp(vault);
+ return new SyncManager(app, service, settings, undefined, () => false, undefined, new ObsidianSyncInteraction(app));
+ }
+
+ return {
+ service,
+ branch,
+ verifier,
+ TFile,
+ runId,
+ path,
+ makeSettings,
+ createVault,
+ newManager,
+ setConflictResolver: (resolver) => { conflictResolver = resolver; },
+ conflictResolver: () => conflictResolver,
+ };
+}
+
+export { describePushResult } from './push-result-diagnostic';
+export type { PushResults };
diff --git a/e2e-tests/provider/support/timing-diagnostics.ts b/e2e-tests/provider/support/timing-diagnostics.ts
new file mode 100644
index 0000000..b92ac9d
--- /dev/null
+++ b/e2e-tests/provider/support/timing-diagnostics.ts
@@ -0,0 +1,17 @@
+/**
+ * Opt-in duration logging for the two-client E2E suite. Silent by default —
+ * set `E2E_TIMING_DEBUG=1` to see where a slow run's time actually goes
+ * (tree listing vs refresh vs push vs pull vs verifier), instead of only
+ * knowing a whole test approached the timeout.
+ */
+const enabled = process.env.E2E_TIMING_DEBUG === '1';
+
+export async function timed(label: string, fn: () => Promise): Promise {
+ if (!enabled) return fn();
+ const start = Date.now();
+ try {
+ return await fn();
+ } finally {
+ console.log(`[e2e-timing] ${label}: ${Date.now() - start}ms`);
+ }
+}
diff --git a/e2e-tests/provider/support/two-client-sync-scenario.ts b/e2e-tests/provider/support/two-client-sync-scenario.ts
new file mode 100644
index 0000000..d74a545
--- /dev/null
+++ b/e2e-tests/provider/support/two-client-sync-scenario.ts
@@ -0,0 +1,316 @@
+import { expect } from 'vitest';
+import type { TFile } from 'obsidian';
+import type { GitServiceInterface } from '../../../src/services/git-service-interface';
+import type { GitLabFilesPushSettings } from '../../../src/settings';
+import type { BatchPushConflict, ConflictResolution, PushResults } from '../../../src/logic/sync/types';
+import type { SyncManager } from '../../../src/logic/sync-manager';
+import type { FileStatus } from '../../../src/logic/sync-status-service';
+import type { GitVerifier } from './git-verifier';
+import type { FakeVault, TFileLike, TFileCtor } from '../shim/fake-vault';
+import { fakeApp } from '../shim/fake-vault';
+import { SyncStatusRefreshService } from '../../../src/logic/sync/SyncStatusRefreshService';
+import { SyncStatusService } from '../../../src/logic/sync-status-service';
+import { GitignoreManager } from '../../../src/logic/gitignore-manager';
+import { ensureSyncWorkspaceRuntime } from '../../../src/logic/sync/SyncWorkspace';
+import { ChangeRepository } from '../../../src/logic/source-control/ChangeRepository';
+import { OperationState } from '../../../src/logic/source-control/OperationState';
+import { SourceControlActionService } from '../../../src/logic/source-control/SourceControlActionService';
+import { toSyncChanges } from '../../../src/logic/source-control/FileStatusAdapter';
+import {
+ filterFilesByVaultFolder,
+ filterPathByVaultFolder,
+ getNormalizedVaultPath,
+ getVaultPathFromNormalized,
+} from '../../../src/logic/sync/vault-folder-scope';
+import { timed } from './timing-diagnostics';
+
+/**
+ * The provider-level fixtures the two-client scenario shares across clients.
+ * `newVault`/`newSettings`/`newManager` return FRESH instances per call — two
+ * clients must never share a vault, a settings object, or a SyncManager.
+ */
+export interface TwoClientFixture {
+ readonly service: GitServiceInterface;
+ readonly branch: string;
+ readonly verifier: GitVerifier;
+ readonly TFile: TFileCtor;
+ /** Namespaced run id, so each test's remote paths stay apart. */
+ readonly runId: string;
+ newVault(): FakeVault;
+ newSettings(): GitLabFilesPushSettings;
+ newManager(vault: FakeVault, settings: GitLabFilesPushSettings): SyncManager;
+ /** Currently installed conflict-modal resolver (steered per test via setConflictResolver). */
+ conflictResolver(): (conflict: BatchPushConflict) => ConflictResolution;
+}
+
+/**
+ * One client's full stack: its own FakeVault + its own settings (and therefore
+ * its own `syncMetadata` baseline store) + its own real SyncManager + the real
+ * Source Control refresh/status/action layer. `sync()` reproduces the
+ * production Sync Queue path end to end: refresh (status projection from live
+ * local + remote state) -> toSyncChanges -> SourceControlActionService.sync
+ * (one merged Sync Plan: pushes/moves/deletions committed together as one
+ * remote mutation set, remote-only changes pulled locally with zero commits).
+ * The multi-client loop therefore exercises the same planner + coordinators
+ * desktop/mobile actually run.
+ */
+export class TwoClient {
+ readonly vault: FakeVault;
+ readonly settings: GitLabFilesPushSettings;
+ readonly manager: SyncManager;
+ /**
+ * The SAME `SyncStatusService` instance `manager` pushes/pulls through
+ * (mirroring `main.ts`'s `this.sync.status` wiring), not a separate one —
+ * a push's `SyncMetadataStore.update` calls `status.markSynced(path, sha)`
+ * on the manager's own instance, so a status map built from a different
+ * instance would never see a row flip to `synced` after its own push.
+ */
+ private readonly statuses: SyncStatusService;
+ private readonly repository = new ChangeRepository();
+ private readonly operations = new OperationState();
+ private readonly refreshService: SyncStatusRefreshService;
+ private readonly actionService: SourceControlActionService;
+
+ constructor(
+ readonly name: 'A' | 'B',
+ private readonly fixture: TwoClientFixture,
+ ) {
+ this.vault = fixture.newVault();
+ this.settings = fixture.newSettings();
+ const app = fakeApp(this.vault);
+ this.manager = fixture.newManager(this.vault, this.settings);
+ this.statuses = this.manager.status;
+ const gitignoreManager = new GitignoreManager(
+ app, fixture.service, this.settings.branch, this.settings.rootPath, this.settings.vaultFolder, this.settings.ignorePatterns,
+ );
+ const refreshService = new SyncStatusRefreshService(
+ {
+ app,
+ settings: () => this.settings,
+ gitService: () => fixture.service,
+ gitignoreManager: () => gitignoreManager,
+ syncManager: () => this.manager,
+ // Real production vaultFolder scoping (shared with src/main.ts
+ // and SyncScanner via src/logic/sync/vault-folder-scope) —
+ // this fixture's settings set vaultFolder to this run's own
+ // `e2e-tc-` namespace, so this scopes local discovery
+ // to this client's own files exactly like a real vault
+ // subfolder mount would.
+ filterFilesByVaultFolder: files => filterFilesByVaultFolder(files, this.settings.vaultFolder),
+ filterPathByVaultFolder: path => filterPathByVaultFolder(path, this.settings.vaultFolder),
+ getNormalizedPath: path => getNormalizedVaultPath(path, this.settings.vaultFolder),
+ getVaultPath: normalizedPath => getVaultPathFromNormalized(normalizedPath, this.settings.vaultFolder),
+ },
+ this.statuses,
+ );
+ this.refreshService = refreshService;
+ const { workspace } = ensureSyncWorkspaceRuntime(app, {
+ settings: this.settings,
+ gitService: fixture.service,
+ sync: this.manager,
+ getNormalizedPath: path => path,
+ }, this.statuses);
+ this.actionService = new SourceControlActionService(this.repository, this.operations, workspace);
+ }
+
+ // --- local vault ops --------------------------------------------------
+
+ write(path: string, content: string): void {
+ this.vault.writeLocal(path, content);
+ }
+
+ delete(path: string): void {
+ this.vault.removeLocal(path);
+ }
+
+ rename(oldPath: string, newPath: string): void {
+ this.vault.renameLocal(oldPath, newPath);
+ }
+
+ async read(path: string): Promise {
+ return this.vault.adapter.read(path);
+ }
+
+ exists(path: string): boolean {
+ return this.vault.has(path);
+ }
+
+ metadata(path: string): GitLabFilesPushSettings['syncMetadata'][string] | undefined {
+ return this.settings.syncMetadata[path];
+ }
+
+ metadataSha(path: string): string | undefined {
+ return this.settings.syncMetadata[path]?.lastSyncedSha;
+ }
+
+ /** A real TFile handle, needed for rename detection on push. */
+ tfile(path: string): TFileLike {
+ return this.vault.fileAt(path);
+ }
+
+ // --- status projection ------------------------------------------------
+
+ /** Runs the real Source Control refresh: live local scan + remote tree + per-file classification. */
+ async refresh(): Promise {
+ await timed(`refresh ${this.name}`, () => this.refreshService.refresh());
+ this.repository.replace(toSyncChanges([...this.statuses.values()]));
+ this.assertScopeIsolation();
+ }
+
+ /**
+ * Fail-fast guard: every change refresh() surfaces must belong to this
+ * run's own `e2e-tc-` namespace. If fixture/rootPath scoping ever
+ * regresses, this throws immediately instead of the suite timing out
+ * (or, worse, silently asserting on another suite's leaked remote files).
+ */
+ private assertScopeIsolation(): void {
+ const prefix = `e2e-tc-${this.fixture.runId}/`;
+ for (const change of this.repository.getAll()) {
+ expect(
+ change.path.startsWith(prefix),
+ `client ${this.name} refresh() surfaced an out-of-scope change: ${change.path} (expected prefix ${prefix})`,
+ ).toBe(true);
+ }
+ }
+
+ /** Status rows from the last refresh — the "Repository Changes" view model. */
+ statusesNow(): FileStatus[] {
+ return [...this.statuses.values()];
+ }
+
+ // --- sync actions ------------------------------------------------------
+
+ /**
+ * The production Sync Queue path on every non-`checking` change, exactly
+ * what the Sync button does after a refresh: one merged Sync Plan per
+ * direction split, one confirm, one commit for the remote mutation set,
+ * pulls applied locally. Conflict modals resolve via the fixture's
+ * resolver (auto-confirmed like the other e2e suites).
+ */
+ async sync(): Promise {
+ await this.refresh();
+ const changeIds = this.repository.getAll().map(change => change.id);
+ await timed(`sync ${this.name}`, () => this.actionService.sync(changeIds));
+ }
+
+ /** Push-only path (the per-row Sync/Push on one or more changes). */
+ async push(paths: string[]): Promise {
+ await this.refresh();
+ await this.actionService.push(this.changesFor(paths));
+ }
+
+ /** Pull-only path. */
+ async pull(paths: string[]): Promise {
+ await this.refresh();
+ await this.actionService.pull(this.changesFor(paths));
+ }
+
+ private changesFor(paths: string[]) {
+ const ids = [];
+ for (const path of paths) {
+ const change = this.repository.getByPath(path);
+ if (change) ids.push(change.id);
+ }
+ return ids;
+ }
+
+ /** Direct pushFiles passthrough for tests that need the raw PushResults (most sync tests use sync()). */
+ pushFiles(files: (TFileLike | string)[]): Promise {
+ return this.manager.pushFiles(files as unknown as (TFile | string)[]);
+ }
+
+ /** Sets a pending rename (production rename-event path; a no-op when the old path was never synced). */
+ async trackRename(newPath: string, oldPath: string): Promise {
+ await this.manager.trackRename(newPath, oldPath);
+ }
+}
+
+/**
+ * The two-client scenario: one shared real provider service + isolated branch
+ * + independent git-CLI verifier, and exactly two fully independent clients
+ * (A and B) wired on top of it. The only faked boundary remains the Obsidian
+ * vault; SyncManager, planners, coordinators, refresh, the Source Control
+ * action layer, and the provider service are all real production code against
+ * a real Git server. Remote assertions always go through the verifier, never
+ * the service under test.
+ */
+export class TwoClientSyncScenario {
+ readonly a: TwoClient;
+ readonly b: TwoClient;
+ /** The isolated branch both clients sync against. */
+ readonly branch: string;
+
+ private constructor(private readonly fixture: TwoClientFixture) {
+ this.branch = fixture.branch;
+ this.a = new TwoClient('A', fixture);
+ this.b = new TwoClient('B', fixture);
+ }
+
+ /** Assembles the scenario from the suites' `beforeAll` SyncManagerFixture. */
+ static from(fixture: TwoClientFixture): TwoClientSyncScenario {
+ return new TwoClientSyncScenario(fixture);
+ }
+
+ /** Remote path namespaced to this run (same shape the single-client suites use). */
+ path(name: string): string {
+ return `e2e-tc-${this.fixture.runId}/${name}`;
+ }
+
+ /**
+ * Establishes a common synced baseline both clients agree on: write + push
+ * through client A's real manager (A's metadata baseline is set by real
+ * code), then mirror the identical content into B's vault and seed the
+ * metadata baseline B would have recorded had it pushed the identical blob
+ * (deterministic: same content => same blob sha). Models "phone and
+ * desktop already have this file in sync" without two round trips of
+ * identical pushes.
+ */
+ async baseline(path: string, content: string): Promise {
+ this.a.write(path, content);
+ const result = await timed('baseline', () => this.a.manager.pushFiles([path]));
+ expect(result.success, `baseline push of ${path} failed: ${JSON.stringify(result.errors)}`).toBe(1);
+ const pushedSha = result.syncedPaths.find(entry => entry.path === path)?.sha;
+ if (!pushedSha) throw new Error(`baseline push of ${path} did not report a sha`);
+ const remote = await this.verifier.getFile(path, this.branch);
+ expect(remote?.sha, 'verifier blob sha must match the pushed blob sha for baseline mirroring').toBe(pushedSha);
+ this.b.vault.writeLocal(path, content);
+ this.b.settings.syncMetadata[path] = {
+ lastSyncedSha: pushedSha,
+ lastSyncedAt: Date.now(),
+ lastKnownPath: path,
+ };
+ }
+
+ // --- independent remote assertions (via the git-CLI verifier) ----------
+
+ async remoteContent(path: string): Promise<{ content: string; sha: string } | null> {
+ return this.verifier.getFile(path, this.branch);
+ }
+
+ async remoteExists(path: string): Promise {
+ return !(await this.verifier.fileMissing(path, this.branch));
+ }
+
+ async expectRemoteContent(path: string, expected: string): Promise {
+ const remote = await this.verifier.getFile(path, this.branch);
+ expect(remote?.content, `remote content for ${path}`).toBe(expected);
+ }
+
+ async expectRemoteMissing(path: string): Promise {
+ expect(await this.verifier.fileMissing(path, this.branch), `expected ${path} missing on remote`).toBe(true);
+ }
+
+ /** Newest-first commit shas on the isolated branch (independent of the service). */
+ listCommitShas(count: number): Promise {
+ return this.verifier.listCommitShas(this.branch, count);
+ }
+
+ async head(): Promise {
+ const [tip] = await this.verifier.listCommitShas(this.branch, 1);
+ return tip!;
+ }
+
+ private get verifier(): GitVerifier {
+ return this.fixture.verifier;
+ }
+}
\ No newline at end of file
diff --git a/e2e/verifier-runtime-types.ts b/e2e/verifier-runtime-types.ts
deleted file mode 100644
index cd4d8f6..0000000
--- a/e2e/verifier-runtime-types.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-/**
- * Type-only contract for the git-CLI-backed verifier `scripts/e2e-harness.sh
- * provision` generates at `${E2E_RUNTIME_DIR}/verifier/git-verifier.ts`
- * (never committed — see docs/testing/real-provider-e2e.md). Suites import
- * only this type statically and load the concrete implementation via a
- * runtime-computed dynamic `import()`, so `npm run build`'s typecheck never
- * needs the generated file to exist on disk.
- *
- * A suite must never call `service.getFile()` to confirm `service.pushFile()`
- * worked — that only proves the service agrees with itself, not that the
- * remote actually changed. Every remote assertion in an E2E suite goes
- * through one of these methods instead.
- */
-export interface GitVerifier {
- /** Fetches raw file content + blob sha directly via `git show`/`git rev-parse`. */
- getFile(path: string, ref: string): Promise<{ content: string; sha: string } | null>;
-
- /** Lists all file paths present at `ref`, for verifying batch pushes/renames. */
- listFiles(ref: string): Promise;
-
- /** True if `path` does not exist at `ref` (used to verify deletes/renames-away). */
- fileMissing(path: string, ref: string): Promise;
-
- /** Commit shas on `ref`, newest first. */
- listCommitShas(ref: string, perPage?: number): Promise;
-
- /** Git tree entry mode at `path` (e.g. "120000" for a symlink). */
- getBlobMode(path: string, ref: string): Promise;
-
- /** Commit message at a given sha. */
- getCommitMessage(sha: string): Promise;
-
- /** Last commit sha that touched `path` on `ref` — GitLab's optimistic-locking "revision". */
- getRevision(path: string, ref: string): Promise;
-}
diff --git a/eslint.config.mts b/eslint.config.mts
index 19f9c7c..f90dfb0 100644
--- a/eslint.config.mts
+++ b/eslint.config.mts
@@ -13,8 +13,9 @@ export default tseslint.config(
parserOptions: {
projectService: {
allowDefaultProject: [
- 'eslint.config.js',
- 'manifest.json'
+ 'eslint.config.mts',
+ 'manifest.json',
+ 'scripts/typecheck-compat.mjs'
]
},
tsconfigRootDir: import.meta.dirname,
@@ -23,6 +24,32 @@ export default tseslint.config(
},
},
...obsidianmd.configs.recommended,
+ {
+ // Architecture regression guard: the legacy sync-status presentation
+ // layer was removed when Source Control became the single view (PR
+ // #129). `SyncStatusService`/`SyncStatusRefreshService` (domain state)
+ // remain, but nothing may import the deleted `ui/sync-status` modules
+ // or resurrect them — the directory no longer exists on disk, and this
+ // rule keeps any future file of the same name from being re-wired in.
+ files: ["src/**/*.ts", "src/**/*.tsx"],
+ rules: {
+ "no-restricted-imports": [
+ "error",
+ {
+ patterns: [
+ {
+ group: ["**/ui/sync-status", "**/ui/sync-status/*", "./sync-status", "./sync-status/*"],
+ message: "The legacy sync-status presentation layer was removed; use ui/source-control instead.",
+ },
+ {
+ group: ["**/SyncStatusView", "**/ui/SyncStatusView"],
+ message: "The legacy SyncStatusView was replaced by SourceControlItemView (ui/source-control).",
+ },
+ ],
+ },
+ ],
+ },
+ },
{
files: ["src/**/*.ts", "src/**/*.tsx"],
...sonarjs.configs.recommended,
@@ -38,20 +65,92 @@ export default tseslint.config(
rules: {
"import/no-nodejs-modules": "off",
"no-restricted-globals": "off",
+ "obsidianmd/rule-custom-message": "off",
+ },
+ },
+ {
+ // These two suites deliberately exercise the pre-1.13 `display()`
+ // imperative-render fallback (see PluginSettingTab.display() in
+ // obsidian.d.ts) for back-compat coverage, so calling it is the point
+ // of the test, not something to migrate away from.
+ files: [
+ "tests/ui/SettingsConnectionStatus.test.ts",
+ "tests/ui/SettingsObsidian113Compatibility.test.ts",
+ ],
+ rules: {
+ "@typescript-eslint/no-deprecated": "off",
+ },
+ },
+ {
+ // CI contract suite asserts against the *committed* workflow/harness
+ // files, so reading them from disk with node:fs is the point of the
+ // test — same local-Node-tooling rationale as scripts/ above.
+ files: ["tests/ci-workflow.test.ts"],
+ rules: {
+ "import/no-nodejs-modules": "off",
+ "obsidianmd/no-nodejs-modules": "off",
},
},
{
- // E2E harness glue runs under Node (vitest, `environment: 'node'`), not
- // Obsidian's Electron renderer — needs `process`, same as scripts/. Unlike
- // scripts/, it deliberately keeps fetch/globalThis/node:* built-ins out
- // (see docs/testing/real-provider-e2e.md), so it does NOT get the same
- // import/no-nodejs-modules / no-restricted-globals exemptions.
- files: ["e2e/**/*.ts", "vitest.e2e.config.ts"],
+ // These suites mock the Obsidian *plugin host environment* under Node
+ // (vitest), not Obsidian's Electron renderer — the obsidianmd popout-
+ // compatibility rules (window.createEl, activeWindow, window timers)
+ // assume plugin runtime code, but here document/window/globalThis
+ // shim globals that don't exist in a bare Node test process, and
+ // createEl/createDiv/createSpan don't exist until the mock defines
+ // them later in the file. Following the suggested rewrites (e.g.
+ // swapping globalThis for window) breaks the tests.
+ files: ["tests/setup.ts", "tests/ui/setup-dom.ts"],
+ rules: {
+ "obsidianmd/no-global-this": "off",
+ "obsidianmd/prefer-create-el": "off",
+ "obsidianmd/prefer-window-timers": "off",
+ },
+ },
+ {
+ // Local Node build tooling invoked by npm scripts, not plugin code;
+ // typecheck-compat deliberately shells out to tsc. Reported as
+ // warning-level by obsidianmd/no-nodejs-modules.
+ files: ["scripts/typecheck-compat.mjs"],
+ rules: {
+ "obsidianmd/no-nodejs-modules": "off",
+ },
+ },
+ {
+ // tseslint.config() is deprecated in favor of ESLint core
+ // defineConfig() in typescript-eslint 8.68+; migrating is a separate
+ // config refactor, not a code-quality issue.
+ files: ["eslint.config.mts"],
+ rules: {
+ "@typescript-eslint/no-deprecated": "off",
+ },
+ },
+ {
+ // e2e-tests/** is Node test tooling (vitest, `environment: 'node'`) that
+ // drives real GitHub/GitLab/Gitea sandboxes via the production provider
+ // code path — not shipping Obsidian plugin runtime, so it gets the same
+ // Node-tooling exemptions as scripts/ below. The real `requestUrl` shim
+ // and the git-CLI verifier genuinely need fetch/node:child_process.
+ // NOTE: an earlier committed-`.ts` version of this harness (see
+ // docs/obsidian-scanner-audit.md) was flagged by the Obsidian
+ // community-plugin scanner for these same APIs; that audit's own
+ // finding was that the scanner's grep is not scoped to what ships in
+ // main.js. Committing them again here under a new directory name is
+ // unverified against the actual scanner until it's re-run — see the
+ // "Known gaps" note this PR adds to docs/testing/real-provider-e2e.md.
+ files: ["e2e-tests/**/*.ts", "vitest.e2e.config.ts"],
languageOptions: {
globals: {
...globals.node,
},
},
+ rules: {
+ "import/no-nodejs-modules": "off",
+ "no-restricted-globals": "off",
+ "obsidianmd/rule-custom-message": "off",
+ "obsidianmd/no-nodejs-modules": "off",
+ "obsidianmd/no-global-this": "off",
+ },
},
globalIgnores([
"node_modules",
diff --git a/feature_list.json b/feature_list.json
index 74d6b91..51e8783 100644
--- a/feature_list.json
+++ b/feature_list.json
@@ -1,14 +1,14 @@
{
"_note": "GitHub Issues (firstsun-dev/git-files-sync, Project #6) is the source of truth for the full backlog and priority/estimate fields. This file mirrors only the active feature and the next few candidates so an agent session has a local, offline checkpoint — sync it against `gh issue list --repo firstsun-dev/git-files-sync --state open` at the start of a session rather than treating it as authoritative.",
- "_lastSync": "2026-08-19: Synced against open GitHub issues; issue #105 is the active architecture refactor.",
+ "_lastSync": "2026-08-27: Synced against open GitHub issues; issue #139 is active on PR #140.",
"features": [
{
- "id": "feat-026",
- "name": "refactor(sync): separate planning, execution, conflicts, metadata, and UI (issue #105)",
- "description": "Preserve sync behavior while extracting SyncStatusView presentation state/controller boundaries and SyncManager scanner/planner/executor/workspace boundaries with regression and integration coverage.",
+ "id": "feat-027",
+ "name": "test(e2e): run disposable Gitea safely in local and CI environments (issue #139)",
+ "description": "Keep local Gitea runs parallel-safe, move the secretless Gitea PR gate to GitHub-hosted runners, and prevent fork code from reaching credentialed self-hosted runners.",
"dependencies": [],
"status": "in-progress",
- "evidence": "Commits dff95db/948df28 on refactor/sync-domain-pipeline: unified sync decisions and CI hardening are covered; 613 tests, local Gitea E2E, and real CI run 32338116598 are green; desktop/mobile smoke pending."
+ "evidence": "Commits 920adee/18de6e0/b5884fc on PR #140: local serial/parallel Gitea and targeted real CI run 33048613679 are green; awaiting review/merge."
},
{
"id": "feat-004",
diff --git a/fix_order.mjs b/fix_order.mjs
deleted file mode 100644
index 84c6a18..0000000
--- a/fix_order.mjs
+++ /dev/null
@@ -1,29 +0,0 @@
-import fs from 'fs';
-
-const pluginEntry = {
- "id": "git-file-sync",
- "name": "Git File Sync",
- "author": "firstsun-dev",
- "description": "Selectively sync individual notes with GitLab or GitHub. Push, pull, diff, and resolve conflicts — file by file, on mobile and desktop.",
- "repo": "firstsun-dev/git-files-push"
-};
-
-const repoDir = '/home/tianyao/obsidian-releases';
-const filePath = `${repoDir}/community-plugins.json`;
-
-try {
- const data = fs.readFileSync(filePath, 'utf8');
- const plugins = JSON.parse(data);
-
- // Remove if it exists
- const filteredPlugins = plugins.filter(p => p.id !== pluginEntry.id);
-
- // ADD TO THE VERY END
- filteredPlugins.push(pluginEntry);
-
- fs.writeFileSync(filePath, JSON.stringify(filteredPlugins, null, 2) + '\n', 'utf8');
-
- console.log('Successfully added plugin to the END of community-plugins.json');
-} catch (error) {
- console.error('Error:', error.message);
-}
diff --git a/imgs/sync-status.jpg b/imgs/sync-status.jpg
new file mode 100644
index 0000000..c202b15
Binary files /dev/null and b/imgs/sync-status.jpg differ
diff --git a/package-lock.json b/package-lock.json
index bcbcfe4..92e0efe 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,15 +1,15 @@
{
"name": "git-file-sync",
- "version": "1.5.7",
+ "version": "1.5.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "git-file-sync",
- "version": "1.5.7",
+ "version": "1.5.9",
"license": "MIT",
"dependencies": {
- "ignore": "^7.0.5",
+ "ignore": "^7.0.6",
"obsidian": "^1.13.1"
},
"devDependencies": {
@@ -21,21 +21,21 @@
"@semantic-release/github": "^12.0.9",
"@types/jsdom": "^28.0.3",
"@types/node": "^24.0.0",
- "@vitest/coverage-v8": "^4.1.9",
- "@vitest/ui": "^4.1.9",
+ "@vitest/coverage-v8": "^4.1.11",
+ "@vitest/ui": "^4.1.11",
"conventional-changelog-conventionalcommits": "^9.3.1",
"esbuild": "0.28.1",
- "eslint-plugin-obsidianmd": "0.1.9",
- "eslint-plugin-sonarjs": "^4.0.3",
+ "eslint-plugin-obsidianmd": "^0.4.2",
+ "eslint-plugin-sonarjs": "^4.2.0",
"globals": "14.0.0",
"husky": "^9.1.7",
"jiti": "2.6.1",
"jsdom": "^29.1.1",
- "semantic-release": "^25.0.5",
+ "semantic-release": "^25.0.9",
"tslib": "2.4.0",
- "typescript": "^5.8.3",
- "typescript-eslint": "8.35.1",
- "vitest": "^4.1.9"
+ "typescript": "~5.9.3",
+ "typescript-eslint": "^8.68.0",
+ "vitest": "^4.1.11"
}
},
"node_modules/@actions/core": {
@@ -407,40 +407,6 @@
"node": ">=20.19.0"
}
},
- "node_modules/@emnapi/core": {
- "version": "1.11.1",
- "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
- "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@emnapi/wasi-threads": "1.2.2",
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@emnapi/runtime": {
- "version": "1.11.1",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
- "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@emnapi/wasi-threads": {
- "version": "1.2.2",
- "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
- "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
@@ -883,6 +849,26 @@
"node": ">=18"
}
},
+ "node_modules/@eslint-community/eslint-plugin-eslint-comments": {
+ "version": "4.7.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-plugin-eslint-comments/-/eslint-plugin-eslint-comments-4.7.2.tgz",
+ "integrity": "sha512-LF03qURSwEWm2dz5wtdDCzNk+7Opl0X7q6I3undsaIuNsEiNvRV3BCtqu14Q/6Pzg1tBj44LcxpW2EpSLZStZw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "escape-string-regexp": "^4.0.0",
+ "ignore": "^7.0.5"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0"
+ }
+ },
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
@@ -1220,63 +1206,6 @@
"ret": "~0.1.10"
}
},
- "node_modules/@napi-rs/wasm-runtime": {
- "version": "1.1.6",
- "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
- "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@tybys/wasm-util": "^0.10.3"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/Brooooooklyn"
- },
- "peerDependencies": {
- "@emnapi/core": "^1.7.1",
- "@emnapi/runtime": "^1.7.1"
- }
- },
- "node_modules/@nodelib/fs.scandir": {
- "version": "2.1.5",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
- "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nodelib/fs.stat": "2.0.5",
- "run-parallel": "^1.1.9"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/@nodelib/fs.stat": {
- "version": "2.0.5",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
- "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/@nodelib/fs.walk": {
- "version": "1.2.8",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
- "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nodelib/fs.scandir": "2.1.5",
- "fastq": "^1.6.0"
- },
- "engines": {
- "node": ">= 8"
- }
- },
"node_modules/@octokit/auth-token": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz",
@@ -1435,9 +1364,9 @@
}
},
"node_modules/@oxc-project/types": {
- "version": "0.138.0",
- "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz",
- "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==",
+ "version": "0.147.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz",
+ "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==",
"dev": true,
"license": "MIT",
"funding": {
@@ -1509,10 +1438,27 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@rolldown/binding-android-arm-eabi": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz",
+ "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
"node_modules/@rolldown/binding-android-arm64": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
- "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==",
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz",
+ "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==",
"cpu": [
"arm64"
],
@@ -1527,9 +1473,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz",
- "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==",
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz",
+ "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==",
"cpu": [
"arm64"
],
@@ -1544,9 +1490,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz",
- "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==",
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz",
+ "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==",
"cpu": [
"x64"
],
@@ -1561,9 +1507,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz",
- "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==",
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz",
+ "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==",
"cpu": [
"x64"
],
@@ -1578,9 +1524,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz",
- "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==",
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz",
+ "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==",
"cpu": [
"arm"
],
@@ -1595,9 +1541,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz",
- "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==",
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz",
+ "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==",
"cpu": [
"arm64"
],
@@ -1612,9 +1558,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz",
- "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==",
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz",
+ "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==",
"cpu": [
"arm64"
],
@@ -1629,9 +1575,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz",
- "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==",
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz",
+ "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==",
"cpu": [
"ppc64"
],
@@ -1646,9 +1592,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz",
- "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==",
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz",
+ "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==",
"cpu": [
"s390x"
],
@@ -1663,9 +1609,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz",
- "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==",
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz",
+ "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==",
"cpu": [
"x64"
],
@@ -1680,9 +1626,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz",
- "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==",
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz",
+ "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==",
"cpu": [
"x64"
],
@@ -1697,9 +1643,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz",
- "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==",
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz",
+ "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==",
"cpu": [
"arm64"
],
@@ -1713,29 +1659,10 @@
"node": "^20.19.0 || >=22.12.0"
}
},
- "node_modules/@rolldown/binding-wasm32-wasi": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz",
- "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==",
- "cpu": [
- "wasm32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@emnapi/core": "1.11.1",
- "@emnapi/runtime": "1.11.1",
- "@napi-rs/wasm-runtime": "^1.1.6"
- },
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
"node_modules/@rolldown/binding-win32-arm64-msvc": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz",
- "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==",
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz",
+ "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==",
"cpu": [
"arm64"
],
@@ -1750,9 +1677,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz",
- "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==",
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz",
+ "integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==",
"cpu": [
"x64"
],
@@ -2338,17 +2265,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@tybys/wasm-util": {
- "version": "0.10.3",
- "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
- "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -2377,9 +2293,9 @@
"license": "MIT"
},
"node_modules/@types/eslint": {
- "version": "8.56.2",
- "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.2.tgz",
- "integrity": "sha512-uQDwm1wFHmbBbCZCqAlq6Do9LYwByNZHWzXppSnay9SuwJ+VRbjkbLABer54kcPnMSlG6Fdiy2yaFXm/z9Z5gw==",
+ "version": "9.6.1",
+ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
+ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2461,21 +2377,20 @@
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
- "version": "8.35.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.1.tgz",
- "integrity": "sha512-9XNTlo7P7RJxbVeICaIIIEipqxLKguyh+3UbXuT2XQuFp6d8VOeDEGuz5IiX0dgZo8CiI6aOFLg4e8cF71SFVg==",
+ "version": "8.68.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz",
+ "integrity": "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@eslint-community/regexpp": "^4.10.0",
- "@typescript-eslint/scope-manager": "8.35.1",
- "@typescript-eslint/type-utils": "8.35.1",
- "@typescript-eslint/utils": "8.35.1",
- "@typescript-eslint/visitor-keys": "8.35.1",
- "graphemer": "^1.4.0",
- "ignore": "^7.0.0",
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.68.0",
+ "@typescript-eslint/type-utils": "8.68.0",
+ "@typescript-eslint/utils": "8.68.0",
+ "@typescript-eslint/visitor-keys": "8.68.0",
+ "ignore": "^7.0.5",
"natural-compare": "^1.4.0",
- "ts-api-utils": "^2.1.0"
+ "ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2485,23 +2400,23 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "@typescript-eslint/parser": "^8.35.1",
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <5.9.0"
+ "@typescript-eslint/parser": "^8.68.0",
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser": {
- "version": "8.35.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.35.1.tgz",
- "integrity": "sha512-3MyiDfrfLeK06bi/g9DqJxP5pV74LNv4rFTyvGDmT3x2p1yp1lOd+qYZfiRPIOf/oON+WRZR5wxxuF85qOar+w==",
+ "version": "8.68.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.68.0.tgz",
+ "integrity": "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/scope-manager": "8.35.1",
- "@typescript-eslint/types": "8.35.1",
- "@typescript-eslint/typescript-estree": "8.35.1",
- "@typescript-eslint/visitor-keys": "8.35.1",
- "debug": "^4.3.4"
+ "@typescript-eslint/scope-manager": "8.68.0",
+ "@typescript-eslint/types": "8.68.0",
+ "@typescript-eslint/typescript-estree": "8.68.0",
+ "@typescript-eslint/visitor-keys": "8.68.0",
+ "debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2511,20 +2426,20 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <5.9.0"
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/project-service": {
- "version": "8.35.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.35.1.tgz",
- "integrity": "sha512-VYxn/5LOpVxADAuP3NrnxxHYfzVtQzLKeldIhDhzC8UHaiQvYlXvKuVho1qLduFbJjjy5U5bkGwa3rUGUb1Q6Q==",
+ "version": "8.68.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.68.0.tgz",
+ "integrity": "sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/tsconfig-utils": "^8.35.1",
- "@typescript-eslint/types": "^8.35.1",
- "debug": "^4.3.4"
+ "@typescript-eslint/tsconfig-utils": "^8.68.0",
+ "@typescript-eslint/types": "^8.68.0",
+ "debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2534,18 +2449,18 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <5.9.0"
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/scope-manager": {
- "version": "8.35.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.35.1.tgz",
- "integrity": "sha512-s/Bpd4i7ht2934nG+UoSPlYXd08KYz3bmjLEb7Ye1UVob0d1ENiT3lY8bsCmik4RqfSbPw9xJJHbugpPpP5JUg==",
+ "version": "8.68.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.68.0.tgz",
+ "integrity": "sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.35.1",
- "@typescript-eslint/visitor-keys": "8.35.1"
+ "@typescript-eslint/types": "8.68.0",
+ "@typescript-eslint/visitor-keys": "8.68.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2556,9 +2471,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
- "version": "8.35.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.35.1.tgz",
- "integrity": "sha512-K5/U9VmT9dTHoNowWZpz+/TObS3xqC5h0xAIjXPw+MNcKV9qg6eSatEnmeAwkjHijhACH0/N7bkhKvbt1+DXWQ==",
+ "version": "8.68.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.68.0.tgz",
+ "integrity": "sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2569,20 +2484,21 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <5.9.0"
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
- "version": "8.35.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.35.1.tgz",
- "integrity": "sha512-HOrUBlfVRz5W2LIKpXzZoy6VTZzMu2n8q9C2V/cFngIC5U1nStJgv0tMV4sZPzdf4wQm9/ToWUFPMN9Vq9VJQQ==",
+ "version": "8.68.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.68.0.tgz",
+ "integrity": "sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/typescript-estree": "8.35.1",
- "@typescript-eslint/utils": "8.35.1",
- "debug": "^4.3.4",
- "ts-api-utils": "^2.1.0"
+ "@typescript-eslint/types": "8.68.0",
+ "@typescript-eslint/typescript-estree": "8.68.0",
+ "@typescript-eslint/utils": "8.68.0",
+ "debug": "^4.4.3",
+ "ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2592,14 +2508,14 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <5.9.0"
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/types": {
- "version": "8.35.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.1.tgz",
- "integrity": "sha512-q/O04vVnKHfrrhNAscndAn1tuQhIkwqnaW+eu5waD5IPts2eX1dgJxgqcPx5BX109/qAz7IG6VrEPTOYKCNfRQ==",
+ "version": "8.68.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.68.0.tgz",
+ "integrity": "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2611,22 +2527,21 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
- "version": "8.35.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.1.tgz",
- "integrity": "sha512-Vvpuvj4tBxIka7cPs6Y1uvM7gJgdF5Uu9F+mBJBPY4MhvjrjWGK4H0lVgLJd/8PWZ23FTqsaJaLEkBCFUk8Y9g==",
+ "version": "8.68.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.68.0.tgz",
+ "integrity": "sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/project-service": "8.35.1",
- "@typescript-eslint/tsconfig-utils": "8.35.1",
- "@typescript-eslint/types": "8.35.1",
- "@typescript-eslint/visitor-keys": "8.35.1",
- "debug": "^4.3.4",
- "fast-glob": "^3.3.2",
- "is-glob": "^4.0.3",
- "minimatch": "^9.0.4",
- "semver": "^7.6.0",
- "ts-api-utils": "^2.1.0"
+ "@typescript-eslint/project-service": "8.68.0",
+ "@typescript-eslint/tsconfig-utils": "8.68.0",
+ "@typescript-eslint/types": "8.68.0",
+ "@typescript-eslint/visitor-keys": "8.68.0",
+ "debug": "^4.4.3",
+ "minimatch": "^10.2.2",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2636,36 +2551,36 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <5.9.0"
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
- "version": "9.0.9",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
- "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
+ "version": "10.2.6",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
+ "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"dev": true,
- "license": "ISC",
+ "license": "BlueOak-1.0.0",
"dependencies": {
- "brace-expansion": "^2.0.2"
+ "brace-expansion": "^5.0.8"
},
"engines": {
- "node": ">=16 || 14 >=14.17"
+ "node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/utils": {
- "version": "8.35.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.35.1.tgz",
- "integrity": "sha512-lhnwatFmOFcazAsUm3ZnZFpXSxiwoa1Lj50HphnDe1Et01NF4+hrdXONSUHIcbVu2eFb1bAf+5yjXkGVkXBKAQ==",
+ "version": "8.68.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.68.0.tgz",
+ "integrity": "sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@eslint-community/eslint-utils": "^4.7.0",
- "@typescript-eslint/scope-manager": "8.35.1",
- "@typescript-eslint/types": "8.35.1",
- "@typescript-eslint/typescript-estree": "8.35.1"
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.68.0",
+ "@typescript-eslint/types": "8.68.0",
+ "@typescript-eslint/typescript-estree": "8.68.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2675,19 +2590,19 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <5.9.0"
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
- "version": "8.35.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.35.1.tgz",
- "integrity": "sha512-VRwixir4zBWCSTP/ljEo091lbpypz57PoeAQ9imjG+vbeof9LplljsL1mos4ccG6H9IjfrVGM359RozUnuFhpw==",
+ "version": "8.68.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.68.0.tgz",
+ "integrity": "sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.35.1",
- "eslint-visitor-keys": "^4.2.1"
+ "@typescript-eslint/types": "8.68.0",
+ "eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2697,15 +2612,28 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
+ "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
"node_modules/@vitest/coverage-v8": {
- "version": "4.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz",
- "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==",
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz",
+ "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
- "@vitest/utils": "4.1.9",
+ "@vitest/utils": "4.1.11",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
@@ -2719,8 +2647,8 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
- "@vitest/browser": "4.1.9",
- "vitest": "4.1.9"
+ "@vitest/browser": "4.1.11",
+ "vitest": "4.1.11"
},
"peerDependenciesMeta": {
"@vitest/browser": {
@@ -2729,16 +2657,16 @@
}
},
"node_modules/@vitest/expect": {
- "version": "4.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
- "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
+ "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
- "@vitest/spy": "4.1.9",
- "@vitest/utils": "4.1.9",
+ "@vitest/spy": "4.1.11",
+ "@vitest/utils": "4.1.11",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -2747,13 +2675,13 @@
}
},
"node_modules/@vitest/mocker": {
- "version": "4.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
- "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
+ "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/spy": "4.1.9",
+ "@vitest/spy": "4.1.11",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -2774,9 +2702,9 @@
}
},
"node_modules/@vitest/pretty-format": {
- "version": "4.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
- "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
+ "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2787,13 +2715,13 @@
}
},
"node_modules/@vitest/runner": {
- "version": "4.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
- "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
+ "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/utils": "4.1.9",
+ "@vitest/utils": "4.1.11",
"pathe": "^2.0.3"
},
"funding": {
@@ -2801,14 +2729,14 @@
}
},
"node_modules/@vitest/snapshot": {
- "version": "4.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
- "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
+ "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.1.9",
- "@vitest/utils": "4.1.9",
+ "@vitest/pretty-format": "4.1.11",
+ "@vitest/utils": "4.1.11",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -2817,9 +2745,9 @@
}
},
"node_modules/@vitest/spy": {
- "version": "4.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
- "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
+ "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -2827,13 +2755,13 @@
}
},
"node_modules/@vitest/ui": {
- "version": "4.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.9.tgz",
- "integrity": "sha512-U/cRvtqfEPj27FI1n9cyUvi4vXXdcLhjJiI+InYKdk8hP4VrS6RXOjGL7rfFaeBc37iRKANsR6eEzIoC7lmgBQ==",
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.11.tgz",
+ "integrity": "sha512-r/rwyKoev21mWdRGSEkZOqkQ2BYy68mwjihg9M90nNRbf4NGrgzZ4cj6JNCEwlOGJkbKeMgsjlykvwKUbRr7gw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/utils": "4.1.9",
+ "@vitest/utils": "4.1.11",
"fflate": "^0.8.2",
"flatted": "^3.4.2",
"pathe": "^2.0.3",
@@ -2845,17 +2773,17 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
- "vitest": "4.1.9"
+ "vitest": "4.1.11"
}
},
"node_modules/@vitest/utils": {
- "version": "4.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
- "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
+ "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.1.9",
+ "@vitest/pretty-format": "4.1.11",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -4405,9 +4333,9 @@
}
},
"node_modules/es-module-lexer": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz",
- "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==",
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
+ "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
"dev": true,
"license": "MIT"
},
@@ -4887,24 +4815,43 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/eslint-plugin-no-unsanitized": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-no-unsanitized/-/eslint-plugin-no-unsanitized-4.1.5.tgz",
+ "integrity": "sha512-MSB4hXPVFQrI8weqzs6gzl7reP2k/qSjtCoL2vUMSDejIIq9YL1ZKvq5/ORBXab/PvfBBrWO2jWviYpL+4Ghfg==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "peerDependencies": {
+ "eslint": "^9 || ^10"
+ }
+ },
"node_modules/eslint-plugin-obsidianmd": {
- "version": "0.1.9",
- "resolved": "https://registry.npmjs.org/eslint-plugin-obsidianmd/-/eslint-plugin-obsidianmd-0.1.9.tgz",
- "integrity": "sha512-/gyo5vky3Y7re4BtT/8MQbHU5Wes4o6VRqas3YmXE7aTCnMsdV0kfzV1GDXJN9Hrsc9UQPoeKUMiapKL0aGE4g==",
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-obsidianmd/-/eslint-plugin-obsidianmd-0.4.2.tgz",
+ "integrity": "sha512-NFp6wsRSvN0TU6EPi03uF2UQ3GuYyVtzjKDl25KyP8Bnz8gM0unX7R/e+VQ2JtMW2/3DC/VwIXjHnzAhB9JQwg==",
"dev": true,
"license": "MIT",
"dependencies": {
+ "@eslint-community/eslint-plugin-eslint-comments": "^4.7.2",
+ "@eslint/config-helpers": "^0.4.2",
+ "@eslint/js": "^9.30.1",
+ "@eslint/json": "0.14.0",
"@microsoft/eslint-plugin-sdl": "^1.1.0",
- "@types/eslint": "8.56.2",
+ "@types/eslint": "9.6.1",
"@types/node": "20.12.12",
- "eslint": ">=9.0.0 <10.0.0",
+ "@typescript-eslint/types": "^8.33.1",
+ "@typescript-eslint/utils": "^8.33.1",
+ "eslint": ">=9.19.0",
"eslint-plugin-depend": "1.3.1",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-json-schema-validator": "5.1.0",
+ "eslint-plugin-no-unsanitized": "^4.1.5",
"eslint-plugin-security": "2.1.1",
"globals": "14.0.0",
- "obsidian": "1.8.7",
- "typescript": "5.4.5"
+ "obsidian": "1.12.3",
+ "semver": "^7.7.4",
+ "typescript": "5.4.5",
+ "typescript-eslint": "^8.35.1"
},
"bin": {
"eslint-plugin-obsidian": "dist/lib/index.js"
@@ -4915,7 +4862,7 @@
"peerDependencies": {
"@eslint/js": "^9.30.1",
"@eslint/json": "0.14.0",
- "eslint": ">=9.0.0 <10.0.0",
+ "eslint": ">=9.19.0",
"obsidian": "1.8.7",
"typescript-eslint": "^8.35.1"
}
@@ -4931,9 +4878,9 @@
}
},
"node_modules/eslint-plugin-obsidianmd/node_modules/obsidian": {
- "version": "1.8.7",
- "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.8.7.tgz",
- "integrity": "sha512-h4bWwNFAGRXlMlMAzdEiIM2ppTGlrh7uGOJS6w4gClrsjc+ei/3YAtU2VdFUlCiPuTHpY4aBpFJJW75S1Tl/JA==",
+ "version": "1.12.3",
+ "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.12.3.tgz",
+ "integrity": "sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4941,8 +4888,8 @@
"moment": "2.29.4"
},
"peerDependencies": {
- "@codemirror/state": "^6.0.0",
- "@codemirror/view": "^6.0.0"
+ "@codemirror/state": "6.5.0",
+ "@codemirror/view": "6.38.6"
}
},
"node_modules/eslint-plugin-obsidianmd/node_modules/typescript": {
@@ -5020,9 +4967,9 @@
}
},
"node_modules/eslint-plugin-sonarjs": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-4.1.0.tgz",
- "integrity": "sha512-rh+FlVz0yfd2RNIb6WqSkuGh0addX/Qi5scwQ5FphXDFrM6fZKcxP1+attJ78yUKcyYfiu6MTaISPpAFPzqRJw==",
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-4.2.0.tgz",
+ "integrity": "sha512-bqADfuNtTL7VK6RU29eoiFTtaaBKIpVPuX3bOl+rBpWSBa0zIBVZlqZNZQjfP6s4iXkAJokv5IsD8OsACkwApg==",
"dev": true,
"license": "LGPL-3.0-only",
"dependencies": {
@@ -5030,14 +4977,14 @@
"builtin-modules": "^3.3.0",
"bytes": "^3.1.2",
"functional-red-black-tree": "^1.0.1",
- "globals": "^17.6.0",
+ "globals": "^17.7.0",
"jsx-ast-utils-x": "^0.1.0",
"lodash.merge": "^4.6.2",
"minimatch": "^10.2.5",
"scslre": "^0.3.0",
- "semver": "^7.8.4",
+ "semver": "^7.8.5",
"ts-api-utils": "^2.5.0",
- "typescript": ">=5",
+ "typescript": ">=5 <6.1.0",
"yaml": "^2.9.0"
},
"peerDependencies": {
@@ -5244,36 +5191,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/fast-glob": {
- "version": "3.3.3",
- "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
- "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nodelib/fs.stat": "^2.0.2",
- "@nodelib/fs.walk": "^1.2.3",
- "glob-parent": "^5.1.2",
- "merge2": "^1.3.0",
- "micromatch": "^4.0.8"
- },
- "engines": {
- "node": ">=8.6.0"
- }
- },
- "node_modules/fast-glob/node_modules/glob-parent": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
- "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "is-glob": "^4.0.1"
- },
- "engines": {
- "node": ">= 6"
- }
- },
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -5305,16 +5222,6 @@
],
"license": "BSD-3-Clause"
},
- "node_modules/fastq": {
- "version": "1.20.1",
- "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
- "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "reusify": "^1.0.4"
- }
- },
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
@@ -5740,13 +5647,6 @@
"dev": true,
"license": "ISC"
},
- "node_modules/graphemer": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
- "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/handlebars": {
"version": "4.7.9",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz",
@@ -5976,9 +5876,9 @@
}
},
"node_modules/ignore": {
- "version": "7.0.5",
- "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
- "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz",
+ "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==",
"license": "MIT",
"engines": {
"node": ">= 4"
@@ -6951,9 +6851,9 @@
}
},
"node_modules/lightningcss": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
- "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
@@ -6967,23 +6867,23 @@
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
- "lightningcss-android-arm64": "1.32.0",
- "lightningcss-darwin-arm64": "1.32.0",
- "lightningcss-darwin-x64": "1.32.0",
- "lightningcss-freebsd-x64": "1.32.0",
- "lightningcss-linux-arm-gnueabihf": "1.32.0",
- "lightningcss-linux-arm64-gnu": "1.32.0",
- "lightningcss-linux-arm64-musl": "1.32.0",
- "lightningcss-linux-x64-gnu": "1.32.0",
- "lightningcss-linux-x64-musl": "1.32.0",
- "lightningcss-win32-arm64-msvc": "1.32.0",
- "lightningcss-win32-x64-msvc": "1.32.0"
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
}
},
"node_modules/lightningcss-android-arm64": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
- "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
"cpu": [
"arm64"
],
@@ -7002,9 +6902,9 @@
}
},
"node_modules/lightningcss-darwin-arm64": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
- "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
"cpu": [
"arm64"
],
@@ -7023,9 +6923,9 @@
}
},
"node_modules/lightningcss-darwin-x64": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
- "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
"cpu": [
"x64"
],
@@ -7044,9 +6944,9 @@
}
},
"node_modules/lightningcss-freebsd-x64": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
- "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
"cpu": [
"x64"
],
@@ -7065,9 +6965,9 @@
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
- "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
"cpu": [
"arm"
],
@@ -7086,9 +6986,9 @@
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
- "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
"cpu": [
"arm64"
],
@@ -7107,9 +7007,9 @@
}
},
"node_modules/lightningcss-linux-arm64-musl": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
- "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
"cpu": [
"arm64"
],
@@ -7128,9 +7028,9 @@
}
},
"node_modules/lightningcss-linux-x64-gnu": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
- "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
"cpu": [
"x64"
],
@@ -7149,9 +7049,9 @@
}
},
"node_modules/lightningcss-linux-x64-musl": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
- "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
"cpu": [
"x64"
],
@@ -7170,9 +7070,9 @@
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
- "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
"cpu": [
"arm64"
],
@@ -7191,9 +7091,9 @@
}
},
"node_modules/lightningcss-win32-x64-msvc": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
- "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
"cpu": [
"x64"
],
@@ -7491,16 +7391,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/merge2": {
- "version": "1.4.1",
- "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
- "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 8"
- }
- },
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@@ -7610,9 +7500,9 @@
}
},
"node_modules/nanoid": {
- "version": "3.3.16",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
- "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
+ "version": "3.3.18",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"dev": true,
"funding": [
{
@@ -7723,9 +7613,9 @@
}
},
"node_modules/npm": {
- "version": "11.18.0",
- "resolved": "https://registry.npmjs.org/npm/-/npm-11.18.0.tgz",
- "integrity": "sha512-T67M4L5wNm0cZ7EBLErcEkY1SmzEW/WJ+SADBzsFUY1UdAPfFHXFQtZ6SEXiK0+vzXysCvAsepbMaBTwnrAD+w==",
+ "version": "11.19.1",
+ "resolved": "https://registry.npmjs.org/npm/-/npm-11.19.1.tgz",
+ "integrity": "sha512-ztsxKxt/kkIaAs+2i0GU6I+DRmUdrNasxTZKJe9TCdSjKxlhah/4r/hl5ygMD6XAg1qZ9c2TNomR4qgOydp10g==",
"bundleDependencies": [
"@isaacs/string-locale-compare",
"@npmcli/arborist",
@@ -7804,7 +7694,7 @@
],
"dependencies": {
"@isaacs/string-locale-compare": "^1.1.0",
- "@npmcli/arborist": "^9.9.0",
+ "@npmcli/arborist": "^9.9.1",
"@npmcli/config": "^10.12.0",
"@npmcli/fs": "^5.0.0",
"@npmcli/map-workspaces": "^5.0.3",
@@ -7829,11 +7719,11 @@
"is-cidr": "^6.0.4",
"json-parse-even-better-errors": "^5.0.0",
"libnpmaccess": "^10.0.3",
- "libnpmdiff": "^8.1.11",
- "libnpmexec": "^10.3.1",
- "libnpmfund": "^7.0.25",
+ "libnpmdiff": "^8.1.12",
+ "libnpmexec": "^10.3.2",
+ "libnpmfund": "^7.0.26",
"libnpmorg": "^8.0.1",
- "libnpmpack": "^9.1.11",
+ "libnpmpack": "^9.1.13",
"libnpmpublish": "^11.2.0",
"libnpmsearch": "^9.0.1",
"libnpmteam": "^8.0.2",
@@ -7862,7 +7752,7 @@
"spdx-expression-parse": "^4.0.0",
"ssri": "^13.0.1",
"supports-color": "^10.2.2",
- "tar": "^7.5.19",
+ "tar": "^7.5.22",
"text-table": "~0.2.0",
"tiny-relative-date": "^2.0.2",
"treeverse": "^3.0.0",
@@ -7951,7 +7841,7 @@
}
},
"node_modules/npm/node_modules/@npmcli/arborist": {
- "version": "9.9.0",
+ "version": "9.9.1",
"dev": true,
"inBundle": true,
"license": "ISC",
@@ -8344,7 +8234,7 @@
}
},
"node_modules/npm/node_modules/brace-expansion": {
- "version": "5.0.7",
+ "version": "5.0.9",
"dev": true,
"inBundle": true,
"license": "MIT",
@@ -8352,7 +8242,7 @@
"balanced-match": "^4.0.2"
},
"engines": {
- "node": "18 || 20 || >=22"
+ "node": "20 || >=22"
}
},
"node_modules/npm/node_modules/cacache": {
@@ -8636,7 +8526,7 @@
}
},
"node_modules/npm/node_modules/ip-address": {
- "version": "10.2.0",
+ "version": "10.5.0",
"dev": true,
"inBundle": true,
"license": "MIT",
@@ -8718,12 +8608,12 @@
}
},
"node_modules/npm/node_modules/libnpmdiff": {
- "version": "8.1.11",
+ "version": "8.1.12",
"dev": true,
"inBundle": true,
"license": "ISC",
"dependencies": {
- "@npmcli/arborist": "^9.9.0",
+ "@npmcli/arborist": "^9.9.1",
"@npmcli/installed-package-contents": "^4.0.0",
"binary-extensions": "^3.0.0",
"diff": "^8.0.2",
@@ -8737,13 +8627,13 @@
}
},
"node_modules/npm/node_modules/libnpmexec": {
- "version": "10.3.1",
+ "version": "10.3.2",
"dev": true,
"inBundle": true,
"license": "ISC",
"dependencies": {
"@gar/promise-retry": "^1.0.0",
- "@npmcli/arborist": "^9.9.0",
+ "@npmcli/arborist": "^9.9.1",
"@npmcli/package-json": "^7.0.0",
"@npmcli/run-script": "^10.0.0",
"ci-info": "^4.0.0",
@@ -8760,12 +8650,12 @@
}
},
"node_modules/npm/node_modules/libnpmfund": {
- "version": "7.0.25",
+ "version": "7.0.26",
"dev": true,
"inBundle": true,
"license": "ISC",
"dependencies": {
- "@npmcli/arborist": "^9.9.0"
+ "@npmcli/arborist": "^9.9.1"
},
"engines": {
"node": "^20.17.0 || >=22.9.0"
@@ -8785,12 +8675,12 @@
}
},
"node_modules/npm/node_modules/libnpmpack": {
- "version": "9.1.11",
+ "version": "9.1.13",
"dev": true,
"inBundle": true,
"license": "ISC",
"dependencies": {
- "@npmcli/arborist": "^9.9.0",
+ "@npmcli/arborist": "^9.9.1",
"@npmcli/run-script": "^10.0.0",
"npm-package-arg": "^13.0.0",
"pacote": "^21.0.2"
@@ -9495,7 +9385,7 @@
}
},
"node_modules/npm/node_modules/tar": {
- "version": "7.5.19",
+ "version": "7.5.22",
"dev": true,
"inBundle": true,
"license": "BlueOak-1.0.0",
@@ -9591,7 +9481,7 @@
}
},
"node_modules/npm/node_modules/undici": {
- "version": "6.27.0",
+ "version": "6.28.0",
"dev": true,
"inBundle": true,
"license": "MIT",
@@ -9797,9 +9687,9 @@
}
},
"node_modules/obug": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
- "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
+ "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
@@ -10221,9 +10111,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.25",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
- "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
+ "version": "8.5.26",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+ "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"dev": true,
"funding": [
{
@@ -10241,7 +10131,7 @@
],
"license": "MIT",
"dependencies": {
- "nanoid": "^3.3.16",
+ "nanoid": "^3.3.17",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -10329,27 +10219,6 @@
"node": ">=6"
}
},
- "node_modules/queue-microtask": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
- "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT"
- },
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
@@ -10657,25 +10526,14 @@
"node": ">=0.12"
}
},
- "node_modules/reusify": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
- "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "iojs": ">=1.0.0",
- "node": ">=0.10.0"
- }
- },
"node_modules/rolldown": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
- "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==",
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz",
+ "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@oxc-project/types": "=0.138.0",
+ "@oxc-project/types": "=0.147.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
@@ -10685,45 +10543,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
- "@rolldown/binding-android-arm64": "1.1.4",
- "@rolldown/binding-darwin-arm64": "1.1.4",
- "@rolldown/binding-darwin-x64": "1.1.4",
- "@rolldown/binding-freebsd-x64": "1.1.4",
- "@rolldown/binding-linux-arm-gnueabihf": "1.1.4",
- "@rolldown/binding-linux-arm64-gnu": "1.1.4",
- "@rolldown/binding-linux-arm64-musl": "1.1.4",
- "@rolldown/binding-linux-ppc64-gnu": "1.1.4",
- "@rolldown/binding-linux-s390x-gnu": "1.1.4",
- "@rolldown/binding-linux-x64-gnu": "1.1.4",
- "@rolldown/binding-linux-x64-musl": "1.1.4",
- "@rolldown/binding-openharmony-arm64": "1.1.4",
- "@rolldown/binding-wasm32-wasi": "1.1.4",
- "@rolldown/binding-win32-arm64-msvc": "1.1.4",
- "@rolldown/binding-win32-x64-msvc": "1.1.4"
- }
- },
- "node_modules/run-parallel": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
- "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "queue-microtask": "^1.2.2"
+ "@rolldown/binding-android-arm-eabi": "1.2.6",
+ "@rolldown/binding-android-arm64": "1.2.6",
+ "@rolldown/binding-darwin-arm64": "1.2.6",
+ "@rolldown/binding-darwin-x64": "1.2.6",
+ "@rolldown/binding-freebsd-x64": "1.2.6",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.6",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.6",
+ "@rolldown/binding-linux-arm64-musl": "1.2.6",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.6",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.6",
+ "@rolldown/binding-linux-x64-gnu": "1.2.6",
+ "@rolldown/binding-linux-x64-musl": "1.2.6",
+ "@rolldown/binding-openharmony-arm64": "1.2.6",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.6",
+ "@rolldown/binding-win32-x64-msvc": "1.2.6"
}
},
"node_modules/safe-array-concat": {
@@ -10827,9 +10661,9 @@
}
},
"node_modules/semantic-release": {
- "version": "25.0.5",
- "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.5.tgz",
- "integrity": "sha512-mn61SUJwtM8ThrWn2WmgLVpwVJeG/hPSupua1psdMoufmwRIPyvRLkRkL0JDXkP67OntlLWUYnBnfVc8EDO3/g==",
+ "version": "25.0.9",
+ "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.9.tgz",
+ "integrity": "sha512-bxve7csK0/Txr++CkfrmV+X1r4jqiSOw2WsSad9E2S68R+ZfLBwDn8IceM8WfiOmKQIHgsQc1cNA8Dzg7U75pg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -11383,9 +11217,9 @@
"license": "MIT"
},
"node_modules/std-env": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
- "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
+ "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
"dev": true,
"license": "MIT"
},
@@ -11836,9 +11670,9 @@
"license": "MIT"
},
"node_modules/tinyexec": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
- "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
+ "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -11894,9 +11728,9 @@
}
},
"node_modules/tinyrainbow": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
- "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
+ "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -12175,9 +12009,9 @@
}
},
"node_modules/typescript": {
- "version": "5.8.3",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
- "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -12189,15 +12023,16 @@
}
},
"node_modules/typescript-eslint": {
- "version": "8.35.1",
- "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.35.1.tgz",
- "integrity": "sha512-xslJjFzhOmHYQzSB/QTeASAHbjmxOGEP6Coh93TXmUBFQoJ1VU35UHIDmG06Jd6taf3wqqC1ntBnCMeymy5Ovw==",
+ "version": "8.68.0",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.68.0.tgz",
+ "integrity": "sha512-MHy0Y0ynqeEbx/S45+i/bBssdy3X6KNBfmJAP35GrgtNxu2TQ5K5xsFDhAnmsq1jvpdoZOPG1LGtJo0HWqYCrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/eslint-plugin": "8.35.1",
- "@typescript-eslint/parser": "8.35.1",
- "@typescript-eslint/utils": "8.35.1"
+ "@typescript-eslint/eslint-plugin": "8.68.0",
+ "@typescript-eslint/parser": "8.68.0",
+ "@typescript-eslint/typescript-estree": "8.68.0",
+ "@typescript-eslint/utils": "8.68.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -12207,8 +12042,8 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <5.9.0"
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/uglify-js": {
@@ -12356,16 +12191,16 @@
}
},
"node_modules/vite": {
- "version": "8.1.3",
- "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
- "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
+ "version": "8.2.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
+ "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "lightningcss": "^1.32.0",
- "picomatch": "^4.0.4",
- "postcss": "^8.5.16",
- "rolldown": "~1.1.3",
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.5",
+ "postcss": "^8.5.26",
+ "rolldown": "~1.2.4",
"tinyglobby": "^0.2.17"
},
"bin": {
@@ -12382,7 +12217,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
- "@vitejs/devtools": "^0.3.0",
+ "@vitejs/devtools": "^0.4.0 || ^0.5.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
@@ -12434,9 +12269,9 @@
}
},
"node_modules/vite/node_modules/picomatch": {
- "version": "4.0.5",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
- "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -12447,19 +12282,19 @@
}
},
"node_modules/vitest": {
- "version": "4.1.9",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
- "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
+ "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/expect": "4.1.9",
- "@vitest/mocker": "4.1.9",
- "@vitest/pretty-format": "4.1.9",
- "@vitest/runner": "4.1.9",
- "@vitest/snapshot": "4.1.9",
- "@vitest/spy": "4.1.9",
- "@vitest/utils": "4.1.9",
+ "@vitest/expect": "4.1.11",
+ "@vitest/mocker": "4.1.11",
+ "@vitest/pretty-format": "4.1.11",
+ "@vitest/runner": "4.1.11",
+ "@vitest/snapshot": "4.1.11",
+ "@vitest/spy": "4.1.11",
+ "@vitest/utils": "4.1.11",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -12487,12 +12322,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
- "@vitest/browser-playwright": "4.1.9",
- "@vitest/browser-preview": "4.1.9",
- "@vitest/browser-webdriverio": "4.1.9",
- "@vitest/coverage-istanbul": "4.1.9",
- "@vitest/coverage-v8": "4.1.9",
- "@vitest/ui": "4.1.9",
+ "@vitest/browser-playwright": "4.1.11",
+ "@vitest/browser-preview": "4.1.11",
+ "@vitest/browser-webdriverio": "4.1.11",
+ "@vitest/coverage-istanbul": "4.1.11",
+ "@vitest/coverage-v8": "4.1.11",
+ "@vitest/ui": "4.1.11",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
@@ -12537,9 +12372,9 @@
}
},
"node_modules/vitest/node_modules/picomatch": {
- "version": "4.0.5",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
- "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
diff --git a/package.json b/package.json
index 0f9d809..3bd7aa7 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"version": "node version-bump.mjs && git add manifest.json versions.json",
"lint": "eslint .",
"test": "vitest run",
+ "deploy": "npm run build && mkdir -p ~/Obsidian/MyPKM/.obsidian/plugins/git-file-sync && cp main.js manifest.json styles.css ~/Obsidian/MyPKM/.obsidian/plugins/git-file-sync/",
"test:ui": "vitest --ui",
"test:e2e": "bash scripts/run-e2e.sh",
"prepare": "husky",
@@ -32,36 +33,37 @@
"@semantic-release/github": "^12.0.9",
"@types/jsdom": "^28.0.3",
"@types/node": "^24.0.0",
- "@vitest/coverage-v8": "^4.1.9",
- "@vitest/ui": "^4.1.9",
+ "@vitest/coverage-v8": "^4.1.11",
+ "@vitest/ui": "^4.1.11",
"conventional-changelog-conventionalcommits": "^9.3.1",
"esbuild": "0.28.1",
- "eslint-plugin-obsidianmd": "0.1.9",
- "eslint-plugin-sonarjs": "^4.0.3",
+ "eslint-plugin-obsidianmd": "^0.4.2",
+ "eslint-plugin-sonarjs": "^4.2.0",
"globals": "14.0.0",
"husky": "^9.1.7",
"jiti": "2.6.1",
"jsdom": "^29.1.1",
- "semantic-release": "^25.0.5",
+ "semantic-release": "^25.0.9",
"tslib": "2.4.0",
- "typescript": "^5.8.3",
- "typescript-eslint": "8.35.1",
- "vitest": "^4.1.9"
+ "typescript": "~5.9.3",
+ "typescript-eslint": "^8.68.0",
+ "vitest": "^4.1.11"
},
"dependencies": {
- "ignore": "^7.0.5",
+ "ignore": "^7.0.6",
"obsidian": "^1.13.1"
},
"overrides": {
"npm": "^11.18.0",
"@actions/http-client": {
- "undici": "^6.27.0"
+ "undici": "^6.28.0"
},
+ "undici": "^7.29.0",
"sigstore": "^4.1.1",
"@sigstore/core": "^3.2.1",
"@sigstore/verify": "^3.1.1",
"tar": "^7.5.19",
- "ip-address": "^10.1.1",
+ "ip-address": "^10.3.1",
"js-yaml": "^4.2.0",
"brace-expansion": "^2.1.2",
"fast-uri": "^3.1.4",
diff --git a/progress.md b/progress.md
index bb8a526..8c9aff4 100644
--- a/progress.md
+++ b/progress.md
@@ -4,122 +4,90 @@ Completed work is archived in [archive/](./archive/), one file per calendar mont
## Current State
-**Last Updated:** 2026-08-20
-**Active Feature:** feat-026 / issue #105 — sync architecture refactor on `refactor/sync-domain-pipeline`. `SyncPlanner` is now the decision source for normal push, batch pull/preview, single pull, and moves. Edited tracked renames with a free destination plan one move instead of being auto-skipped; remote-only changes pull without false conflicts; real two-sided divergence and occupied move destinations remain conflicts. Post-push CI hardening is locally green; real provider CI plus Obsidian desktop/mobile manual verification remain before declaring the feature complete.
-**Parallel Work:** PR #87 (4x Dependabot security alerts via npm overrides) and Issue #57 (live-credential smoke test).
+**Last Updated:** 2026-08-31
+**Active Feature:** Issue #142 — move real-provider E2E from `e2e/` to `e2e-tests/provider/` and replace `E2E_RUNTIME_DIR` per-run generation with committed static runtime files. Working tree changes complete, uncommitted.
+**Branch / PR:** `refactor/e2e-tests-scanner-boundary`, branched off `claude/source-control-foundation` (PR #129, still open). Intended to retarget/rebase onto `main` once #129 merges — see #142 for the full plan.
+
+**Open risk, not yet resolved**: this PR's core premise — that a directory rename from `e2e/` to `e2e-tests/` makes committing `fetch`/`node:child_process` `.ts` files scanner-safe — is unverified against the actual Obsidian community-plugin scanner, and contradicts this repo's own prior audit (`docs/obsidian-scanner-audit.md`), which found the scanner flagged those exact APIs while committed under `e2e/` regardless of directory. Proceeded on explicit user instruction; flagged in `docs/testing/real-provider-e2e.md`'s "Known gaps" and `docs/obsidian-scanner-audit.md`'s Phase 2 section. **A real scanner rescan is required before trusting this.**
+
+Below that: the previous "Outstanding Items"/"Verification Evidence" entries track separate, still-open work on PR #129 / `claude/source-control-foundation` — not superseded by this entry.
## Outstanding Items
-0a. **Re-enable the gitea leg in CI** (`.github/workflows/ci.yml`, "Determine whether this provider leg should run" step) — disabled 2026-08-13 after two rounds of real-CI-only failures (host-port/127.0.0.1 unreachable from this self-hosted fleet's sibling-container topology, then a curl hang) got fixed but a third run wasn't attempted before the user asked to pause it; harness code (`scripts/e2e-harness.sh`'s gitea path, `e2e/suites/gitea.e2e.test.ts`) is unchanged and passes locally every time (`npm run test:e2e -- --provider gitea`, most recently re-confirmed 14/14 twice this session). While disabled, fork PRs get zero E2E coverage (gitea is normally the only leg that needs no secrets). PR #124 is already open and green with this leg gated off; re-enabling is a follow-up, not a blocker.
-1. **feat-025 manual verification** — Tree view code is complete and all automated checks pass; manual Obsidian verification in a real vault remains for user to confirm functionality (tree hierarchy, folder expand/collapse, checkboxes, Show synced toggle).
-2. **PR #87** — Dependabot security patches via npm overrides; awaiting review/merge.
-3. **Issue #57** — Live-credential smoke test; pre-existing, relevant before pushing major sync work.
-
-## Latest Evidence
-
-- [x] Issue #105 post-push CI hardening (2026-08-20), commit `948df28`: diagnosed run 32336155736 as two exhausted transient-provider attempts rather than a planner regression (GitHub 503/socket close; GitLab deadline exceeded). Increased provider E2E attempts from 2 to 3. A duplicate matrix cancelled by the shared push/PR concurrency group now produces a neutral aggregate gate with `run-ci=false`, so it neither creates a misleading `E2E gate` failure nor starts duplicate downstream CI; real failures still block. SyncManager E2E push preconditions now include `success`, `failed`, and provider `errors` in assertion diagnostics instead of surfacing only a secondary count mismatch. Added workflow contract and diagnostic unit tests and updated the E2E documentation. Verification: `actionlint v1.7.12 .github/workflows/ci.yml` — 0 errors; `npx eslint .` — 0 errors; `npm run build` — clean including Obsidian 1.11 compatibility; `npx vitest run` — 56 files / 613 tests; `npm run test:e2e -- --provider gitea` — 2 files / 14 tests and container cleanup; `git diff --check` — clean. Real CI run 32338116598 passed GitHub/GitLab production E2E, independent verification, cleanup, aggregate gate, Node 22/24 tests, lint, package, and build/release. The initial disabled-Gitea job landed on offline runner `heavenweb-runner-8`; failed-only rerun completed its skip in 11s and the full run concluded success. Provider API checks found no remaining `e2e/pr/127/**` or branch-source E2E refs. AGENTS-required Haiku was unavailable, so verification ran locally and through real CI.
-
-- [x] Issue #105 unified sync decisions and move regression (2026-08-20): added operation-aware `SyncPlanner.planFor(push|pull)`, `MoveFacts`, and the `move` domain action. Normal push, batch pull and preview, single pull, and tracked moves now consume planner decisions instead of reimplementing SHA conflict checks. Removed `PushCoordinator.queueMove`'s stale-metadata gate, so an edited tracked rename with a free destination appears under Moves and commits once; occupied destinations remain conflicts. Fixed the complementary pull false positive: a remote-only change now pulls, while real two-sided divergence still resolves as conflict. Content-fetched text/binary paths normalize equal bytes to the provider blob SHA before planning, preserving binary and GitLab legacy-baseline behavior. Added planner operation matrix, coordinator move regression, batch pull, and single pull coverage. Verification: `npx eslint .` — 0 errors; `npm run build` — clean including Obsidian 1.11 compatibility; `npx vitest run` — 54 files / 610 tests; `git diff --check` — clean. Manual Obsidian verification remains.
-
-- [x] Issue #105 architecture implementation (2026-08-19): extracted `SyncStatusRenderer` and `SyncStatusComposition`; `SyncStatusView.ts` is 11.5 KB / 251 lines. Extracted `PullCoordinator` and `PushCoordinator`; `SyncManager.ts` is 13.7 KB / 298 lines and retains its public compatibility API. `SyncManagerWorkspace` now owns refresh/tree-snapshot reuse, push/pull, diff, local/remote deletion, move, metadata mutations, provider URLs and UI-safe workspace info; sync-status UI code no longer reaches provider/tree/settings/vault mutation helpers, and `src/logic/**` has no UI imports. Legacy refresh characterization cases now target the extracted service instead of private View delegates; legacy modal tests explicitly inject the Obsidian interaction adapter. Added real refresh integration plus focused push-coordinator/workspace regression tests. Independent verification: `npx eslint .` — 0 errors; `npm run build` — clean including Obsidian 1.11 compatibility; `npx vitest run` — 54 files / 598 tests; `npm run test:e2e -- --provider gitea` — 2 files / 14 tests with container cleanup; `git diff --check` — clean. Desktop/mobile Obsidian smoke remains manual.
-
-- [x] Issue #105 architecture slice 3 (2026-08-19): added tested `SyncDiffService` and `SyncStatusNavigator`, so lazy blob loading/cache/content-kind projection is a domain `FileDiff` boundary. Extracted single-file, batch push/pull, local/remote delete, move revert, remote-tree reuse, progress/confirmation, and optimistic-status orchestration into `SyncStatusOperations`; all View row/group events now enter through `SyncStatusController`. The actual View is about 40 KB (down from 58 KB this slice and 80 KB initially). Independent verification: `npx eslint .` — 0 errors; `npm run build` — clean including Obsidian 1.11 compatibility; `npx vitest run` — 52 files / 594 tests; `npm run test:e2e -- --provider gitea` — 2 files / 14 tests with container cleanup; `git diff --check` — clean. Feature remains in progress because renderer composition and the ~50 KB manager facade are still oversized.
-- [x] Issue #105 architecture slice 2 (2026-08-19): extracted `SyncStatusRefreshService` for local/remote discovery, hidden files, symlinks, SHA/content classification, out-of-band move reconciliation, and live modify/rename transitions. The actual View fell from about 80 KB to 58 KB while legacy characterization entrypoints remain thin delegates. Added `SyncInteractionPort` plus `ObsidianSyncInteraction`; `logic/sync/SyncManager.ts` no longer imports Modal or Notice classes. Independent verification: `npx eslint .` — 0 errors; `npm run build` — clean including Obsidian 1.11 compatibility; `npx vitest run` — 51 files / 585 tests; `npm run test:e2e -- --provider gitea` — 2 files / 14 tests with container cleanup; `git diff --check` — clean. Feature remains in progress: action orchestration is still View-owned, the manager core is about 50 KB, and desktop/mobile manual smoke tests remain pending.
-- [x] Issue #105 architecture slice (2026-08-19): moved compatibility entrypoints to thin re-exports; added presentation state, pure selectors, path-only controller commands, pure planner matrix, scanner, metadata store, push/pull/remote-delete/conflict executors, `SyncManagerWorkspace`, `FileDiff`, and four workspace integration paths. `npx eslint .` — 0 errors; `npm run build` — clean including Obsidian 1.11 compatibility; `npx vitest run` — 51 files / 585 tests passed; `npm run test:e2e -- --provider gitea` — 2 files / 14 tests passed with sandbox cleanup. Feature remains in progress: the implementation files are still oversized (`sync-status/SyncStatusView.ts` ~80 KB, `sync/SyncManager.ts` ~50 KB), domain still imports modal adapters, and manual Obsidian desktop/mobile checks are pending.
-- [x] Real-provider E2E Phase 2, PR #124 fully green (2 more follow-up commits, same branch/PR):
- (1) NOSONAR placement fix — the previous commit's `# NOSONAR` comments on the gitea-provisioning
- `curl` calls landed on the *closing* line of each multi-line statement, but SonarCloud attributes
- shell:S5332 to the *opening* `curl` line, which can't carry a trailing comment while also ending
- in a `\` continuation; collapsed those two calls to single lines (payload JSON pulled into a
- local var first) so the marker lands correctly — confirmed via SonarCloud's issues API (2 of 7
- findings were still OPEN after the first fix, both on the curl lines themselves; 0 after this
- one, Security Rating A). (2) Dedup push/pull_request races — `provider-e2e`'s concurrency group
- keyed PR runs by PR number and branch-only runs by branch name, so a push to a branch with an
- open PR (this branch, since it has an open PR) fired both a `push` and a `pull_request` run in
- *different* concurrency groups for the same commit, running fully concurrently against the same
- shared GitLab sandbox; reproduced twice (rerunning the `pull_request`-triggered run's GitLab leg
- failed both times — first with 3 different real-API errors including `400: Deadline Exceeded`,
- then with a plain `testConnection` 120s timeout — while the `push`-triggered run for the
- identical commit passed cleanly both times). Fixed by keying the group by branch name alone
- (`github.head_ref || github.ref_name`, same expression `E2E_SOURCE_BRANCH` already used)
- regardless of trigger event, and updated `e2e-pr-cleanup.yml`/`e2e-branch-cleanup.yml`'s groups
- to match (documented as sharing `provider-e2e`'s group so cleanup queues behind rather than races
- an active run). Verified end-to-end: pushed the fix, both a `push` and a `pull_request` run fired
- again for the same commit as expected, and this time the concurrency group correctly cancelled
- one of them instead of letting them race — the surviving run passed 100% clean (GitHub/GitLab/
- Gitea E2E, full CI, SonarCloud A). User explicitly chose "fix the dedup now" over deferring or
- just re-running until green, when asked.
- Verification: `actionlint` — 0 errors; `npx eslint .` — 0 errors; `npm run build` — clean;
- `npx vitest run` — 527 passed; real end-to-end Gitea sandbox run (`npm run test:e2e --
- --provider gitea`) — 14/14 passed, twice, exercising the edited curl calls directly; real CI —
- PR #124's surviving run fully green including all three real-provider E2E legs and SonarCloud
- Security Rating A.
-- [x] Real-provider E2E Phase 2 follow-up fix (same branch/PR #124): `ci.yml`'s `provider-e2e` job
- set `E2E_WORKDIR` in job-level `env:` using `${{ runner.temp }}` — `runner` isn't an allowed
- context there (only `github`/`inputs`/`matrix`/`needs`/`secrets`/`strategy`/`vars` are), which
- makes GitHub Actions reject the whole workflow file at parse time; confirmed via `actionlint`
- and via the GitHub API (`jobs_url` for the c8382cb push run returned `total_count: 0` — no job
- was ever created). Fixed by computing `E2E_WORKDIR` in an unconditional first step instead,
- exporting it through `$GITHUB_ENV` (uses `$RUNNER_TEMP`, the step-level equivalent). Also fixed
- the SonarCloud Quality Gate failure (Security Rating D on new code, required ≥ A):
- `scripts/e2e-namespace.sh`'s `e2e_branch_hash` used `sha1sum`/`shasum` (CRITICAL, shell:S4790
- weak-hash — not a real security use, just a collision-avoidance digest, but Sonar flags SHA-1
- regardless of context) — switched to `sha256sum`/`shasum -a 256`; five `curl`/log lines in
- `scripts/e2e-harness.sh`'s gitea provisioning that talk `http://` to a per-run Docker-bridge-only
- container (shell:S5332 clear-text-protocol) — annotated `# NOSONAR` with an inline justification
- (address never leaves the run's own Docker network, credentials are freshly random and
- discarded at cleanup); `.github/workflows/ci.yml`'s new `npm ci` (githubactions:S6505, missing
- `--ignore-scripts`) and `actions/checkout@v6`/`actions/setup-node@v6`/`dorny/paths-filter@v3` in
- the two new jobs plus the three new standalone workflow files (githubactions:S7637, unpinned
- action refs) — pinned to full commit SHAs, `npm ci` in the new job got `--ignore-scripts`
- (husky's `prepare` hook isn't needed in CI). Left the pre-existing `build-artifact` job's
- checkout/setup-node/npm ci untouched (not flagged, out of this fix's scope).
- Verification: `actionlint` (downloaded v1.7.12 binary) — 0 errors on all 4 workflow files
- (aside from an expected false-positive on the `32gb-ram` custom self-hosted label, which
- actionlint can't know about); `bash -n` on all 5 changed/touched shell scripts — all parse;
- `npx eslint .` — 0 errors; `npm run build` (incl. Obsidian 1.11.0 compat typecheck) — clean;
- `npx vitest run` — 527 passed. Not yet re-verified against real CI/SonarCloud (push pending).
-- [x] Real-provider E2E Phase 2 (multi-run isolation): added `scripts/e2e-namespace.sh` (single
- canonical `e2e/pr///run--` / `e2e/branch///
- run--` identity generator, sourced by every other layer — no branch-naming logic
- duplicated anywhere else), `scripts/e2e-namespace-cleanup.sh` (layer 2: deletes a whole PR/branch
- namespace), `scripts/e2e-janitor.sh` (layer 3: TTL sweep, default 24h, of any leftover `e2e/**`
- branch, generic `git for-each-ref`/`push --delete`, tolerant of already-deleted refs — no
- Node-based sweeper reintroduced). Removed `e2e-harness.sh`'s old `sweep` subcommand (superseded
- by the janitor) and its ad hoc `gfs-e2e--` naming. `ci.yml`'s `provider-e2e` job
- now sets `E2E_WORKDIR` to `$RUNNER_TEMP/git-files-sync-e2e///`
- (was a shared `e2e-` dir), passes `E2E_PR_NUMBER`/`E2E_SOURCE_BRANCH` through for
- `provision`, and carries a per-source/provider `concurrency` group
- (`e2e-pr--`/`e2e-branch--`, `cancel-in-progress: true`) so a
- repeated push/rerun cancels its own predecessor instead of both running. Added
- `.github/workflows/e2e-pr-cleanup.yml` (`pull_request_target: [closed]`, no `ref:` override on
- checkout so it only ever runs this repo's own trusted code/secrets, never the closing PR's
- branch) and `e2e-branch-cleanup.yml` (`delete` event) — both share the same concurrency-group
- naming as `provider-e2e` with `cancel-in-progress: false` so cleanup queues behind rather than
- races an active run. Added `.github/workflows/e2e-janitor.yml` (schedule, every 6h, plus
- `workflow_dispatch`). Rewrote `docs/testing/real-provider-e2e.md`'s "Isolation model" section
- (namespace scheme, concurrency/cancellation semantics, 3-layer cleanup hierarchy with a Mermaid
- diagram, self-hosted workdir isolation) and updated Layout/CI/Cleanup/Known-gaps to match.
- Verification: `npx eslint .` — 0 errors; `npm run build` (incl. Obsidian 1.11.0 compat
- typecheck) — clean; `npx vitest run` — 527 passed; `python3 -c yaml.safe_load(...)` on all 4
- touched/new workflow YAML files — all parse; `bash -n` on all 4 shell scripts — all parse;
- functional dry-runs against throwaway local git repos (not the real sandboxes) for
- `e2e_test_branch`/`e2e_branch_id` collision resolution (`feature/foo-bar` vs `feature-foo/bar`
- hash to different identities), the janitor's TTL sweep (old branch deleted, recent branch and an
- unrelated `feature/keep-me` branch both left untouched), and `e2e-namespace-cleanup.sh`'s prefix
- match (`e2e/pr/123/**` matches only that PR's two provider branches, not PR 456 or the
- branch-only namespace); **real end-to-end run against a live local Gitea sandbox**
- (`npm run test:e2e -- --provider gitea`) with the new harness/namespace code — 14/14 E2E tests
- passed including a real Docker provision/seed/cleanup cycle; confirmed
- `E2E_PROVIDER=github scripts/e2e-harness.sh provision` still hard-fails on missing
- `E2E_GITHUB_OWNER` (never a silent skip) with the new identity plumbing in place. Not yet
- exercised against live GitHub/GitLab sandboxes or the real self-hosted runner fleet from this
- checkout (no credentials/runner access here) — see `docs/testing/real-provider-e2e.md`'s "Known
- gaps".
-- [x] Real-provider E2E: pushed to `origin/test/real-provider-e2e`, real CI run against `firstsun-dev/git-files-sync`'s self-hosted fleet (run 31666859288) fully green: `E2E / github` (3m15s) and `E2E / gitlab` (3m54s) both passed for real against live sandboxes, `E2E / github`+`gitlab`+`gitea` gate, and the full downstream `CI` (lint, test Node 22/24, package, build/release) all green. Getting there took 3 fix-and-repush rounds off real CI failures the local-only verification hadn't caught: (1) the generated `GitVerifier`'s git calls had no `GIT_ASKPASS`/`GIT_TERMINAL_PROMPT` in the separate vitest-step process — fixed by persisting them (paths/flags only, not the token itself) into `e2e.env`; (2) gitea provisioning timed out on `127.0.0.1:` — this runner fleet is itself a sibling container of the Docker daemon, so a published host port isn't reachable from it; switched to the container's own bridge IP; (3) that same curl call could hang indefinitely with no `--max-time`, silently blowing past the health-check loop's own retry budget — added `--max-time` everywhere and a retry-with-backoff on `docker inspect` returning an empty IP. Gitea leg then temporarily disabled in CI per user request (still passes locally) — see Outstanding Items.
-- [x] Real-provider E2E Phase 1 (Shell/Git harness rewrite): replaced the Node-based `e2e/provision`/`e2e/verifier`/`e2e/providers`/`e2e/shim/{obsidian-request-url,window-timers}`/`scripts/run-e2e*.mjs` (fetch/globalThis/node:child_process/node:crypto in committed `.ts` — the exact APIs `docs/obsidian-scanner-audit.md` flagged) with `scripts/e2e-harness.sh` (provision/seed/verify/cleanup/sweep — Shell + Git CLI: `git push :refs/heads/` for GitHub/GitLab branch isolation, plain `docker`/`curl` for Gitea's disposable container+repo, `GIT_ASKPASS` generated per-run under `$RUNNER_TEMP`/`$E2E_WORKDIR`, never persisted) plus `scripts/run-e2e.sh` (local orchestration wrapper). Node-only glue the suites still need at runtime (real `requestUrl` shim, `window` timer alias, a git-CLI-backed verifier) is generated by `provision` into `$E2E_RUNTIME_DIR` and loaded via runtime-computed dynamic `import()` — never committed — so `e2e/**/*.ts` went back into `tsconfig.json`'s `include`/`eslint.config.mts`'s scope clean. Ported all 4 suites (github/gitlab/gitea/sync-manager) to the new `SyncManager.pushFiles` API and the generated verifier. `npx eslint .` — 0 errors; `npm run build` — clean; `npx vitest run` — 527 passed; **real end-to-end run against a live local Gitea sandbox** (`npm run test:e2e -- --provider gitea`) — 14/14 E2E tests passed (gitea contract suite + SyncManager suite), including a real Docker container provision/seed/cleanup cycle. GitHub/GitLab E2E legs are written and typecheck/lint clean but weren't run live (no sandbox credentials in this environment) — same known gap the pre-Phase-1 harness had, documented in `docs/testing/real-provider-e2e.md`'s "Known gaps". Self-audit of `docs/obsidian-scanner-audit.md`'s grep method against the new tree: zero hits for `fetch`/`globalThis`/`node:crypto`/`node:child_process`/`node:util`/bare-timers in `e2e/**` or `src/**`.
-- [x] Real-provider E2E Phase 0 reconcile: merged `origin/main` (scanner-driven E2E removal, v1.5.8) into `test/real-provider-e2e-work`, keeping the old `e2e/**` tree temporarily (added `e2e/**`/`vitest.e2e.config.ts` to `eslint.config.mts` `globalIgnores` as an interim measure — not in `tsconfig.json` `include` either, both to be resolved for real by the Phase 1 harness rewrite), then merged `origin/claude/unify-push-pull-pipeline` (new unified `SyncManager.pushFiles` API) cleanly (disjoint file sets, only `package-lock.json` auto-merged). `npx eslint .` — 0 errors; `npm run build` (incl. Obsidian 1.11.0 compat typecheck) — clean; `npx vitest run` — 527 tests passed.
-- [x] `fix(sync): ensure parent dirs exist when reverting file moves` (issue #94): extracted `ensureParentDirs()` to `src/utils/vault-path.ts` and called it before rename in both `revertMove` and `revertMoveGroup`, fixing "folder does not exist" error when reverting moves to deleted parent folders. `npx eslint .` — 0 errors; `npm run build` — clean; `npx vitest run` — 502 tests passed.
-- [x] `fix(gitlab): fix sha/revision semantics for optimistic locking` (issue #101, PR #113, merged): `GitFile.sha` now consistently represents blob identity across providers; added `GitFile.revision` for provider-specific write control.
-
-Full history of completed features (feat-001 through feat-024) archived to [archive/2026-07.md](./archive/2026-07.md). August work archived to [archive/2026-08.md](./archive/2026-08.md).
+1. Run the new two-client e2e suite on a Linux/CI shell (local macOS system bash 3.2 can't run `scripts/e2e-harness.sh provision` — pre-existing `${var@Q}` bash-4-ism, not this session's change): `npm run test:e2e -- --provider gitea` exercises `e2e/suites/two-client-sync.e2e.test.ts` (now registered in `scripts/e2e-suites.txt`).
+2. Manual Obsidian verification of the prior UI rounds (see handoff); commit working tree; push → CI → merge flow.
+3. P0-4 (delete/modify) and P0-5 (rename/modify) are written as SAFETY INVARIANTS, not semantics: if production's current behavior silently loses content, the test goes RED — file `fix(sync): prevent silent data loss on divergent operations` follow-up in that case (per plan, likely a separate PR).
+4. Next phases (not started): Phase 4 divergence matrix (add/add, rename/rename, reverse delete/modify, mixed batch), Phase 5 offline/restart, Phase 6 failure/recovery, Phase 7 stress (scheduled/manual only).
+
+## Verification Evidence
+
+This session (follow-up round on the same PR — `test(e2e): isolate and streamline two-client sync scenarios`):
+
+- Phase 1 (correctness, requested follow-up to the prior round): extracted the vaultFolder path-mapping rules (`filterPathByVaultFolder`/`filterFilesByVaultFolder`/`getNormalizedVaultPath`/`getVaultPathFromNormalized`) into a new pure module `src/logic/sync/vault-folder-scope.ts`, shared by `src/main.ts` (delegates now, behavior unchanged), `SyncScanner.toRepoPath` (delegates now, behavior unchanged), and `two-client-sync-scenario.ts`'s `TwoClient` wiring (now imports the same functions instead of a hand-copied duplicate) — so production and the E2E fixture can never silently drift apart on this logic again.
+- Phase 2 (remove redundant work):
+ - P0-1: dropped the second `s.baseline(other, ...)` — `other` was baselined then immediately treated as "A creates a new file", which was actually exercising modify, not create. `other` is now a genuine create (never baselined), one fewer real provider push + verifier read, and the test now actually covers the create→remote→pull path its comment claims.
+ - P0-2: dropped its trailing `expectIdempotent(ctx)` + second `expectTwoClientConvergence(ctx)` — idempotency-under-repeated-sync is already covered by P0-1's own `expectIdempotent`; P0-2's contract is "concurrent edits on different files both survive", which the first `expectTwoClientConvergence` + explicit remote-content checks already prove. Removes 3 extra full sync rounds (`A.sync/B.sync/A.sync`) worth of provider round trips per run.
+ - `convergence-assertions.ts`: added `captureRemoteSnapshot`/`RemoteSnapshot` — one `getFile` per tracked path + one `listFiles`, captured once — and `expectConverged`/`expectMetadataConsistent` now accept an optional snapshot instead of each independently re-fetching the same remote files. `expectTwoClientConvergence` captures one snapshot and passes it to both, roughly halving the verifier calls per convergence check.
+- Phase 3 (measurement): `captureRemoteSnapshot` is now wrapped in the existing opt-in `timed()` helper (`E2E_TIMING_DEBUG=1`) as `"remote snapshot (verifier)"`, alongside the prior round's `refresh`/`sync`/`baseline` timings — covers the plan's tree-listing/refresh/push/pull/verifier attribution list. Per-test total duration is already reported natively by vitest's own output; not hand-rolled separately.
+- Explicitly NOT done this round (per plan): no GitLab-provider-side server-side `rootPath` tree-listing optimization, no timeout/retry changes, no production sync **semantics** changes — `main.ts`/`SyncScanner.ts` changes here are a pure logic-preserving extraction only.
+- `npx eslint .` — 0 errors, 1 pre-existing unrelated warning (`obsidian-request-url.ts`'s unused `_T` generic).
+- `npm run build` (tsc + Obsidian 1.11.0 compat typecheck + esbuild) — passed.
+- `npx vitest run` — 68 files / 862 tests passed (same count as before this round — the extraction is behavior-preserving, no new/removed unit tests).
+- **Not verified in this environment**: a real multi-suite E2E run proving the new P0-1/P0-2 timings land in the plan's target ranges (35–50s / 25–40s) and that GitLab stops hitting 120s — needs `scripts/run-e2e.sh --provider gitlab|github|gitea` against a real provisioned branch/CI (no Docker daemon / provider credentials in this environment).
+
+This session (test(e2e): isolate two-client sync scope — follow-up to the CI-run-33358507732 triage):
+
+- Root cause of the P0-1..P0-5 slowness/timeout risk: `two-client-sync.e2e.test.ts`'s fixture (`createSyncManagerFixture()`) built settings with `rootPath: ''`/`vaultFolder: ''`, and `TwoClient`'s `SyncStatusRefreshService` wiring bypassed vault-folder filtering entirely (`filterFilesByVaultFolder: files => files`, `filterPathByVaultFolder: () => true`). Every `refresh()` therefore listed and classified the WHOLE shared branch's remote tree — every other suite's `e2e-sc-*` fixtures included — not just this run's `e2e-tc-/` namespace.
+- Fixed via the real production rootPath/vaultFolder model, not a test-only filter: `createSyncManagerFixture({ scoped: true })` (new opt-in option, `e2e-tests/provider/support/sync-manager-fixture.ts`) now generates its `runId` up front and configures BOTH the git service's own `rootPath` (`e2e-tests/provider/config/env.ts`'s `contextFor`/`githubContext`/`gitlabContext`/`giteaContext` now take a `rootPath` param, threaded into `service.updateConfig`) and `settings.vaultFolder` to the same `e2e-tc-` value. Because `vaultFolder` and `rootPath` are set identically, the local-vault-path ⇄ repo-relative-path round trip cancels out symmetrically: `SyncScanner.toRepoPath` strips `vaultFolder` before calling the service, and the service's own `rootPath` re-adds the same prefix when resolving the real remote path — so push/pull targets are unchanged, but `SyncStatusRefreshService.getNormalizedRemotePath` (already reading `settings().rootPath`) now actually scopes remote-tree classification, and `filterFilesByVaultFolder`/`filterPathByVaultFolder`/`getNormalizedPath`/`getVaultPath` in `two-client-sync-scenario.ts`'s `TwoClient` wiring were changed from test-only bypasses to the same vaultFolder-prefix logic `src/main.ts` uses in production.
+- Added a fail-fast scope-leakage guard: `TwoClient.refresh()` now asserts every classified change's path starts with `e2e-tc-/` immediately after refresh, so a future regression in this isolation fails in seconds instead of surfacing as a 120s suite timeout.
+- Added opt-in timing diagnostics (`e2e-tests/provider/support/timing-diagnostics.ts`, gated on `E2E_TIMING_DEBUG=1`, silent otherwise) around `refresh`/`sync`/`baseline`, so a future slow CI run can be attributed to a specific phase (tree listing / refresh / push / pull) instead of only "the test approached 120s".
+- Scope: E2E fixture/support/diagnostics only — did not touch `E2E_TEST_TIMEOUT_MS`, retry policy, or `src/` production sync code. `path()`-based test bodies in `two-client-sync.e2e.test.ts` (P0-1..P0-5) needed no changes — the vaultFolder/rootPath symmetry keeps their existing `s.path('...')` full-path convention working unchanged.
+- `npx eslint .` — 0 errors, 1 pre-existing unrelated warning (`obsidian-request-url.ts`'s unused `_T` generic).
+- `npm run build` (tsc + Obsidian 1.11.0 compat typecheck + esbuild) — passed.
+- `npx vitest run` — 68 files / 862 tests passed.
+- **Not verified in this environment**: an actual multi-suite-sharing-one-branch E2E run proving the leakage is gone in practice (needs `scripts/run-e2e.sh --provider gitlab|github|gitea` against a real provisioned branch/CI, per the plan's verification matrix — this environment has no Docker daemon / provider credentials).
+
+This session (#142 — e2e/ → e2e-tests/provider/ scanner-boundary move, static runtime files):
+
+- Moved `e2e/{config,shim,suites,support}` → `e2e-tests/provider/{config,shim,suites,support}` (git mv, history preserved); deleted `e2e/runtime-modules.d.ts` and `e2e/verifier-runtime-types.ts`.
+- Replaced `scripts/e2e-harness.sh`'s `generate_runtime()` (which wrote `obsidian-request-url.ts`/`window-timers.ts`/`git-verifier.ts` per-run into `$E2E_RUNTIME_DIR`) with committed static files at `e2e-tests/provider/runtime/{obsidian-request-url,window-timers}.ts` and `e2e-tests/provider/support/git-verifier.ts`. `GitVerifier` now reads its clone path from `process.env.E2E_WORKDIR` at call time instead of a shell-baked constructor default — verified directly against a throwaway local git repo (`listFiles`/`getFile`/`listCommitShas` all correct).
+- Side effect: removing `generate_runtime()` also removed the file's only `${var@Q}` bash-4-ism, which previously blocked `scripts/e2e-harness.sh provision` under macOS system bash 3.2 (see "Outstanding Items" #1 below — that specific blocker no longer applies, though a live run still needs Docker, which this sandbox doesn't have running).
+- Updated `scripts/run-e2e.sh`, `scripts/e2e-suites.txt`, `vitest.e2e.config.ts`, `tsconfig.json`, `eslint.config.mts`, `.github/workflows/ci.yml` (`e2e-relevant` filter: `e2e/**` → `e2e-tests/**`, added `scripts/e2e-suites.txt`/`vitest.e2e.config.ts`, added `src/logic/sync/**`/`src/logic/source-control/**` — these were exercised by the E2E suites but not previously watched by the path filter) for the new layout.
+- Extended `tests/ci-workflow.test.ts` with contract assertions for the new paths and for the absence of `E2E_RUNTIME_DIR`/`generate_runtime`/`@e2e-runtime`.
+- Manually replayed `scripts/run-e2e.sh`'s forward/reverse suite-manifest checks against the new paths (bash snippet, no Docker needed) — both pass.
+- `npx eslint .` — 0 errors, 1 pre-existing-shape warning (unused `_T` generic in the committed `AbstractInputSuggest<_T>` stand-in, matches obsidian's real generic shape).
+- `npm run build` (tsc + Obsidian 1.11.0 compat typecheck + esbuild) — passed.
+- `npx vitest run` — 68 files / 857 tests passed.
+- **Not verified in this environment**: an actual local Gitea E2E run (`npm run test:e2e -- --provider gitea`) — Docker is installed but its daemon isn't reachable/running in this sandbox. The suite-manifest and `GitVerifier` logic were validated by other means above, but the full provision→seed→vitest→cleanup path was not exercised end-to-end here.
+- Filed #143 (`test: reduce real-provider API pressure`) as a separate follow-up for CI retry/tiering — out of scope for this PR, not touched here.
+
+This session (Source Control error-handling fixes, code-review follow-up + small cleanups):
+
+- `DiffStatProvider.clear()` now also clears the per-row `generations` map (previously only `cache`/`queued`/`active` were cleared, so `generations` grew unbounded across refreshes); added a white-box regression test.
+- `styles.css` `.batch-conflict-row`: replaced the ambiguous multicol-spec `column-gap` with the unambiguous `gap` shorthand (same grid layout, fixes an "Unexpected browser feature 'multicolumn' is only partially supported by Obsidian 1.9.12" lint warning).
+- `npx eslint .` — 0 errors; `npx vitest run` — 68 files / 850 tests passed; `npm run build` — passed.
+
+
+- Fixed 4 review findings against `SourceControlActionService.ts` / `PushExecutor.ts`: (1) `resolveConflict`'s local-push branch now checks `PushResults.errors` instead of assuming success whenever the workspace call doesn't throw; (2) `sync()`'s `planPush`/`planPull`/`confirmPlan` phase is now wrapped in `try/catch` (extracted into `planSync()`) so a planning rejection fails the batch and notifies instead of becoming an unhandled rejection; (3) `PushExecutor` now isolates local metadata bookkeeping (`updateMetadata`/`clearMetadata`) from the remote mutation call via `persistMetadata`/`persistMetadataClear`, so a metadata-write failure after a successful remote commit/push/delete no longer misreports the whole chunk as failed; (4) `sync()`'s remote-commit and pull phases (extracted into `commitRemoteBucket()`/`applyPullBucket()`) now have independent error boundaries, so a pull failure no longer fails already-succeeded push/delete targets.
+- `npx eslint .` — 0 errors
+- `npm run build` (+ Obsidian 1.11.0 compat typecheck) — passed
+- `npx vitest run` — 68 files / 849 tests passed (5 new regression tests added: 1 in `PushExecutor.test.ts`, 4 in `SourceControlActionService.test.ts`)
+
+Prior session (Multi-client E2E hardening):
+
+- `npx eslint .` — 0 errors (4 new files, no warnings)
+- `npx vitest run` — 68 files / 844 tests passed
+- `npm run build` (+ Obsidian 1.11.0 compat typecheck) — passed
+- `E2E_RUNTIME_DIR= E2E_PROVIDER=gitea npx vitest -c vitest.e2e.config.ts run e2e/suites/two-client-sync.e2e.test.ts` — suite collects all 5 P0 tests and wires real mocks/fixtures; fails only at provider-credential load (expected locally without harness env). Full run requires CI/`run-e2e.sh` (blocked locally by pre-existing bash-3.2 `${var@Q}` issue in `scripts/e2e-harness.sh`).
+- New files: `e2e/support/two-client-sync-scenario.ts`, `e2e/support/convergence-assertions.ts`, `e2e/suites/two-client-sync.e2e.test.ts`; extended `e2e/shim/fake-vault.ts` (paths/getFiles/getAbstractFileByPath/adapter.list/adapter.stat for the real refresh pipeline), `e2e/support/sync-manager-fixture.ts` (conflictResolver getter), `scripts/e2e-suites.txt` (suite registration).
+
+This session (CI run 33358507732 triage — `feat(source-control): replace sync status panel with the source control workflow`, PR #129):
+
+- Diagnosed the `github` provider E2E leg: `firstsun-dev/obsidian-sync-test`'s `main` branch had accumulated ~1,307 leftover `bench-61-*` files from an old manual perf run, never cleaned up. Every CI run clones that bloated `main`, and the two-client-sync suite's per-file remote pulls against it exhausted GitHub's API rate limit (2,669 "rate limit exceeded" hits in the log), cascading into failures across the whole run's retries. Fixed by removing the `bench-61-*` debris from `main` directly (outside this repo).
+- Diagnosed the `gitlab` leg (clean fixture repo, no pollution) and found the same underlying symptom independent of repo size: `two-client-sync.e2e.test.ts`'s P0-1..P0-4 tests each hang silently for exactly their 120s `testTimeout` with zero log output — consistent with a stalled `fetch()` that never resolves rather than an application-level deadlock, since `e2e-tests/provider/runtime/obsidian-request-url.ts`'s `requestUrl` shim had no network timeout at all.
+- Fixed: added a 30s `AbortSignal.timeout` to the shim's `fetch()` call so a stalled connection fails fast with a clear error instead of masquerading as a hang until the suite's own timeout. Does not by itself prove/disprove a real sync-logic deadlock — if CI still times out here after this fix, that's stronger evidence of an actual bug in `two-client-sync`, not infra flakiness (see #143 for the pre-existing "reduce real-provider API pressure" follow-up).
+- `npx eslint e2e-tests/provider/runtime/obsidian-request-url.ts` — 0 errors, 1 pre-existing warning (unused `_T` generic).
+- `npm run build` (tsc + Obsidian 1.11.0 compat typecheck + esbuild) — passed.
+- `npx vitest run` — 68 files / 862 tests passed.
+
+Prior round evidence (UI refactor rounds above) — see git log + [archive/2026-08.md](./archive/2026-08.md) at next archive pass.
\ No newline at end of file
diff --git a/scripts/e2e-harness.sh b/scripts/e2e-harness.sh
index 46464c4..33ab777 100755
--- a/scripts/e2e-harness.sh
+++ b/scripts/e2e-harness.sh
@@ -6,8 +6,7 @@
#
# Subcommands:
# provision create/resolve the isolated test branch (or, for gitea,
-# the whole disposable container+repo) and generate the
-# Node-only vitest runtime adapters under $E2E_RUNTIME_DIR
+# the whole disposable container+repo)
# seed write deterministic baseline fixtures to the branch
# verify independent post-run sanity check (branch exists, has
# the expected number of commits) — the fine-grained,
@@ -54,8 +53,7 @@ fi
# local dev falls back to a provider-namespaced (not random) tmp dir so
# sequential `npm run test:e2e` steps in the same shell session share it too.
workdir="${E2E_WORKDIR:-${TMPDIR:-/tmp}/gfs-e2e-${provider}}"
-runtime_dir="${E2E_RUNTIME_DIR:-$workdir/runtime}"
-mkdir -p "$workdir" "$runtime_dir"
+mkdir -p "$workdir"
keep_branch=0
case "${E2E_KEEP_BRANCH:-}" in
@@ -64,6 +62,29 @@ esac
log() { echo "[e2e-harness:$provider] $*" >&2; }
+git_network() {
+ local args=("$@")
+ local start_ms
+ start_ms=$(date +%s%3N 2>/dev/null || echo $(($(date +%s) * 1000)))
+ local desc="${args[*]}"
+ log "git $desc ... (45s timeout)"
+ local ret=0
+ if command -v timeout >/dev/null 2>&1; then
+ timeout --kill-after=5s 45s git "${args[@]}" || ret=$?
+ else
+ git -c http.lowSpeedLimit=1024 -c http.lowSpeedTime=30 "${args[@]}" || ret=$?
+ fi
+ local end_ms
+ end_ms=$(date +%s%3N 2>/dev/null || echo $(($(date +%s) * 1000)))
+ local elapsed_ms=$((end_ms - start_ms))
+ if [ "$ret" -eq 0 ]; then
+ log "git $desc completed in ${elapsed_ms}ms"
+ else
+ log "git $desc failed after ${elapsed_ms}ms (exit $ret)"
+ return "$ret"
+ fi
+}
+
# --- credential-sensitive helpers -------------------------------------------
# Generates a throwaway GIT_ASKPASS helper under $RUNNER_TEMP (falls back to
@@ -158,9 +179,10 @@ ensure_clone() {
local dir; dir=$(clone_dir)
if [ ! -d "$dir/.git" ]; then
log "Cloning $E2E_TEST_REPO_URL"
- git clone --no-tags --filter=blob:none "$E2E_TEST_REPO_URL" "$dir"
+ git_network clone --no-tags --filter=blob:none "$E2E_TEST_REPO_URL" "$dir"
else
- git -C "$dir" fetch origin --prune
+ log "Fetching existing clone"
+ git_network -C "$dir" fetch origin --prune
fi
}
@@ -179,10 +201,9 @@ cmd_provision() {
base_sha=$(git -C "$dir" rev-parse "origin/${E2E_BASE_BRANCH}")
export E2E_TEST_BRANCH="${E2E_TEST_BRANCH:-$(namespace)}"
log "Creating isolated branch $E2E_TEST_BRANCH off ${E2E_BASE_BRANCH} (${base_sha})"
- git -C "$dir" push origin "${base_sha}:refs/heads/${E2E_TEST_BRANCH}"
+ git_network -C "$dir" push origin "${base_sha}:refs/heads/${E2E_TEST_BRANCH}"
fi
- generate_runtime
write_env_file
}
@@ -203,7 +224,8 @@ EOF
if ! git -C "$dir" diff --cached --quiet; then
git -C "$dir" -c user.email="e2e@git-files-sync.local" -c user.name="git-files-sync E2E" \
commit -m "chore(e2e): seed baseline fixture for ${E2E_TEST_BRANCH}"
- git -C "$dir" push origin "HEAD:refs/heads/${E2E_TEST_BRANCH}"
+ log "Seeding commit to $E2E_TEST_BRANCH"
+ git_network -C "$dir" push origin "HEAD:refs/heads/${E2E_TEST_BRANCH}"
fi
}
@@ -211,7 +233,8 @@ cmd_verify() {
load_env_file
setup_askpass
local dir; dir=$(clone_dir)
- git -C "$dir" fetch origin "$E2E_TEST_BRANCH"
+ log "Fetching $E2E_TEST_BRANCH for verification"
+ git_network -C "$dir" fetch origin "$E2E_TEST_BRANCH"
local head; head=$(git -C "$dir" rev-parse "origin/$E2E_TEST_BRANCH")
log "Branch $E2E_TEST_BRANCH exists at $head"
}
@@ -234,184 +257,40 @@ cmd_cleanup() {
fi
local dir; dir=$(clone_dir)
log "Deleting isolated branch $E2E_TEST_BRANCH"
- git -C "$dir" push origin ":refs/heads/${E2E_TEST_BRANCH}" || true
-}
-
-# --- generated vitest runtime (never committed) -----------------------------
-
-# Everything under here is Node-only glue (fetch/globalThis/node:child_process)
-# equivalent to what used to live in e2e/shim + e2e/verifier as committed
-# .ts files -- generated fresh per run instead, so the checked-in suites stay
-# free of the APIs the Obsidian scanner flags. See section 6/7 of the task
-# and docs/testing/real-provider-e2e.md.
-generate_runtime() {
- mkdir -p "$runtime_dir/verifier"
-
- cat >"$runtime_dir/obsidian-request-url.ts" <<'EOF'
-import type { RequestUrlParam, RequestUrlResponse } from 'obsidian';
-
-export async function requestUrl(request: RequestUrlParam | string): Promise {
- const params: RequestUrlParam = typeof request === 'string' ? { url: request } : request;
- const shouldThrow = params.throw ?? true;
- const headers: Record = { ...params.headers };
- if (params.contentType && !headers['Content-Type']) headers['Content-Type'] = params.contentType;
- const res = await fetch(params.url, { method: params.method ?? 'GET', headers, body: params.body });
- const arrayBuffer = await res.arrayBuffer();
- const text = new TextDecoder().decode(arrayBuffer);
- let json: unknown;
- try { json = text ? JSON.parse(text) : undefined; } catch { json = undefined; }
- const response: RequestUrlResponse = { status: res.status, headers: Object.fromEntries(res.headers.entries()), arrayBuffer, text, json };
- if (shouldThrow && res.status >= 400) {
- const error = new Error(`Request failed, status ${res.status}`);
- (error as Error & { status: number }).status = res.status;
- throw error;
- }
- return response;
-}
-
-export class Modal {
- app: unknown;
- constructor(app?: unknown) { this.app = app; }
- open(): void {}
- close(): void {}
-}
-export class PluginSettingTab { constructor(_app?: unknown, _plugin?: unknown) {} }
-export class TextComponent {}
-export class AbstractInputSuggest<_T> { constructor(_app: unknown, _inputEl: unknown) {} }
-export class TFolder { path: string; constructor(path: string) { this.path = path; } }
-export class Setting { constructor(_containerEl?: unknown) {} }
-export class TFile {
- path: string;
- name: string;
- constructor(path: string) { this.path = path; this.name = path.split('/').pop() ?? path; }
-}
-export class Notice {
- constructor(_message?: string, _timeout?: number) {}
- setMessage(): this { return this; }
- hide(): void {}
-}
-export const Platform = { isDesktopApp: false, isMobile: false };
-export class FileSystemAdapter { getBasePath(): string { return '/e2e/fake-vault'; } }
-EOF
-
- cat >"$runtime_dir/window-timers.ts" <<'EOF'
-if (typeof (globalThis as { window?: unknown }).window === 'undefined') {
- (globalThis as unknown as { window: typeof globalThis }).window = globalThis;
-}
-EOF
-
- local repo_dir; repo_dir=$(clone_dir)
- cat >"$runtime_dir/verifier/git-verifier.ts" < {
- this.fetch(ref);
- try {
- const sha = this.git(['rev-parse', \`origin/\${ref}:\${path}\`]).trim();
- const content = this.git(['show', \`origin/\${ref}:\${path}\`]);
- return { content, sha };
- } catch {
- return null;
- }
- }
-
- async listFiles(ref: string): Promise {
- this.fetch(ref);
- return this.git(['ls-tree', '-r', '--name-only', \`origin/\${ref}\`])
- .split('\\n')
- .filter(Boolean);
- }
-
- async fileMissing(path: string, ref: string): Promise {
- return (await this.getFile(path, ref)) === null;
- }
-
- async listCommitShas(ref: string, perPage = 30): Promise {
- this.fetch(ref);
- return this.git(['log', '--format=%H', '-n', String(perPage), \`origin/\${ref}\`])
- .split('\\n')
- .filter(Boolean);
- }
-
- /** Git tree mode at path (e.g. "120000" for a symlink). */
- async getBlobMode(path: string, ref: string): Promise {
- this.fetch(ref);
- const line = this.git(['ls-tree', \`origin/\${ref}\`, '--', path]).trim();
- if (!line) return null;
- return line.split(/\\s+/)[0] ?? null;
- }
-
- async getCommitMessage(sha: string): Promise {
- return this.git(['log', '-1', '--format=%B', sha]).trim();
- }
-
- /** Last commit sha that touched path -- GitLab's optimistic-locking "revision". */
- async getRevision(path: string, ref: string): Promise {
- this.fetch(ref);
- const sha = this.git(['log', '-1', '--format=%H', \`origin/\${ref}\`, '--', path]).trim();
- return sha || null;
- }
-}
-EOF
- log "Generated vitest runtime adapters under $runtime_dir"
+ git_network -C "$dir" push origin ":refs/heads/${E2E_TEST_BRANCH}" || true
}
# --- gitea container lifecycle (shell/docker, never node:child_process) -----
provision_gitea_container() {
local image="${E2E_GITEA_IMAGE:-gitea/gitea:1.22}"
- local name="gfs-e2e-gitea-$$"
+ local run_id="${GITHUB_RUN_ID:-local-$(date +%s)}"
+ local run_attempt="${GITHUB_RUN_ATTEMPT:-1}"
+ local name="gfs-e2e-gitea-${run_id}-${run_attempt}-$$"
log "Starting gitea container ($image)"
- # No -p host-port mapping: on a self-hosted runner that is *itself* a
- # sibling container of the Docker daemon (confirmed to be this fleet's
- # topology -- a published host port + `127.0.0.1` is only reachable from
- # the Docker host's own network namespace, not from a sibling container's),
- # a host-port + 127.0.0.1 URL is unreachable. The container's own bridge
- # IP is reachable from any container on the same (default) Docker
- # network, including the runner itself, whether the runner is bare-metal
- # or a sibling container -- so use that instead.
+ # Gitea runs beside this script on a developer machine or fresh
+ # GitHub-hosted VM. Publishing to loopback avoids Docker bridge-IP routing
+ # assumptions and asks Docker for a collision-free host port.
docker run -d --name "$name" \
+ -p 127.0.0.1::3000 \
-e GITEA__security__INSTALL_LOCK=true \
"$image" >/dev/null
echo "$name" >"$workdir/gitea-container-name"
- # Retry: docker run -d returns before the network attachment always has
- # an IP assigned yet on every runner/docker version observed.
- local container_ip=""
+ local host_port=""
for _ in 1 2 3 4 5 6 7 8 9 10; do
- container_ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$name")
- [ -n "$container_ip" ] && break
+ host_port=$(docker port "$name" 3000/tcp | sed -n 's/^127\.0\.0\.1://p' | head -n 1)
+ [ -n "$host_port" ] && break
sleep 1
done
- if [ -z "$container_ip" ]; then
- echo "gitea container never got a network IP (docker inspect empty)" >&2
+ if [ -z "$host_port" ]; then
+ echo "gitea container never published a localhost port" >&2
docker logs "$name" >&2 || true
exit 1
fi
# NOSONAR-justified plain HTTP (shell:S5332, x5 below): base_url never
- # leaves the Docker bridge network this run created -- container_ip is a
- # per-run internal address, admin_pass/token are freshly random and
- # discarded when the container is torn down at cleanup, and there is no
- # TLS-terminating endpoint to speak to on an ephemeral local sandbox
- # container. Not a real clear-text-credential exposure.
- local base_url="http://${container_ip}:3000" # NOSONAR
+ # leaves loopback; credentials are per-run and discarded at cleanup.
+ local base_url="http://127.0.0.1:${host_port}" # NOSONAR
local ready_ms="${E2E_CONTAINER_READY_MS:-60000}"
local poll_ms="${E2E_POLL_INTERVAL_MS:-500}"
@@ -483,12 +362,11 @@ write_env_file() {
echo "E2E_BASE_BRANCH=$E2E_BASE_BRANCH"
echo "E2E_TEST_BRANCH=$E2E_TEST_BRANCH"
echo "E2E_WORKDIR=$workdir"
- echo "E2E_RUNTIME_DIR=$runtime_dir"
# Not a credential itself -- the token lives only in the mode-700
# askpass file on disk at this path (still present for later steps
# in the same job, since it's written under $RUNNER_TEMP). Every git
- # call the generated verifier makes (used by the vitest step, which
- # never runs this script) needs these two set to authenticate.
+ # call the committed GitVerifier makes (used by the vitest step,
+ # which never runs this script) needs these two set to authenticate.
echo "GIT_ASKPASS=$GIT_ASKPASS"
echo "GIT_TERMINAL_PROMPT=0"
} >"$env_file"
diff --git a/scripts/e2e-suites.txt b/scripts/e2e-suites.txt
new file mode 100644
index 0000000..7a42629
--- /dev/null
+++ b/scripts/e2e-suites.txt
@@ -0,0 +1,11 @@
+# E2E suite manifest — the single source of truth for which vitest suites run
+# per provider. scripts/run-e2e.sh reads this, expands ${provider}, and runs
+# them; CI calls run-e2e.sh so this list is never duplicated in the workflow.
+# scripts/run-e2e.sh's own forward/reverse checks enforce that every
+# e2e-tests/provider/suites/*.e2e.test.ts is registered here: the ${provider} line covers the
+# provider-specific suites (github/gitlab/gitea); every other shared suite
+# must be listed explicitly, or the run fails.
+e2e-tests/provider/suites/${provider}.e2e.test.ts
+e2e-tests/provider/suites/sync-manager.e2e.test.ts
+e2e-tests/provider/suites/source-control-flows.e2e.test.ts
+e2e-tests/provider/suites/two-client-sync.e2e.test.ts
\ No newline at end of file
diff --git a/scripts/run-e2e.sh b/scripts/run-e2e.sh
index 247502c..72e47a3 100755
--- a/scripts/run-e2e.sh
+++ b/scripts/run-e2e.sh
@@ -1,16 +1,21 @@
#!/usr/bin/env bash
# Thin local-dev orchestration around scripts/e2e-harness.sh: provision the
# isolated branch/container, seed a baseline fixture, run the provider's
-# vitest suite + the SyncManager suite, then clean up (even on failure). CI
-# drives the same four steps directly from .github/workflows/ci.yml instead,
-# so each shows up as its own job step.
+# vitest suites, then clean up (even on failure). CI calls this same script
+# (see .github/workflows/ci.yml), so the suite list lives in exactly one
+# place: scripts/e2e-suites.txt. Add a new shared suite there and both local
+# and CI pick it up; this script's own forward/reverse checks below fail the
+# run if a suite file isn't registered (or a manifest entry doesn't exist).
set -euo pipefail
provider=""
+tier="auto"
while [[ $# -gt 0 ]]; do
case "$1" in
--provider) provider="$2"; shift 2 ;;
--provider=*) provider="${1#*=}"; shift ;;
+ --tier) tier="$2"; shift 2 ;;
+ --tier=*) tier="${1#*=}"; shift ;;
*) shift ;;
esac
done
@@ -19,14 +24,49 @@ if [ -z "$provider" ]; then
exit 1
fi
+if [ "$tier" = "auto" ]; then
+ if [ "${GITHUB_ACTIONS:-}" != "true" ]; then
+ tier="full"
+ elif [ "$provider" = "github" ] && { [ "${GITHUB_REF_NAME:-}" = "main" ] || [ "${GITHUB_REF_NAME:-}" = "master" ] || [ "${GITHUB_EVENT_NAME:-}" = "schedule" ] || [ "${GITHUB_EVENT_NAME:-}" = "workflow_dispatch" ]; }; then
+ tier="full"
+ else
+ tier="core"
+ fi
+fi
+if [ "$tier" != "core" ] && [ "$tier" != "full" ]; then
+ echo "Invalid E2E tier: $tier (expected core|full|auto)" >&2
+ exit 1
+fi
+
export E2E_PROVIDER="$provider"
-export E2E_WORKDIR="${E2E_WORKDIR:-${TMPDIR:-/tmp}/gfs-e2e-${provider}}"
+export E2E_TIER="$tier"
+created_workdir=0
+if [ -z "${E2E_WORKDIR:-}" ]; then
+ E2E_WORKDIR=$(mktemp -d "${TMPDIR:-/tmp}/gfs-e2e-${provider}.XXXXXX")
+ created_workdir=1
+fi
+export E2E_WORKDIR
cleanup() {
scripts/e2e-harness.sh cleanup || true
+ if [ "$created_workdir" -eq 1 ] \
+ && [[ ! "${E2E_KEEP_BRANCH:-}" =~ ^(1|true)$ ]]; then
+ case "$E2E_WORKDIR" in
+ "${TMPDIR:-/tmp}/gfs-e2e-${provider}."*) rm -rf -- "$E2E_WORKDIR" ;;
+ esac
+ fi
}
trap cleanup EXIT
+reset_retry_state() {
+ local repo_dir="$E2E_WORKDIR/repo"
+ rm -rf -- "$repo_dir"
+ rm -f -- "$E2E_WORKDIR/e2e.env" "$E2E_WORKDIR/e2e.secrets.env"
+ echo "[run-e2e] Reset local state for retry attempt" >&2
+}
+
+reset_retry_state
+
scripts/e2e-harness.sh provision
# Credentials/run-state provision resolved (E2E_TEST_BRANCH, E2E_RUNTIME_DIR,
# and -- gitea only -- the generated container token) live in $E2E_WORKDIR,
@@ -36,8 +76,74 @@ scripts/e2e-harness.sh provision
set -a; source "$E2E_WORKDIR/e2e.env"; [ -f "$E2E_WORKDIR/e2e.secrets.env" ] && source "$E2E_WORKDIR/e2e.secrets.env"; set +a
scripts/e2e-harness.sh seed
-# Only this provider's contract suite + the shared SyncManager suite --
-# vitest.e2e.config.ts's `include` matches every e2e/suites/*.e2e.test.ts
-# file, and the other two providers' suites would otherwise also try to run
-# (and fail on missing credentials) regardless of --provider.
-npx vitest run -c vitest.e2e.config.ts "e2e/suites/${provider}.e2e.test.ts" e2e/suites/sync-manager.e2e.test.ts
+
+# Suite manifest: scripts/e2e-suites.txt (single source of truth). ${provider}
+# expands to the active provider's contract suite; the rest are shared suites.
+# `|| [ -n "$line" ]` keeps the last line even without a trailing newline.
+PROVIDERS=(github gitlab gitea)
+manifest_has_dynamic=0
+SHARED_SUITES=()
+while IFS= read -r line || [ -n "$line" ]; do
+ case "$line" in ''|\#*) continue;; esac
+ if [[ "$line" == *'${provider}'* ]]; then
+ manifest_has_dynamic=1
+ continue
+ fi
+ SHARED_SUITES+=("$line")
+done < scripts/e2e-suites.txt
+
+if [ "$manifest_has_dynamic" -ne 1 ]; then
+ echo "scripts/e2e-suites.txt is missing a \${provider} line -- provider-specific suites (github/gitlab/gitea) would not run." >&2
+ exit 1
+fi
+
+SUITES=("e2e-tests/provider/suites/${provider}.e2e.test.ts" "${SHARED_SUITES[@]}")
+
+# Forward check: every manifest entry (after ${provider} expansion) must
+# exist on disk -- catches a typo'd or deleted suite path in the manifest.
+for suite in "${SUITES[@]}"; do
+ if [[ ! -f "$suite" ]]; then
+ echo "E2E suite not found: $suite" >&2
+ exit 1
+ fi
+done
+
+# Reverse check: every e2e-tests/provider/suites/*.e2e.test.ts file on disk must be either a
+# known provider suite (github/gitlab/gitea -- covered by the ${provider}
+# line regardless of which provider this run targets) or a shared suite
+# explicitly registered in the manifest. Catches a new suite file added
+# without wiring it into scripts/e2e-suites.txt, which would otherwise pass
+# CI without ever running (the exact "fake green" this guards against).
+is_shared_suite() {
+ local candidate="$1" s
+ for s in "${SHARED_SUITES[@]}"; do
+ [[ "$s" == "$candidate" ]] && return 0
+ done
+ return 1
+}
+unregistered=()
+for file in e2e-tests/provider/suites/*.e2e.test.ts; do
+ [ -e "$file" ] || continue
+ base="$(basename "$file" .e2e.test.ts)"
+ is_known_provider=0
+ for p in "${PROVIDERS[@]}"; do
+ [ "$base" = "$p" ] && is_known_provider=1 && break
+ done
+ [ "$is_known_provider" -eq 1 ] && continue
+ is_shared_suite "$file" || unregistered+=("$file")
+done
+if [ "${#unregistered[@]}" -gt 0 ]; then
+ echo "Unregistered E2E suite file(s) -- add to scripts/e2e-suites.txt:" >&2
+ printf ' %s\n' "${unregistered[@]}" >&2
+ exit 1
+fi
+
+echo "[run-e2e] tier=$E2E_TIER provider=$E2E_PROVIDER" >&2
+echo "[run-e2e] running suites: ${SUITES[*]}" >&2
+
+# vitest.e2e.config.ts's `include` matches every e2e-tests/provider/suites/*.e2e.test.ts, so
+# the other two providers' suites would also try to run (and fail on missing
+# credentials) if not explicitly limited to this list. source-control-flows
+# gates its Extended scenarios to GitHub only (and 1000-file stress to
+# E2E_STRESS=1) in-file.
+npx vitest run -c vitest.e2e.config.ts "${SUITES[@]}"
diff --git a/scripts/run-preflight.sh b/scripts/run-preflight.sh
new file mode 100755
index 0000000..e5b8b9f
--- /dev/null
+++ b/scripts/run-preflight.sh
@@ -0,0 +1,19 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+check="${1:-}"
+case "$check" in
+ lint)
+ npm run lint
+ ;;
+ test)
+ npm test
+ ;;
+ build)
+ npm run build
+ ;;
+ *)
+ echo "Usage: scripts/run-preflight.sh " >&2
+ exit 2
+ ;;
+esac
diff --git a/session-handoff.md b/session-handoff.md
index 9a92258..77bf0fa 100644
--- a/session-handoff.md
+++ b/session-handoff.md
@@ -1,44 +1,47 @@
# Session Handoff
-**Date:** 2026-08-20
-**Branch:** `refactor/sync-domain-pipeline` (PR #127)
-**Active Feature:** feat-026 / issue #105 — sync architecture refactor
-
-## Completed This Session
-
-Investigated the failed real-provider CI after the unified planner commit. The move paths passed;
-GitHub exhausted two attempts on a 503 and `UND_ERR_SOCKET`, while GitLab exhausted two attempts
-on provider deadline errors. The tests then surfaced secondary count/existence assertions that
-hid those original request failures.
-
-Hardened CI with three provider attempts, explicit push-result diagnostics in SyncManager E2E,
-and workflow contract coverage. When the shared push/PR concurrency group cancels a duplicate
-matrix, its aggregate gate now reports the replacement neutrally and emits `run-ci=false`, so it
-does not leave an additional aggregate red check or run downstream CI twice. Real failures remain
-blocking. Updated the real-provider E2E documentation to match.
-
-Committed as `948df28` (`fix(ci): harden provider e2e failures`) and pushed to
-`origin/refactor/sync-domain-pipeline`. The pre-existing untracked `.codex-gitlab.env` remains
-untouched.
-
-## Verification Evidence
-
-```text
-npx eslint . -> PASS, 0 errors
-npm run build -> PASS, incl. Obsidian 1.11 compatibility
-npx vitest run -> PASS, 56 files / 613 tests
-npm run test:e2e -- --provider gitea -> PASS, 2 files / 14 tests; container removed
-actionlint v1.7.12 .github/workflows/ci.yml -> PASS, 0 errors
-git diff --check -> PASS
-real CI run 32338116598 -> PASS after failed-only rerun of a disabled Gitea leg assigned to an offline runner
-GitHub/GitLab sandbox branch query -> PASS, no e2e/pr/127 or source-branch refs remain
-```
-
-The AGENTS-required Haiku verifier was unavailable in this environment, so verification ran
-locally in this session.
-
-## Exact Next Step
-
-Complete the remaining Obsidian desktop/mobile move smoke tests. Verify moving and editing a
-tracked file appears under Moves and applies as one remote move, while an occupied remote
-destination remains a skipped conflict.
+**Date:** 2026-08-30
+**Branch / PR:** `claude/source-control-foundation` / PR #129
+**Latest commits:** `ac2bd2a` + uncommitted working tree on top — refactor round + `totalFiles` removal + dense desktop batch list (this session).
+
+## Completed (this session, three rounds)
+
+### Round A — refactor boundary
+1. **[1] SyncDiffService owns conflict stat data** — `getConflictStat()` (binary/text, remote blob memoization, `computeDiffStat`); `main.ts` wires one shared instance to diff tab + `setConflictDiffStatLoader`. Production-wiring test computes a real +1/-2 stat.
+2. **[2] `DiffViewer`** (`src/ui/components/DiffViewer.ts`) — `renderDiffViewer(container, {remote, local, layout, toggleHost?, onLayoutChange?})`. Toggle host is emptied/re-rendered in place; body class swapped, diff body NOT rebuilt. **Gotcha: `toggleHost` must be a dedicated slot (DiffTabView passes `scv-diff-tab-header-toggle`) — passing a container with other children gets them emptied.** Migrated DiffTabView + SyncConflictModal. New `tests/ui/components/DiffViewer.test.ts`.
+3. **[3] `.gfs-conflict-modal`** shell + `--single` (1600px) / `--batch` (1100px) modifiers replace `.sync-conflict-modal`/`.batch-conflict-modal` selectors. Classes on `modalEl` (+ tests/setup.ts Modal mock nests modalEl).
+4. **[4] `.gfs-diff-surface`** — `--scv-diff-*` token block (light + dark) targets only this class; added in SourceControlView (with `scv-root`), DiffTabView, both conflict modals.
+
+### Round B — desktop polish part 1
+5. `+N/-N` beside filename (`.batch-conflict-row-name-line`: flex-start, gap 10px), not pushed to far edge.
+6. **Inline pluralization in `t()`**: `{count|conflict|conflicts}` → value + branch (`1 conflict` / `3 conflicts`), resolved per variable. `.one`-variant key approach was tried and reverted (can't handle count=1/total=3 independence). zh locales unaffected (no inflection).
+
+### Round C — 40-conflict density + compact header + modal split default (latest)
+7. **Desktop ≥900px dense list**: `.batch-conflict-row` becomes `grid-template-columns: minmax(300px,1fr) auto` — identity left (name+stat / dir), actions right (View Diff + radios, `nowrap`, flex-end). Card chrome removed: no per-row bg/rounding/gap; divider list (`border-bottom` + list `border-top`). Row ≈ 52px → target 12-15 rows/viewport. Modal width **stays 1100px** — the width was never the problem; the rows now use it.
+8. **Tablet 700-899px**: stacked but divider-listed (no card chrome), padding 8px.
+9. **Phone <700px**: full stacking, radios wrap below, names/dirs wrap (`break-all`), unchanged from before otherwise.
+10. **Compact header**: `Resolve {count|conflict|conflicts}` (was "Resolve N conflict(s) before pushing M file(s)") + single description line `{safeCount} other {safeCount|file|files}: ready to sync, pushed with this batch.`; description omitted entirely when `safeCount === 0`. **`totalFiles` parameter removed through the whole chain**: `SyncInteractionPort.resolveBatchConflicts(gitService, conflicts, safeCount, diffStatLoader?)` → `ObsidianSyncInteraction` → modal (constructor lost `totalFiles`) → `PushCoordinatorDependencies.resolveConflicts(conflicts, safeCount)` → `SyncManager` adapter. PushCoordinator's `resolvePlanConflicts` lost its `totalFiles` param (it was only threaded to the modal).
+11. **Conflict modal diff opens in split on desktop** (was hardcoded unified): `Platform.isMobile ? 'unified' : 'split'` in `SyncConflictModal.renderTextComparison` — the 1600px desktop modal now matches the diff tab's default; phones keep unified. Tests updated for the desktop default.
+12. **Layout state unified across all surfaces** — `DiffViewer` module now owns both the policy and the memory: `defaultDiffLayout()` (`Platform.isMobile ? 'unified' : 'split'`), `currentDiffLayout()` / `rememberDiffLayout()` (session-wide, not persisted). DiffTabView, SyncConflictModal, and SourceControlView's mobile detail all read `currentDiffLayout()` and write through `rememberDiffLayout` on toggle — switch unified in the modal and the next diff tab/detail opens unified. Mobile detail migrated off its own `mobileDiffLayout` field + raw toggle/panel assembly onto `renderDiffViewer` (empty placeholder body, async `loadAndRenderDiff` fills it; body gets `scv-detail-diff` class to keep the legacy wrapper CSS; dedicated `scv-detail-bar-toggle` slot for the toggle — the viewer empties its host on switch).
+
+## Verification
+
+- `npx eslint .` — 0 errors
+- `npx vitest run` — 68 files / 832 tests passed
+- `npm run build` (+ Obsidian 1.11.0 compat typecheck) — passed
+- e2e fixtures (`e2e/suites/sync-manager.e2e.test.ts`, `e2e/support/sync-manager-fixture.ts`) mock-implementation signatures updated to the new 6-arg constructor; e2e NOT run (real-provider suite, separate command).
+- NOT yet done: manual Obsidian verification (see below).
+
+## Gotchas for the next session
+
+- Modal mock constructor arity changed (7 args): loader is mock `calls[0][6]`, conflicts `calls[0][2]`.
+- **Diff layout default + memory live in `src/ui/components/DiffViewer.ts`**: `currentDiffLayout()` / `rememberDiffLayout()` are session-wide and shared by all three surfaces. `resetDiffLayoutMemoryForTests()` exists if a test needs an isolated default. `renderDiffViewer` empties its `toggleHost` on every switch — always pass a dedicated toggle slot, never a container with other children (bit both DiffTabView's header and the mobile detail bar).
+- Batch row CSS has three media tiers: `min-width:900px` grid / `max-width:899px` stacked / `max-width:700px` phone. The base (unscoped) `.batch-conflict-row` is still the old card style — any viewport ≥900 uses the grid override; the base background/radius only shows if a media query ever fails to match (shouldn't happen; kept as safe fallback).
+- en + zh-tw + zh-cn `batchConflictModal.title/.description` all rewritten; keys unchanged, only template bodies.
+
+## Next Steps
+
+1. Manual verification in Obsidian (desktop ≥900px + iPad + phone): dense grid rows (~52px), dividers not cards, header "Resolve N conflicts", fixed header/bulk/footer with only the list scrolling, `+N -N` still beside filename, dark-theme diff tokens.
+2. Commit working tree — suggested: `refactor(diff): DiffViewer composition + gfs-conflict-modal shell + gfs-diff-surface tokens`, `feat(batch-conflict): dense desktop list + compact header + inline plural copy`, `refactor(sync): drop totalFiles from the batch-conflict interaction port` (or fold the last into #2).
+3. Push → CI → iPad regression → merge plan (standing flow).
+4. Follow-up candidates: CSS source-partials split; pluralize remaining `(s)` keys (`main.confirm.pushAll/pullAll`, `syncPlanModal.deletionWarning`, `sourceControl.push.tooltip`).
\ No newline at end of file
diff --git a/src/changelog/1.6.0/index.ts b/src/changelog/1.6.0/index.ts
new file mode 100644
index 0000000..09b916d
--- /dev/null
+++ b/src/changelog/1.6.0/index.ts
@@ -0,0 +1,92 @@
+import { type ChangelogRelease } from '../types';
+
+export const release: ChangelogRelease = {
+ version: '1.6.0',
+
+ headline: {
+ en: 'A new Source Control workflow',
+ 'zh-tw': '全新的原始碼控制流程',
+ 'zh-cn': '全新的源代码控制流程',
+ },
+ summary: {
+ en: 'Review repository changes, choose what belongs in the Sync Queue, then sync everything from one place.',
+ 'zh-tw': '先檢視儲存庫變更,選擇要放入同步佇列的項目,最後在同一處完成同步。',
+ 'zh-cn': '先查看仓库变更,选择要放入同步队列的项目,最后在同一处完成同步。',
+ },
+
+ onboarding: {
+ action: 'open-source-control',
+ steps: [
+ {
+ title: {
+ en: 'Review Repository Changes',
+ 'zh-tw': '檢視儲存庫變更',
+ 'zh-cn': '查看仓库变更',
+ },
+ description: {
+ en: 'See local edits, remote updates, renames, deletions, and conflicts in one place.',
+ 'zh-tw': '在同一處查看本機編輯、遠端更新、重新命名、刪除與衝突。',
+ 'zh-cn': '在同一处查看本地编辑、远程更新、重命名、删除与冲突。',
+ },
+ },
+ {
+ title: {
+ en: 'Build your Sync Queue',
+ 'zh-tw': '建立同步佇列',
+ 'zh-cn': '建立同步队列',
+ },
+ description: {
+ en: 'Select exactly which changes should be included in the next sync.',
+ 'zh-tw': '精確選擇下一次同步要包含哪些變更。',
+ 'zh-cn': '精确选择下一次同步要包含哪些变更。',
+ },
+ },
+ {
+ title: {
+ en: 'Review and Sync',
+ 'zh-tw': '檢視並同步',
+ 'zh-cn': '查看并同步',
+ },
+ description: {
+ en: 'Uploads, downloads, moves, and remote deletions are combined into one reviewed operation.',
+ 'zh-tw': '上傳、下載、搬移與遠端刪除會合併成一次可檢視的操作。',
+ 'zh-cn': '上传、下载、移动与远程删除会合并成一次可查看的操作。',
+ },
+ },
+ ],
+ },
+
+ entries: [
+ {
+ notable: true,
+ text: {
+ en: '🔀 New Source Control workflow — Review, Queue, then Sync replaces the old select-and-push flow.',
+ 'zh-tw': '🔀 全新的原始碼控制流程 — 「檢視 → 佇列 → 同步」取代了舊有的選取後直接推送流程。',
+ 'zh-cn': '🔀 全新的源代码控制流程 — “查看 → 队列 → 同步”取代了原有的选取后直接推送流程。',
+ },
+ },
+ {
+ notable: true,
+ text: {
+ en: '📋 Unified Sync Queue — Uploads, downloads, and remote deletions are gathered into one queue and applied together.',
+ 'zh-tw': '📋 統一的同步佇列 — 上傳、下載與遠端刪除會集中到同一個佇列,並一併套用。',
+ 'zh-cn': '📋 统一的同步队列 — 上传、下载与远程删除会集中到同一个队列,并一并应用。',
+ },
+ },
+ {
+ notable: true,
+ text: {
+ en: '🗑️ Local deletions now behave predictably — Sync a locally deleted tracked file to remove it remotely, or use Download to restore it.',
+ 'zh-tw': '🗑️ 本機刪除行為更可預期 — 同步已在本機刪除的追蹤檔案會一併移除遠端;也可使用「下載」還原該檔案。',
+ 'zh-cn': '🗑️ 本地删除行为更可预期 — 同步已在本地删除的跟踪文件会一并移除远程;也可使用“下载”还原该文件。',
+ },
+ },
+ {
+ text: {
+ en: '✨ Clear file status indicators and an improved desktop and mobile workflow.',
+ 'zh-tw': '✨ 更清楚的檔案狀態標示,桌面與行動裝置操作體驗也一併優化。',
+ 'zh-cn': '✨ 更清晰的文件状态标识,桌面与移动设备操作体验也一并优化。',
+ },
+ },
+ ],
+};
diff --git a/src/changelog/index.ts b/src/changelog/index.ts
index 40d7786..0068044 100644
--- a/src/changelog/index.ts
+++ b/src/changelog/index.ts
@@ -1,13 +1,21 @@
import { compareVersions } from '../utils/version';
import { getActiveLocale } from '../i18n';
import { type ChangelogEntry, type ChangelogEntryText, type ChangelogRelease } from './types';
+import { release as release_1_6_0 } from './1.6.0';
import { release as release_1_5_0 } from './1.5.0';
import { release as release_1_4_0 } from './1.4.0';
import { release as release_1_3_1 } from './1.3.1';
import { release as release_1_3_0 } from './1.3.0';
import { release as release_1_2_1 } from './1.2.1';
-export { type ChangelogEntry, type ChangelogEntryText, type ChangelogRelease } from './types';
+export {
+ type ChangelogEntry,
+ type ChangelogEntryText,
+ type ChangelogRelease,
+ type ChangelogStep,
+ type ChangelogOnboarding,
+ type ChangelogAction,
+} from './types';
/**
* Hand-curated, user-facing highlights shown in the "what's new" modal after an
@@ -21,12 +29,24 @@ export { type ChangelogEntry, type ChangelogEntryText, type ChangelogRelease } f
* locale files forever. Versions are matched against manifest.json's version
* by exact string, so keep them in sync.
*/
-export const CHANGELOG: ChangelogRelease[] = [release_1_5_0, release_1_4_0, release_1_3_1, release_1_3_0, release_1_2_1];
+export const CHANGELOG: ChangelogRelease[] = [
+ release_1_6_0,
+ release_1_5_0,
+ release_1_4_0,
+ release_1_3_1,
+ release_1_3_0,
+ release_1_2_1,
+];
+
+/** Resolves per-locale text for the active UI locale, falling back to English. */
+export function resolveText(text: ChangelogEntryText): string {
+ const locale = getActiveLocale() as keyof ChangelogEntryText;
+ return text[locale] ?? text.en;
+}
/** Resolves an entry's text for the active UI locale, falling back to English. */
export function entryText(entry: ChangelogEntry): string {
- const locale = getActiveLocale() as keyof ChangelogEntryText;
- return entry.text[locale] ?? entry.text.en;
+ return resolveText(entry.text);
}
/**
diff --git a/src/changelog/types.ts b/src/changelog/types.ts
index 59d6817..1ac58a7 100644
--- a/src/changelog/types.ts
+++ b/src/changelog/types.ts
@@ -8,7 +8,28 @@ export interface ChangelogEntry {
notable?: boolean;
}
+export interface ChangelogStep {
+ title: ChangelogEntryText;
+ description?: ChangelogEntryText;
+}
+
+/** Action a modal's primary CTA can trigger, beyond just closing. */
+export type ChangelogAction = 'open-source-control';
+
+/** Guides a user through a changed mental model, shown above the regular entry list. */
+export interface ChangelogOnboarding {
+ steps: ChangelogStep[];
+ action?: ChangelogAction;
+}
+
export interface ChangelogRelease {
version: string;
+
+ /** Short mental-model summary shown above the entry list, e.g. "A new Source Control workflow". Omitted for ordinary releases. */
+ headline?: ChangelogEntryText;
+ summary?: ChangelogEntryText;
+
+ onboarding?: ChangelogOnboarding;
+
entries: ChangelogEntry[];
}
diff --git a/src/i18n/index.ts b/src/i18n/index.ts
index 50cba80..9e17464 100644
--- a/src/i18n/index.ts
+++ b/src/i18n/index.ts
@@ -50,7 +50,16 @@ export function t(key: TranslationKey, vars?: Record):
const dict = locales[getActiveLocale()] ?? en;
const template = dict[key] ?? en[key];
if (!vars) return template;
- return template.replace(/\{(\w+)\}/g, (match, name: string) =>
- name in vars ? String(vars[name]) : match
- );
+ return template.replace(/\{(\w+)(?:\|([^|]*)\|([^}]*))?\}/g, (match, name: string, singular: string | undefined, plural: string | undefined) => {
+ if (!(name in vars)) return match;
+ const value = String(vars[name]);
+ // Inline plural form: '{count|conflict|conflicts}' renders the value
+ // suffixed with the singular branch when it is exactly 1 and the
+ // plural branch otherwise ('1 conflict' / '3 conflicts'). Locales
+ // without inflection (zh) simply omit the |-branches.
+ if (singular !== undefined && plural !== undefined) {
+ return vars[name] === 1 ? `${value} ${singular}` : `${value} ${plural}`;
+ }
+ return value;
+ });
}
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index a5e5355..975d2c9 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -79,10 +79,10 @@ const en = {
'settings.repoName.desc.github': 'Name of the GitHub repository',
'settings.repoName.placeholder': 'My notes',
- 'main.ribbon.openSyncStatus': 'Open sync status',
+ 'main.ribbon.openSyncStatus': 'Open source control',
'main.ribbon.push': 'Push',
'main.ribbon.pushTo': 'Push to {service}',
- 'main.command.openSyncStatus': 'Open sync status',
+ 'main.command.openSyncStatus': 'Open source control',
'main.command.pushCurrentFile': 'Push current file',
'main.command.pullCurrentFile': 'Pull current file',
'main.command.pushAllFiles': 'Push all files',
@@ -111,108 +111,29 @@ const en = {
'whatsNew.viewOnGitHub': 'View on GitHub',
'whatsNew.viewChangelog': 'View full changelog',
'whatsNew.gotIt': 'Got it',
+ 'whatsNew.openSourceControl': 'Open Source Control',
+ 'whatsNew.close': 'Close',
+ 'whatsNew.stepLabel': 'Step {number}',
'settings.whatsNewBanner.title': "What's new in v{version}",
'settings.whatsNewBanner.dismiss': 'Dismiss',
+ 'settings.whatsNewBanner.view': "See what's new",
+ 'settings.releaseHistory.name': 'Release history',
+ 'settings.releaseHistory.desc': "Review what's new in current and previous versions",
+ 'settings.releaseHistory.button': 'View release history',
+
+
+
- 'syncStatus.viewTitle': 'Sync status',
- 'syncStatus.emptyPrompt': 'Click "Refresh" to check sync status',
- 'syncStatus.progress.checkingWithCount': 'Checking files… {current}/{total} ({pct}%)',
- 'syncStatus.progress.checking': 'Checking files…',
- 'syncStatus.lastSync': 'Last sync: {time}',
- 'syncStatus.tab.all': 'All',
- 'syncStatus.tab.synced': 'Synced',
- 'syncStatus.tab.modified': 'Changed',
- 'syncStatus.tab.unsynced': 'Local only',
- 'syncStatus.tab.remote-only': 'Remote',
- 'syncStatus.tab.moved': 'Moved',
- 'syncStatus.showSynced': 'Show synced',
- 'syncStatus.treeView': 'Tree view',
- 'syncStatus.filterByStatus': 'Filter files by status',
- 'syncStatus.noFilesForFilter': 'No {filter} files',
- 'syncStatus.search.placeholder': 'Filter by path…',
- 'syncStatus.search.clear': 'Clear filter',
- 'syncStatus.noFilesForSearch': 'No files matching "{query}"',
- 'syncStatus.confirmDeleteLocal': 'Delete local file "{path}"? Handled per your vault\'s "Deleted files" setting.',
- 'syncStatus.notice.deleted': 'Deleted {path}',
- 'syncStatus.notice.deleteFailed': 'Failed to delete: {message}',
- 'syncStatus.notice.opStarted': '{verb} {name}…',
- 'syncStatus.confirmRevertMove': 'Move "{from}" back to "{to}"? This undoes the pending move.',
- 'syncStatus.notice.moveReverted': 'Reverted move; "{path}" is back where it was.',
- 'syncStatus.notice.revertFailed': 'Failed to revert move: {message}',
- 'syncStatus.confirmRevertMoveGroup': 'Move {count} file(s) back to where they were? This undoes the pending move.',
- 'syncStatus.notice.opFailed': '{verb} failed: {message}',
- 'syncStatus.notice.alreadyRefreshing': 'Already refreshing…',
- 'syncStatus.notice.refreshed': 'Checked {local} local + {remote} remote files',
- 'syncStatus.notice.refreshFailed': 'Failed to refresh: {message}',
- 'syncStatus.notice.noPushableFiles.selected': 'No pushable files selected.',
- 'syncStatus.notice.noPushableFiles.found': 'No pushable files found.',
- 'syncStatus.notice.noPullableFiles.selected': 'No pullable files selected.',
- 'syncStatus.notice.noPullableFiles.found': 'No pullable files found.',
- 'syncStatus.confirm.pushSelected': 'Push {count} file(s) to {service}?',
- 'syncStatus.confirm.pullSelected': 'Pull {count} file(s) from {service}? This will overwrite local changes.',
- 'syncStatus.notice.opCompleted': '{verb} completed. Refreshing…',
- 'syncStatus.notice.nothingToDelete': 'Nothing to delete',
- 'syncStatus.notice.noFilesSelected': 'No files selected',
- 'syncStatus.confirmDelete.localOnly': 'Delete {local} local file(s)? They\'ll be handled per your vault\'s "Deleted files" setting.',
- 'syncStatus.confirmDelete.remoteOnly': 'Delete {remote} remote file(s)? This cannot be undone.',
- 'syncStatus.confirmDelete.alsoLocal': "Also deletes {local} local file(s), handled per your vault's \"Deleted files\" setting.",
- 'syncStatus.notice.deleteResult.partial': 'Deleted {succeeded}/{total}. {failed} failed.',
- 'syncStatus.notice.deleteResult.partialWithMessage': 'Deleted {succeeded}/{total}. {failed} failed: {message}',
- 'syncStatus.notice.deleteResult.success': 'Deleted {total} files',
- 'syncStatus.progress.deleting': 'Deleting 0/{total} files…',
- 'syncStatus.progress.pushing': 'Pushing {current}/{total}: {name}',
- 'syncStatus.progress.pulling': 'Pulling {current}/{total}: {name}',
- 'syncStatus.progress.deletingLocal': 'Deleting local {current}/{total}: {path}',
- 'syncStatus.progress.deletingRemote': 'Deleting remote {current}/{total}: {path}',
-
- 'actionBar.select': 'Select',
- 'actionBar.refresh': ' Refresh',
- 'actionBar.refreshAll': 'Refresh all statuses',
- 'actionBar.pushCount': ' Push ({count})',
- 'actionBar.pushFiles': 'Push {count} files',
- 'actionBar.pullCount': ' Pull ({count})',
- 'actionBar.pullFiles': 'Pull {count} files',
- 'actionBar.deleteCount': ' Delete ({count})',
- 'actionBar.deleteFiles': 'Delete {count} files',
-
- 'syncStatus.status.checking': 'Checking',
-
- 'fileListItem.action.push': ' Push',
- 'fileListItem.action.pull': ' Pull',
- 'fileListItem.action.revert': ' Revert',
- 'fileListItem.action.remove': ' Remove',
- 'fileListItem.action.diff': ' Diff',
- 'fileListItem.action.hide': ' Hide',
- 'fileListItem.tooltip.pushToRemote': 'Push to remote',
- 'fileListItem.tooltip.pullFromRemote': 'Pull from remote',
- 'fileListItem.tooltip.revertMove': 'Revert move (moves the file back)',
- 'fileListItem.movedGroup.badge': 'moved · {count} files',
- 'fileListItem.movedGroup.show': 'Show {count} files',
- 'fileListItem.movedGroup.hide': 'Hide',
- 'fileListItem.tooltip.deleteLocalFile': 'Delete local file',
- 'fileListItem.tooltip.toggleDiff': 'Toggle diff view',
- 'fileListItem.tooltip.openFile': 'Open file',
- 'fileListItem.tooltip.openDiffPane': 'Open diff in a pane',
'diffView.title': 'Diff',
'diffView.titleWithFile': 'Diff: {path}',
'diffView.empty': 'Select a changed file in the sync panel to see its diff.',
- 'fileListItem.tooltip.openRemote': 'Open on remote',
- 'fileListItem.diff.symlinkChanged': 'Symlink target changed',
- 'fileListItem.diff.loading': 'Loading diff…',
- 'fileListItem.diff.clickToLoad': 'Click Diff to load…',
- 'fileListItem.diff.binaryChanged': 'Binary file changed',
'diffPanel.remote': 'Remote',
'diffPanel.local': 'Local',
'syncConflictModal.title': 'Conflict in {fileName}',
'syncConflictModal.description': 'The remote file has different content. Review the differences and choose which version to keep.',
- 'syncConflictModal.tab.diff': 'Diff',
- 'syncConflictModal.tab.local': 'Local',
- 'syncConflictModal.tab.remote': 'Remote',
- 'syncConflictModal.localVersion': 'Local version',
- 'syncConflictModal.remoteVersion': 'Remote version',
'syncConflictModal.differences': 'Differences',
'syncConflictModal.keepLocal': 'Keep local',
'syncConflictModal.keepLocal.tooltip': 'Overwrite remote with your local content',
@@ -224,9 +145,11 @@ const en = {
'syncPlanModal.title.push': 'Review push plan',
'syncPlanModal.title.pull': 'Review pull plan',
'syncPlanModal.title.delete': 'Review deletion',
+ 'syncPlanModal.title.sync': 'Review sync plan',
'syncPlanModal.section.additions': 'Additions',
'syncPlanModal.section.modifications': 'Modifications',
'syncPlanModal.section.moves': 'Moves',
+ 'syncPlanModal.section.downloads': 'Downloads',
'syncPlanModal.section.acceptedRemote': 'Accept remote locally',
'syncPlanModal.section.skippedConflicts': 'Skipped conflicts',
'syncPlanModal.section.deletions': 'Deletions',
@@ -235,12 +158,11 @@ const en = {
'syncPlanModal.confirm': 'Apply',
'syncPlanModal.cancel': 'Cancel',
- 'batchConflictModal.title': 'Resolve {count} conflict(s) before pushing {total} file(s)',
- 'batchConflictModal.description': '{safeCount} file(s) are ready. {conflictCount} file(s) changed both locally and remotely. Resolve them before continuing.',
+ 'batchConflictModal.title': 'Resolve {count|conflict|conflicts}',
+ 'batchConflictModal.description': 'other {safeCount|file|files}: ready to sync, pushed with this batch.',
'batchConflictModal.keepLocalAll': 'Keep Local for All',
'batchConflictModal.keepRemoteAll': 'Keep Remote for All',
'batchConflictModal.skipAll': 'Skip All',
- 'batchConflictModal.row.badge': 'Local changed · Remote changed',
'batchConflictModal.row.binary': 'Binary',
'batchConflictModal.row.viewDiff': 'View Diff',
'batchConflictModal.row.keepLocal': 'Keep Local',
@@ -249,6 +171,85 @@ const en = {
'batchConflictModal.continue': 'Continue',
'batchConflictModal.cancel': 'Cancel',
'batchConflictModal.unresolvedWarning': 'Choose a resolution for every conflict before continuing.',
+
+ 'sourceControl.viewTitle': 'Source control',
+ 'sourceControl.filter.all': 'All',
+ 'sourceControl.filter.needsSync': 'Needs Sync',
+ 'sourceControl.filter.changes': 'Changes',
+ 'sourceControl.filter.local': 'Local',
+ 'sourceControl.filter.remote': 'Incoming',
+ 'sourceControl.filter.conflict': 'Conflict',
+ 'sourceControl.filter.readyToPush': 'Ready to Push',
+ 'sourceControl.filter.remoteChanges': 'Incoming',
+ 'sourceControl.filter.conflicts': 'Conflicts',
+ 'sourceControl.filter.synced': 'Synced',
+ 'sourceControl.filter.showSynced': 'Show synced',
+ 'sourceControl.section.selectedForSync': 'Sync Queue',
+ 'sourceControl.section.queueSubtitle': '{count} files selected',
+ 'sourceControl.section.repositoryChanges': 'Repository Changes',
+ 'sourceControl.section.clearSelection': 'Clear',
+ 'sourceControl.section.clearSelection.tooltip': 'Deselect all changes',
+ 'sourceControl.push': ' Sync ({count})',
+ 'sourceControl.push.tooltip': 'Push {count} ready file(s)',
+ 'sourceControl.refresh.tooltip': 'Refresh',
+ 'sourceControl.refresh.refreshing': 'Refreshing…',
+ 'sourceControl.refresh.failed': 'Refresh failed',
+ 'sourceControl.op.syncing': 'Syncing',
+ 'sourceControl.op.synced': 'Synced',
+ 'sourceControl.op.failed': 'Failed',
+ 'sourceControl.status.added': 'Added locally',
+ 'sourceControl.status.modified': 'Modified locally',
+ 'sourceControl.status.deletedLocally': 'Deleted locally',
+ 'sourceControl.status.deletedLocally.tooltip': 'Tracked file removed locally — Sync deletes it from the remote by default; use Download to restore it locally instead',
+ 'sourceControl.status.renamed': 'Renamed',
+ 'sourceControl.status.remoteAvailable': 'Remote available',
+ 'sourceControl.status.remoteAvailable.tooltip': 'Exists on remote but not locally — download to add it',
+ 'sourceControl.status.modifiedRemotely': 'Modified remotely',
+ 'sourceControl.status.conflict': 'Conflict',
+ 'sourceControl.status.synced': 'Synced',
+ 'sourceControl.queue.upload': 'Upload',
+ 'sourceControl.queue.download': 'Download',
+ 'sourceControl.queue.delete': 'Delete',
+ 'sourceControl.action.download': 'Download',
+ 'sourceControl.action.download.tooltip': 'Download from remote',
+ 'sourceControl.empty': 'No changes',
+ 'sourceControl.detail.back': 'Back',
+ 'sourceControl.mobile.filesSelected': '{count} files selected',
+ 'sourceControl.mobile.sync': 'Sync',
+ 'sourceControl.info.lastSync': 'Last sync: {time}',
+ 'sourceControl.info.lastChecked': 'Last checked: {time}',
+ 'sourceControl.info.justChecked': 'Last checked: just now',
+ 'sourceControl.info.neverSynced': 'Never synced',
+ 'sourceControl.search.placeholder': 'Filter by path…',
+ 'sourceControl.search.clear': 'Clear filter',
+ 'sourceControl.folder.selectAll': 'Select all in folder',
+ 'sourceControl.diff.switchToSplit': 'Switch to side-by-side diff',
+ 'sourceControl.diff.switchToUnified': 'Switch to single-column diff',
+ 'sourceControl.diff.split': 'Split',
+ 'sourceControl.diff.unified': 'Unified',
+ 'sourceControl.view.toggleLabel': 'View',
+ 'sourceControl.view.tree': 'Tree',
+ 'sourceControl.view.list': 'List',
+
+ 'sync.notice.pushSummary': 'Pushed to {service}: {added} added, {updated} updated{commitNote}.',
+ 'sync.notice.pushAddedOnly': 'Pushed to {service}: {added} added{commitNote}.',
+ 'sync.notice.pushUpdatedOnly': 'Pushed to {service}: {updated} updated{commitNote}.',
+ 'sync.notice.pushCommitNote': ' in one commit',
+ 'sync.notice.pullSummary': 'Pulled from {service}: {added} added, {updated} updated.',
+ 'sync.notice.pullAddedOnly': 'Pulled from {service}: {added} added.',
+ 'sync.notice.pullUpdatedOnly': 'Pulled from {service}: {updated} updated.',
+ 'sourceControl.notice.sync.success': 'Sync complete — {details}',
+ 'sourceControl.notice.sync.partial': 'Sync completed with issues — {details}',
+ 'sourceControl.notice.sync.failed': 'Sync failed — {details}',
+ 'sourceControl.notice.sync.added': '{count} added',
+ 'sourceControl.notice.sync.updated': '{count} updated',
+ 'sourceControl.notice.sync.moved': '{count} moved',
+ 'sourceControl.notice.sync.deleted': '{count} deleted',
+ 'sourceControl.notice.sync.downloaded': '{count} downloaded',
+ 'sourceControl.notice.sync.acceptedRemote': 'Accepted remote {count}',
+ 'sourceControl.notice.sync.failedCount': '{count} failed',
+ 'sourceControl.notice.sync.conflicts': '{count} conflicts',
+ 'sourceControl.notice.sync.skippedConflicts': '{count} skipped conflicts',
};
export default en;
diff --git a/src/i18n/locales/zh-cn.ts b/src/i18n/locales/zh-cn.ts
index a471d49..6b1ef3f 100644
--- a/src/i18n/locales/zh-cn.ts
+++ b/src/i18n/locales/zh-cn.ts
@@ -81,10 +81,10 @@ const zhCn: Partial> = {
'settings.repoName.desc.github': 'GitHub 仓库的名称',
'settings.repoName.placeholder': '我的笔记',
- 'main.ribbon.openSyncStatus': '打开同步状态',
+ 'main.ribbon.openSyncStatus': '打开源代码管理',
'main.ribbon.push': '推送',
'main.ribbon.pushTo': '推送至 {service}',
- 'main.command.openSyncStatus': '打开同步状态',
+ 'main.command.openSyncStatus': '打开源代码管理',
'main.command.pushCurrentFile': '推送当前文件',
'main.command.pullCurrentFile': '拉取当前文件',
'main.command.pushAllFiles': '推送所有文件',
@@ -113,108 +113,29 @@ const zhCn: Partial> = {
'whatsNew.viewOnGitHub': '在 GitHub 上查看',
'whatsNew.viewChangelog': '查看完整更新日志',
'whatsNew.gotIt': '知道了',
+ 'whatsNew.openSourceControl': '打开源代码控制',
+ 'whatsNew.close': '关闭',
+ 'whatsNew.stepLabel': '步骤 {number}',
'settings.whatsNewBanner.title': 'v{version} 更新重点',
'settings.whatsNewBanner.dismiss': '关闭提示',
+ 'settings.whatsNewBanner.view': '查看更新内容',
+ 'settings.releaseHistory.name': '版本更新记录',
+ 'settings.releaseHistory.desc': '查看当前与过往版本的更新内容',
+ 'settings.releaseHistory.button': '查看更新记录',
- 'syncStatus.viewTitle': '同步状态',
- 'syncStatus.emptyPrompt': '点击「刷新」以检查同步状态',
- 'syncStatus.progress.checkingWithCount': '检查文件中… {current}/{total}({pct}%)',
- 'syncStatus.progress.checking': '检查文件中…',
- 'syncStatus.lastSync': '上次同步:{time}',
- 'syncStatus.tab.all': '全部',
- 'syncStatus.tab.synced': '已同步',
- 'syncStatus.tab.modified': '有变更',
- 'syncStatus.tab.unsynced': '仅本机',
- 'syncStatus.tab.remote-only': '远程',
- 'syncStatus.tab.moved': '已移动',
- 'syncStatus.showSynced': '显示已同步',
- 'syncStatus.treeView': '树状视图',
- 'syncStatus.filterByStatus': '按状态筛选文件',
- 'syncStatus.noFilesForFilter': '没有{filter}的文件',
- 'syncStatus.search.placeholder': '按路径过滤…',
- 'syncStatus.search.clear': '清除过滤',
- 'syncStatus.noFilesForSearch': '没有匹配“{query}”的文件',
- 'syncStatus.confirmDeleteLocal': '删除本机文件「{path}」?将依您库的「已删除文件」设置处理。',
- 'syncStatus.notice.deleted': '已删除 {path}',
- 'syncStatus.notice.deleteFailed': '删除失败:{message}',
- 'syncStatus.notice.opStarted': '{verb} {name}…',
- 'syncStatus.confirmRevertMove': '将“{from}”移回“{to}”?这会撤销尚未推送的移动。',
- 'syncStatus.notice.moveReverted': '已撤销移动,“{path}”已还原。',
- 'syncStatus.notice.revertFailed': '撤销移动失败:{message}',
- 'syncStatus.confirmRevertMoveGroup': '将 {count} 个文件移回原处?这会撤销尚未推送的移动。',
- 'syncStatus.notice.opFailed': '{verb}失败:{message}',
- 'syncStatus.notice.alreadyRefreshing': '正在刷新中…',
- 'syncStatus.notice.refreshed': '已检查 {local} 个本机文件与 {remote} 个远程文件',
- 'syncStatus.notice.refreshFailed': '刷新失败:{message}',
- 'syncStatus.notice.noPushableFiles.selected': '未选取任何可推送的文件。',
- 'syncStatus.notice.noPushableFiles.found': '没有可推送的文件。',
- 'syncStatus.notice.noPullableFiles.selected': '未选取任何可拉取的文件。',
- 'syncStatus.notice.noPullableFiles.found': '没有可拉取的文件。',
- 'syncStatus.confirm.pushSelected': '要推送 {count} 个文件至 {service} 吗?',
- 'syncStatus.confirm.pullSelected': '要从 {service} 拉取 {count} 个文件吗?这将覆盖本机变更。',
- 'syncStatus.notice.opCompleted': '{verb}完成,刷新中…',
- 'syncStatus.notice.nothingToDelete': '没有可删除的项目',
- 'syncStatus.notice.noFilesSelected': '尚未选取任何文件',
- 'syncStatus.confirmDelete.localOnly': '要删除 {local} 个本机文件吗?将依您库的「已删除文件」设置处理。',
- 'syncStatus.confirmDelete.remoteOnly': '要删除 {remote} 个远程文件吗?此操作无法恢复。',
- 'syncStatus.confirmDelete.alsoLocal': '同时会删除 {local} 个本地文件,处理方式取决于你的库“已删除的文件”设置。',
- 'syncStatus.notice.deleteResult.partial': '已删除 {succeeded}/{total} 个,{failed} 个失败。',
- 'syncStatus.notice.deleteResult.partialWithMessage': '已删除 {succeeded}/{total} 个,{failed} 个失败:{message}',
- 'syncStatus.notice.deleteResult.success': '已删除 {total} 个文件',
- 'syncStatus.progress.deleting': '删除中 0/{total} 个文件…',
- 'syncStatus.progress.pushing': '推送中 {current}/{total}:{name}',
- 'syncStatus.progress.pulling': '拉取中 {current}/{total}:{name}',
- 'syncStatus.progress.deletingLocal': '删除本机 {current}/{total}:{path}',
- 'syncStatus.progress.deletingRemote': '删除远程 {current}/{total}:{path}',
- 'actionBar.select': '选取',
- 'actionBar.refresh': ' 刷新',
- 'actionBar.refreshAll': '刷新所有状态',
- 'actionBar.pushCount': ' 推送({count})',
- 'actionBar.pushFiles': '推送 {count} 个文件',
- 'actionBar.pullCount': ' 拉取({count})',
- 'actionBar.pullFiles': '拉取 {count} 个文件',
- 'actionBar.deleteCount': ' 删除({count})',
- 'actionBar.deleteFiles': '删除 {count} 个文件',
- 'syncStatus.status.checking': '检查中',
- 'fileListItem.action.push': ' 推送',
- 'fileListItem.action.pull': ' 拉取',
- 'fileListItem.action.revert': ' 撤销',
- 'fileListItem.action.remove': ' 移除',
- 'fileListItem.action.diff': ' 差异',
- 'fileListItem.action.hide': ' 隐藏',
- 'fileListItem.tooltip.pushToRemote': '推送至远程',
- 'fileListItem.tooltip.pullFromRemote': '从远程拉取',
- 'fileListItem.tooltip.revertMove': '撤销移动(将文件移回原路径)',
- 'fileListItem.movedGroup.badge': '已移动 · {count} 个文件',
- 'fileListItem.movedGroup.show': '显示 {count} 个文件',
- 'fileListItem.movedGroup.hide': '隐藏',
- 'fileListItem.tooltip.deleteLocalFile': '删除本机文件',
- 'fileListItem.tooltip.toggleDiff': '切换差异视图',
- 'fileListItem.tooltip.openFile': '打开文件',
- 'fileListItem.tooltip.openDiffPane': '在独立面板打开差异',
'diffView.title': '差异',
'diffView.titleWithFile': '差异:{path}',
'diffView.empty': '在同步面板选一个有变更的文件以查看差异。',
- 'fileListItem.tooltip.openRemote': '在远程打开',
- 'fileListItem.diff.symlinkChanged': '符号链接目标已变更',
- 'fileListItem.diff.loading': '加载差异中…',
- 'fileListItem.diff.clickToLoad': '点击「差异」以加载…',
- 'fileListItem.diff.binaryChanged': '二进制文件已变更',
'diffPanel.remote': '远程',
'diffPanel.local': '本机',
'syncConflictModal.title': '{fileName} 发生冲突',
'syncConflictModal.description': '远程文件内容有所不同。请查看差异并选择要保留的版本。',
- 'syncConflictModal.tab.diff': '差异',
- 'syncConflictModal.tab.local': '本机',
- 'syncConflictModal.tab.remote': '远程',
- 'syncConflictModal.localVersion': '本机版本',
- 'syncConflictModal.remoteVersion': '远程版本',
'syncConflictModal.differences': '差异',
'syncConflictModal.keepLocal': '保留本机',
'syncConflictModal.keepLocal.tooltip': '以本机内容覆盖远程',
@@ -226,9 +147,11 @@ const zhCn: Partial> = {
'syncPlanModal.title.push': '查看推送计划',
'syncPlanModal.title.pull': '查看拉取计划',
'syncPlanModal.title.delete': '查看删除计划',
+ 'syncPlanModal.title.sync': '查看同步计划',
'syncPlanModal.section.additions': '新增',
'syncPlanModal.section.modifications': '修改',
'syncPlanModal.section.moves': '移动',
+ 'syncPlanModal.section.downloads': '下载',
'syncPlanModal.section.acceptedRemote': '在本地接受远程版本',
'syncPlanModal.section.skippedConflicts': '已跳过的冲突',
'syncPlanModal.section.deletions': '删除',
@@ -237,12 +160,11 @@ const zhCn: Partial> = {
'syncPlanModal.confirm': '应用',
'syncPlanModal.cancel': '取消',
- 'batchConflictModal.title': '在推送 {total} 个文件前,先解决 {count} 个冲突',
- 'batchConflictModal.description': '{safeCount} 个文件已就绪。{conflictCount} 个文件同时在本地与远程发生更改,请先解决后再继续。',
+ 'batchConflictModal.title': '先解决 {count} 个冲突',
+ 'batchConflictModal.description': '另有 {safeCount} 个文件已就绪,将随本次一并推送。',
'batchConflictModal.keepLocalAll': '全部保留本地',
'batchConflictModal.keepRemoteAll': '全部保留远程',
'batchConflictModal.skipAll': '全部跳过',
- 'batchConflictModal.row.badge': '本地已更改 · 远程已更改',
'batchConflictModal.row.binary': '二进制文件',
'batchConflictModal.row.viewDiff': '查看差异',
'batchConflictModal.row.keepLocal': '保留本地',
@@ -251,6 +173,85 @@ const zhCn: Partial> = {
'batchConflictModal.continue': '继续',
'batchConflictModal.cancel': '取消',
'batchConflictModal.unresolvedWarning': '请先为每个冲突选择解决方式,才能继续。',
+
+ 'sourceControl.viewTitle': '源代码管理',
+ 'sourceControl.filter.all': '全部',
+ 'sourceControl.filter.needsSync': '待同步',
+ 'sourceControl.filter.changes': '更改',
+ 'sourceControl.filter.local': '本地',
+ 'sourceControl.filter.remote': '传入',
+ 'sourceControl.filter.conflict': '冲突',
+ 'sourceControl.filter.readyToPush': '待推送',
+ 'sourceControl.filter.remoteChanges': '传入',
+ 'sourceControl.filter.conflicts': '冲突',
+ 'sourceControl.filter.synced': '已同步',
+ 'sourceControl.filter.showSynced': '显示已同步',
+ 'sourceControl.section.selectedForSync': '同步队列',
+ 'sourceControl.section.queueSubtitle': '已选 {count} 个文件',
+ 'sourceControl.section.repositoryChanges': '仓库更改',
+ 'sourceControl.section.clearSelection': '清除',
+ 'sourceControl.section.clearSelection.tooltip': '取消全部选择',
+ 'sourceControl.push': ' 同步 ({count})',
+ 'sourceControl.push.tooltip': '推送 {count} 个已就绪的文件',
+ 'sourceControl.refresh.tooltip': '刷新',
+ 'sourceControl.refresh.refreshing': '刷新中…',
+ 'sourceControl.refresh.failed': '刷新失败',
+ 'sourceControl.op.syncing': '同步中',
+ 'sourceControl.op.synced': '已同步',
+ 'sourceControl.op.failed': '失败',
+ 'sourceControl.status.added': '本地新增',
+ 'sourceControl.status.modified': '本地修改',
+ 'sourceControl.status.deletedLocally': '本地已删除',
+ 'sourceControl.status.deletedLocally.tooltip': '已跟踪文件在本地被删除 — 同步默认会删除远程副本;如需恢复,请改用「下载」',
+ 'sourceControl.status.renamed': '已重命名',
+ 'sourceControl.status.remoteAvailable': '远程可用',
+ 'sourceControl.status.remoteAvailable.tooltip': '远程存在但本地缺失 — 下载以添加',
+ 'sourceControl.status.modifiedRemotely': '远程已修改',
+ 'sourceControl.status.conflict': '冲突',
+ 'sourceControl.status.synced': '已同步',
+ 'sourceControl.queue.upload': '上传',
+ 'sourceControl.queue.download': '下载',
+ 'sourceControl.queue.delete': '删除',
+ 'sourceControl.action.download': '下载',
+ 'sourceControl.action.download.tooltip': '从远程下载',
+ 'sourceControl.empty': '没有更改',
+ 'sourceControl.detail.back': '返回',
+ 'sourceControl.mobile.filesSelected': '已选 {count} 个文件',
+ 'sourceControl.mobile.sync': '同步',
+ 'sourceControl.info.lastSync': '上次同步:{time}',
+ 'sourceControl.info.lastChecked': '上次检查:{time}',
+ 'sourceControl.info.justChecked': '上次检查:刚刚',
+ 'sourceControl.info.neverSynced': '尚未同步',
+ 'sourceControl.search.placeholder': '按路径过滤…',
+ 'sourceControl.search.clear': '清除过滤',
+ 'sourceControl.folder.selectAll': '选取文件夹内全部项目',
+ 'sourceControl.diff.switchToSplit': '切换为两栏式差异显示',
+ 'sourceControl.diff.switchToUnified': '切换为单栏式差异显示',
+ 'sourceControl.diff.split': '两栏',
+ 'sourceControl.diff.unified': '单栏',
+ 'sourceControl.view.toggleLabel': '视图',
+ 'sourceControl.view.tree': '树状',
+ 'sourceControl.view.list': '列表',
+
+ 'sync.notice.pushSummary': '已推送至 {service}:新增 {added} 个、更新 {updated} 个{commitNote}。',
+ 'sync.notice.pushAddedOnly': '已推送至 {service}:新增 {added} 个{commitNote}。',
+ 'sync.notice.pushUpdatedOnly': '已推送至 {service}:更新 {updated} 个{commitNote}。',
+ 'sync.notice.pushCommitNote': '(合并为一次提交)',
+ 'sync.notice.pullSummary': '已从 {service} 拉取:新增 {added} 个、更新 {updated} 个。',
+ 'sync.notice.pullAddedOnly': '已从 {service} 拉取:新增 {added} 个。',
+ 'sync.notice.pullUpdatedOnly': '已从 {service} 拉取:更新 {updated} 个。',
+ 'sourceControl.notice.sync.success': '同步完成 — {details}',
+ 'sourceControl.notice.sync.partial': '同步完成但有问题 — {details}',
+ 'sourceControl.notice.sync.failed': '同步失败 — {details}',
+ 'sourceControl.notice.sync.added': '新增 {count} 个',
+ 'sourceControl.notice.sync.updated': '更新 {count} 个',
+ 'sourceControl.notice.sync.moved': '移动 {count} 个',
+ 'sourceControl.notice.sync.deleted': '删除 {count} 个',
+ 'sourceControl.notice.sync.downloaded': '下载 {count} 个',
+ 'sourceControl.notice.sync.acceptedRemote': '采用远程版本 {count}',
+ 'sourceControl.notice.sync.failedCount': '失败 {count} 个',
+ 'sourceControl.notice.sync.conflicts': '{count} 个冲突',
+ 'sourceControl.notice.sync.skippedConflicts': '跳过 {count} 个冲突',
};
export default zhCn;
diff --git a/src/i18n/locales/zh-tw.ts b/src/i18n/locales/zh-tw.ts
index f56d5cd..800c56a 100644
--- a/src/i18n/locales/zh-tw.ts
+++ b/src/i18n/locales/zh-tw.ts
@@ -81,10 +81,10 @@ const zhTw: Partial> = {
'settings.repoName.desc.github': 'GitHub 儲存庫的名稱',
'settings.repoName.placeholder': '我的筆記',
- 'main.ribbon.openSyncStatus': '開啟同步狀態',
+ 'main.ribbon.openSyncStatus': '開啟原始碼控制',
'main.ribbon.push': '推送',
'main.ribbon.pushTo': '推送至 {service}',
- 'main.command.openSyncStatus': '開啟同步狀態',
+ 'main.command.openSyncStatus': '開啟原始碼控制',
'main.command.pushCurrentFile': '推送目前檔案',
'main.command.pullCurrentFile': '拉取目前檔案',
'main.command.pushAllFiles': '推送所有檔案',
@@ -113,108 +113,29 @@ const zhTw: Partial> = {
'whatsNew.viewOnGitHub': '在 GitHub 上查看',
'whatsNew.viewChangelog': '查看完整更新日誌',
'whatsNew.gotIt': '知道了',
+ 'whatsNew.openSourceControl': '開啟原始碼控制',
+ 'whatsNew.close': '關閉',
+ 'whatsNew.stepLabel': '步驟 {number}',
'settings.whatsNewBanner.title': 'v{version} 更新重點',
'settings.whatsNewBanner.dismiss': '關閉提示',
+ 'settings.whatsNewBanner.view': '查看更新內容',
+ 'settings.releaseHistory.name': '版本更新紀錄',
+ 'settings.releaseHistory.desc': '查看目前與過往版本的更新內容',
+ 'settings.releaseHistory.button': '查看更新紀錄',
- 'syncStatus.viewTitle': '同步狀態',
- 'syncStatus.emptyPrompt': '點擊「重新整理」以檢查同步狀態',
- 'syncStatus.progress.checkingWithCount': '檢查檔案中… {current}/{total}({pct}%)',
- 'syncStatus.progress.checking': '檢查檔案中…',
- 'syncStatus.lastSync': '上次同步:{time}',
- 'syncStatus.tab.all': '全部',
- 'syncStatus.tab.synced': '已同步',
- 'syncStatus.tab.modified': '有變更',
- 'syncStatus.tab.unsynced': '僅本機',
- 'syncStatus.tab.remote-only': '遠端',
- 'syncStatus.tab.moved': '已移動',
- 'syncStatus.showSynced': '顯示已同步',
- 'syncStatus.treeView': '樹狀檢視',
- 'syncStatus.filterByStatus': '依狀態篩選檔案',
- 'syncStatus.noFilesForFilter': '沒有{filter}的檔案',
- 'syncStatus.search.placeholder': '以路徑過濾…',
- 'syncStatus.search.clear': '清除過濾',
- 'syncStatus.noFilesForSearch': '沒有符合「{query}」的檔案',
- 'syncStatus.confirmDeleteLocal': '刪除本機檔案「{path}」?將依您保存庫的「已刪除的檔案」設定處理。',
- 'syncStatus.notice.deleted': '已刪除 {path}',
- 'syncStatus.notice.deleteFailed': '刪除失敗:{message}',
- 'syncStatus.notice.opStarted': '{verb} {name}…',
- 'syncStatus.confirmRevertMove': '將「{from}」移回「{to}」?這會復原尚未推送的移動。',
- 'syncStatus.notice.moveReverted': '已復原移動,「{path}」已還原。',
- 'syncStatus.notice.revertFailed': '復原移動失敗:{message}',
- 'syncStatus.confirmRevertMoveGroup': '將 {count} 個檔案移回原處?這會復原尚未推送的移動。',
- 'syncStatus.notice.opFailed': '{verb}失敗:{message}',
- 'syncStatus.notice.alreadyRefreshing': '正在重新整理中…',
- 'syncStatus.notice.refreshed': '已檢查 {local} 個本機檔案與 {remote} 個遠端檔案',
- 'syncStatus.notice.refreshFailed': '重新整理失敗:{message}',
- 'syncStatus.notice.noPushableFiles.selected': '未選取任何可推送的檔案。',
- 'syncStatus.notice.noPushableFiles.found': '沒有可推送的檔案。',
- 'syncStatus.notice.noPullableFiles.selected': '未選取任何可拉取的檔案。',
- 'syncStatus.notice.noPullableFiles.found': '沒有可拉取的檔案。',
- 'syncStatus.confirm.pushSelected': '要推送 {count} 個檔案至 {service} 嗎?',
- 'syncStatus.confirm.pullSelected': '要從 {service} 拉取 {count} 個檔案嗎?這將覆蓋本機變更。',
- 'syncStatus.notice.opCompleted': '{verb}完成,重新整理中…',
- 'syncStatus.notice.nothingToDelete': '沒有可刪除的項目',
- 'syncStatus.notice.noFilesSelected': '尚未選取任何檔案',
- 'syncStatus.confirmDelete.localOnly': '要刪除 {local} 個本機檔案嗎?將依您保存庫的「已刪除的檔案」設定處理。',
- 'syncStatus.confirmDelete.remoteOnly': '要刪除 {remote} 個遠端檔案嗎?此操作無法復原。',
- 'syncStatus.confirmDelete.alsoLocal': '同時會刪除 {local} 個本機檔案,處理方式依你的保存庫「已刪除的檔案」設定而定。',
- 'syncStatus.notice.deleteResult.partial': '已刪除 {succeeded}/{total} 個,{failed} 個失敗。',
- 'syncStatus.notice.deleteResult.partialWithMessage': '已刪除 {succeeded}/{total} 個,{failed} 個失敗:{message}',
- 'syncStatus.notice.deleteResult.success': '已刪除 {total} 個檔案',
- 'syncStatus.progress.deleting': '刪除中 0/{total} 個檔案…',
- 'syncStatus.progress.pushing': '推送中 {current}/{total}:{name}',
- 'syncStatus.progress.pulling': '拉取中 {current}/{total}:{name}',
- 'syncStatus.progress.deletingLocal': '刪除本機 {current}/{total}:{path}',
- 'syncStatus.progress.deletingRemote': '刪除遠端 {current}/{total}:{path}',
- 'actionBar.select': '選取',
- 'actionBar.refresh': ' 重新整理',
- 'actionBar.refreshAll': '重新整理所有狀態',
- 'actionBar.pushCount': ' 推送({count})',
- 'actionBar.pushFiles': '推送 {count} 個檔案',
- 'actionBar.pullCount': ' 拉取({count})',
- 'actionBar.pullFiles': '拉取 {count} 個檔案',
- 'actionBar.deleteCount': ' 刪除({count})',
- 'actionBar.deleteFiles': '刪除 {count} 個檔案',
- 'syncStatus.status.checking': '檢查中',
- 'fileListItem.action.push': ' 推送',
- 'fileListItem.action.pull': ' 拉取',
- 'fileListItem.action.revert': ' 復原',
- 'fileListItem.action.remove': ' 移除',
- 'fileListItem.action.diff': ' 差異',
- 'fileListItem.action.hide': ' 隱藏',
- 'fileListItem.tooltip.pushToRemote': '推送至遠端',
- 'fileListItem.tooltip.pullFromRemote': '從遠端拉取',
- 'fileListItem.tooltip.revertMove': '復原移動(將檔案移回原路徑)',
- 'fileListItem.movedGroup.badge': '已移動 · {count} 個檔案',
- 'fileListItem.movedGroup.show': '顯示 {count} 個檔案',
- 'fileListItem.movedGroup.hide': '隱藏',
- 'fileListItem.tooltip.deleteLocalFile': '刪除本機檔案',
- 'fileListItem.tooltip.toggleDiff': '切換差異檢視',
- 'fileListItem.tooltip.openFile': '開啟檔案',
- 'fileListItem.tooltip.openDiffPane': '在獨立面板開啟差異',
'diffView.title': '差異',
'diffView.titleWithFile': '差異:{path}',
'diffView.empty': '在同步面板選一個有變更的檔案以檢視差異。',
- 'fileListItem.tooltip.openRemote': '在遠端開啟',
- 'fileListItem.diff.symlinkChanged': '符號連結目標已變更',
- 'fileListItem.diff.loading': '載入差異中…',
- 'fileListItem.diff.clickToLoad': '點擊「差異」以載入…',
- 'fileListItem.diff.binaryChanged': '二進位檔案已變更',
'diffPanel.remote': '遠端',
'diffPanel.local': '本機',
'syncConflictModal.title': '{fileName} 發生衝突',
'syncConflictModal.description': '遠端檔案內容有所不同。請檢視差異並選擇要保留的版本。',
- 'syncConflictModal.tab.diff': '差異',
- 'syncConflictModal.tab.local': '本機',
- 'syncConflictModal.tab.remote': '遠端',
- 'syncConflictModal.localVersion': '本機版本',
- 'syncConflictModal.remoteVersion': '遠端版本',
'syncConflictModal.differences': '差異',
'syncConflictModal.keepLocal': '保留本機',
'syncConflictModal.keepLocal.tooltip': '以本機內容覆蓋遠端',
@@ -226,9 +147,11 @@ const zhTw: Partial> = {
'syncPlanModal.title.push': '檢視推送計畫',
'syncPlanModal.title.pull': '檢視拉取計畫',
'syncPlanModal.title.delete': '檢視刪除計畫',
+ 'syncPlanModal.title.sync': '檢視同步計畫',
'syncPlanModal.section.additions': '新增',
'syncPlanModal.section.modifications': '修改',
'syncPlanModal.section.moves': '移動',
+ 'syncPlanModal.section.downloads': '下載',
'syncPlanModal.section.acceptedRemote': '在本機接受遠端版本',
'syncPlanModal.section.skippedConflicts': '已略過的衝突',
'syncPlanModal.section.deletions': '刪除',
@@ -237,12 +160,11 @@ const zhTw: Partial> = {
'syncPlanModal.confirm': '套用',
'syncPlanModal.cancel': '取消',
- 'batchConflictModal.title': '在推送 {total} 個檔案前,先解決 {count} 個衝突',
- 'batchConflictModal.description': '{safeCount} 個檔案已就緒。{conflictCount} 個檔案同時在本機與遠端變更,請先解決後再繼續。',
+ 'batchConflictModal.title': '先解決 {count} 個衝突',
+ 'batchConflictModal.description': '另有 {safeCount} 個檔案已就緒,將隨本次一併推送。',
'batchConflictModal.keepLocalAll': '全部保留本機',
'batchConflictModal.keepRemoteAll': '全部保留遠端',
'batchConflictModal.skipAll': '全部略過',
- 'batchConflictModal.row.badge': '本機已變更 · 遠端已變更',
'batchConflictModal.row.binary': '二進位檔案',
'batchConflictModal.row.viewDiff': '檢視差異',
'batchConflictModal.row.keepLocal': '保留本機',
@@ -251,6 +173,85 @@ const zhTw: Partial> = {
'batchConflictModal.continue': '繼續',
'batchConflictModal.cancel': '取消',
'batchConflictModal.unresolvedWarning': '請先為每個衝突選擇解決方式,才能繼續。',
+
+ 'sourceControl.viewTitle': '原始碼控制',
+ 'sourceControl.filter.all': '全部',
+ 'sourceControl.filter.needsSync': '待同步',
+ 'sourceControl.filter.changes': '變更',
+ 'sourceControl.filter.local': '本地',
+ 'sourceControl.filter.remote': '傳入',
+ 'sourceControl.filter.conflict': '衝突',
+ 'sourceControl.filter.readyToPush': '待推送',
+ 'sourceControl.filter.remoteChanges': '傳入',
+ 'sourceControl.filter.conflicts': '衝突',
+ 'sourceControl.filter.synced': '已同步',
+ 'sourceControl.filter.showSynced': '顯示已同步',
+ 'sourceControl.section.selectedForSync': '同步佇列',
+ 'sourceControl.section.queueSubtitle': '已選 {count} 個檔案',
+ 'sourceControl.section.repositoryChanges': '儲存庫變更',
+ 'sourceControl.section.clearSelection': '清除',
+ 'sourceControl.section.clearSelection.tooltip': '取消全部選取',
+ 'sourceControl.push': ' 同步 ({count})',
+ 'sourceControl.push.tooltip': '推送 {count} 個已就緒的檔案',
+ 'sourceControl.refresh.tooltip': '重新整理',
+ 'sourceControl.refresh.refreshing': '重新整理中…',
+ 'sourceControl.refresh.failed': '重新整理失敗',
+ 'sourceControl.op.syncing': '同步中',
+ 'sourceControl.op.synced': '已同步',
+ 'sourceControl.op.failed': '失敗',
+ 'sourceControl.status.added': '本地新增',
+ 'sourceControl.status.modified': '本地修改',
+ 'sourceControl.status.deletedLocally': '本地已刪除',
+ 'sourceControl.status.deletedLocally.tooltip': '已追蹤檔案在本地被刪除 — 同步預設會刪除遠端副本;如需還原,請改用「下載」',
+ 'sourceControl.status.renamed': '已重新命名',
+ 'sourceControl.status.remoteAvailable': '遠端可用',
+ 'sourceControl.status.remoteAvailable.tooltip': '遠端存在但本地缺失 — 下載以新增',
+ 'sourceControl.status.modifiedRemotely': '遠端已修改',
+ 'sourceControl.status.conflict': '衝突',
+ 'sourceControl.status.synced': '已同步',
+ 'sourceControl.queue.upload': '上傳',
+ 'sourceControl.queue.download': '下載',
+ 'sourceControl.queue.delete': '刪除',
+ 'sourceControl.action.download': '下載',
+ 'sourceControl.action.download.tooltip': '從遠端下載',
+ 'sourceControl.empty': '沒有變更',
+ 'sourceControl.detail.back': '返回',
+ 'sourceControl.mobile.filesSelected': '已選 {count} 個檔案',
+ 'sourceControl.mobile.sync': '同步',
+ 'sourceControl.info.lastSync': '上次同步:{time}',
+ 'sourceControl.info.lastChecked': '上次檢查:{time}',
+ 'sourceControl.info.justChecked': '上次檢查:剛剛',
+ 'sourceControl.info.neverSynced': '尚未同步',
+ 'sourceControl.search.placeholder': '以路徑過濾…',
+ 'sourceControl.search.clear': '清除過濾',
+ 'sourceControl.folder.selectAll': '選取資料夾內全部項目',
+ 'sourceControl.diff.switchToSplit': '切換為兩欄式差異顯示',
+ 'sourceControl.diff.switchToUnified': '切換為單欄式差異顯示',
+ 'sourceControl.diff.split': '兩欄',
+ 'sourceControl.diff.unified': '單欄',
+ 'sourceControl.view.toggleLabel': '檢視',
+ 'sourceControl.view.tree': '樹狀',
+ 'sourceControl.view.list': '列表',
+
+ 'sync.notice.pushSummary': '已推送至 {service}:新增 {added} 個、更新 {updated} 個{commitNote}。',
+ 'sync.notice.pushAddedOnly': '已推送至 {service}:新增 {added} 個{commitNote}。',
+ 'sync.notice.pushUpdatedOnly': '已推送至 {service}:更新 {updated} 個{commitNote}。',
+ 'sync.notice.pushCommitNote': '(合併為一次提交)',
+ 'sync.notice.pullSummary': '已從 {service} 拉取:新增 {added} 個、更新 {updated} 個。',
+ 'sync.notice.pullAddedOnly': '已從 {service} 拉取:新增 {added} 個。',
+ 'sync.notice.pullUpdatedOnly': '已從 {service} 拉取:更新 {updated} 個。',
+ 'sourceControl.notice.sync.success': '同步完成 — {details}',
+ 'sourceControl.notice.sync.partial': '同步完成但有問題 — {details}',
+ 'sourceControl.notice.sync.failed': '同步失敗 — {details}',
+ 'sourceControl.notice.sync.added': '新增 {count} 個',
+ 'sourceControl.notice.sync.updated': '更新 {count} 個',
+ 'sourceControl.notice.sync.moved': '移動 {count} 個',
+ 'sourceControl.notice.sync.deleted': '刪除 {count} 個',
+ 'sourceControl.notice.sync.downloaded': '下載 {count} 個',
+ 'sourceControl.notice.sync.acceptedRemote': '採用遠端版本 {count}',
+ 'sourceControl.notice.sync.failedCount': '失敗 {count} 個',
+ 'sourceControl.notice.sync.conflicts': '{count} 個衝突',
+ 'sourceControl.notice.sync.skippedConflicts': '略過 {count} 個衝突',
};
export default zhTw;
diff --git a/src/logic/source-control/ChangeActionPolicy.ts b/src/logic/source-control/ChangeActionPolicy.ts
new file mode 100644
index 0000000..c36bbb7
--- /dev/null
+++ b/src/logic/source-control/ChangeActionPolicy.ts
@@ -0,0 +1,46 @@
+import type { SyncChangeKind } from './types';
+
+/**
+ * Which sync operation a change kind defaults to when it's synced from the
+ * Sync Queue (the Sync button routes each queued change to one of these).
+ * This is what `changeOperation` in `ui/source-control/ChangePresentation.ts`
+ * used to be: that file's job is UI-only presentation (badge, subtitle,
+ * tooltip), but the routing decision itself is domain/application policy —
+ * which primitive a change kind maps to isn't a rendering concern — so it
+ * lives here instead, decoupled from presentation.
+ */
+export type DefaultSyncAction = 'push' | 'pull' | 'delete-remote';
+
+const DEFAULT_ACTION: Record = {
+ 'local-only': 'push',
+ 'local-modified': 'push',
+ // A tracked file removed locally has no local content to push, so its
+ // non-destructive-by-omission default is to delete it on the remote
+ // (mirroring the local deletion), not to silently restore it — that
+ // would undo the user's delete. Restoring is still available via the
+ // row's Download action (see `canDownload`).
+ 'local-deleted': 'delete-remote',
+ 'remote-only': 'pull',
+ 'remote-modified': 'pull',
+ moved: 'push',
+ conflict: 'push',
+ // 'synced' never reaches the Sync Queue; mapped to 'push' only to
+ // satisfy the exhaustive record.
+ synced: 'push',
+};
+
+/** The default sync action a change kind routes to when synced from the Sync Queue. */
+export function defaultSyncAction(kind: SyncChangeKind): DefaultSyncAction {
+ return DEFAULT_ACTION[kind];
+}
+
+/**
+ * Whether a change kind has something on the remote it can pull/restore —
+ * `remote-only` (never existed locally), `remote-modified` (tracked file
+ * changed only on the remote), and `local-deleted` (tracked file removed
+ * locally, still present on remote) all do. Drives whether a row renders the
+ * inline Download button.
+ */
+export function canDownload(kind: SyncChangeKind): boolean {
+ return kind === 'remote-only' || kind === 'remote-modified' || kind === 'local-deleted';
+}
diff --git a/src/logic/source-control/ChangeRepository.ts b/src/logic/source-control/ChangeRepository.ts
new file mode 100644
index 0000000..2463e33
--- /dev/null
+++ b/src/logic/source-control/ChangeRepository.ts
@@ -0,0 +1,37 @@
+import type { ChangeId, SyncChange } from './types';
+
+/**
+ * Read-side lookup for the current set of pending `SyncChange`s. Holds no
+ * sync/business logic of its own — it's populated wholesale (`replace`) by
+ * whatever assembles `SyncChange[]` from the sync domain, and exists purely
+ * to give the ViewModel and UI O(1) lookup by id or path instead of scanning
+ * an array.
+ */
+export class ChangeRepository {
+ private changes: SyncChange[] = [];
+ private readonly byId = new Map();
+ private readonly byPath = new Map();
+
+ /** Replaces the full change set, e.g. after a status refresh. */
+ replace(changes: readonly SyncChange[]): void {
+ this.changes = [...changes];
+ this.byId.clear();
+ this.byPath.clear();
+ for (const change of this.changes) {
+ this.byId.set(change.id, change);
+ this.byPath.set(change.path, change);
+ }
+ }
+
+ getAll(): SyncChange[] {
+ return [...this.changes];
+ }
+
+ getById(id: ChangeId): SyncChange | undefined {
+ return this.byId.get(id);
+ }
+
+ getByPath(path: string): SyncChange | undefined {
+ return this.byPath.get(path);
+ }
+}
diff --git a/src/logic/source-control/ChangeTreeBuilder.ts b/src/logic/source-control/ChangeTreeBuilder.ts
new file mode 100644
index 0000000..1a9152b
--- /dev/null
+++ b/src/logic/source-control/ChangeTreeBuilder.ts
@@ -0,0 +1,183 @@
+import type { ChangeId, SyncChange, SyncChangeKind } from './types';
+
+export interface ChangeTreeFileNode {
+ type: 'file';
+ id: ChangeId;
+ name: string;
+ path: string;
+ previousPath?: string;
+ kind: SyncChangeKind;
+}
+
+export interface ChangeTreeFolderNode {
+ type: 'folder';
+ name: string;
+ path: string;
+ children: ChangeTreeNode[];
+}
+
+export type ChangeTreeNode = ChangeTreeFileNode | ChangeTreeFolderNode;
+
+/**
+ * Presentation-only controls for tree rendering, so the Source Control tree
+ * stays a compact change view rather than reproducing the full file Explorer.
+ *
+ * - `maxDepth`: the maximum number of folder nesting levels rendered as
+ * separate, collapsible nodes. Deeper folders are folded into a single
+ * flattened path segment (e.g. `02_Areas/blog/_pixnet/zh-tw/tech`) instead of
+ * five nested expandable rows. Files always render at their real depth; only
+ * intermediate folders are flattened. Defaults to unlimited depth (legacy
+ * behavior) when omitted.
+ * - `collapseSingleChild`: when true, a folder that contains exactly one
+ * child folder (no files) is merged with that child into one combined folder
+ * node, reducing pointless single-step nesting like `tech › tech › tech`.
+ * Defaults to false to preserve the existing rendering when omitted.
+ */
+export interface TreeDisplayOptions {
+ maxDepth?: number;
+ collapseSingleChild?: boolean;
+}
+
+const DEFAULT_OPTIONS: Required> = {
+ maxDepth: Number.POSITIVE_INFINITY,
+ collapseSingleChild: false,
+};
+
+/**
+ * Turns a flat `SyncChange[]` into a folder/file tree for rendering.
+ * A renamed/moved file is placed at its *current* path — `previousPath`
+ * travels with the file node purely for display (e.g. "old → new"), it does
+ * not create a second tree entry.
+ */
+export class ChangeTreeBuilder {
+ build(changes: readonly SyncChange[], options: TreeDisplayOptions = {}): ChangeTreeNode[] {
+ const opts = { ...DEFAULT_OPTIONS, ...options };
+ const root: ChangeTreeFolderNode = { type: 'folder', name: '', path: '', children: [] };
+ for (const change of changes) {
+ this.insert(root, change);
+ }
+ const nodes = this.collapseAndLimit(root.children, opts, 0);
+ return nodes;
+ }
+
+ private insert(root: ChangeTreeFolderNode, change: SyncChange): void {
+ const segments = change.path.split('/').filter(Boolean);
+ const fileName = segments.pop();
+ if (!fileName) return;
+
+ let folder = root;
+ let accumulatedPath = '';
+ for (const segment of segments) {
+ accumulatedPath = accumulatedPath ? `${accumulatedPath}/${segment}` : segment;
+ folder = this.getOrCreateFolder(folder, segment, accumulatedPath);
+ }
+
+ folder.children.push({
+ type: 'file',
+ id: change.id,
+ name: fileName,
+ path: change.path,
+ previousPath: change.previousPath,
+ kind: change.kind,
+ });
+ }
+
+ private getOrCreateFolder(parent: ChangeTreeFolderNode, name: string, path: string): ChangeTreeFolderNode {
+ const existing = parent.children.find(
+ (node): node is ChangeTreeFolderNode => node.type === 'folder' && node.name === name,
+ );
+ if (existing) return existing;
+
+ const created: ChangeTreeFolderNode = { type: 'folder', name, path, children: [] };
+ parent.children.push(created);
+ return created;
+ }
+
+ /**
+ * Applies `collapseSingleChild` and `maxDepth` to a depth's children.
+ *
+ * `collapseSingleChild` merges a folder whose only child is a single folder
+ * (no file siblings) into one combined node, joining names/paths with `/`.
+ * The merge repeats along a run of single-child folders so
+ * `a/b/c/d.md` collapses to `a/b/c` (one node) when every level has only one
+ * child folder. Files break the run, so `a/x.md` + `a/b/c/y.md` keeps `a`
+ * separate from the collapsed `b/c`.
+ *
+ * `maxDepth` flattens any folder nesting deeper than the limit into a
+ * single path-labelled node whose children are the files/subfolders at that
+ * point (no further nesting is rendered).
+ */
+ private collapseAndLimit(
+ nodes: ChangeTreeNode[],
+ opts: Required>,
+ depth: number,
+ ): ChangeTreeNode[] {
+ const result: ChangeTreeNode[] = [];
+ for (const node of nodes) {
+ if (node.type === 'file') {
+ result.push(node);
+ continue;
+ }
+
+ const collapsed = this.collapseSingleChildRun(node, opts);
+ const atDepthLimit = depth >= opts.maxDepth;
+
+ if (atDepthLimit) {
+ // Flatten deeper structure into one folder node holding all descendants' files.
+ result.push(this.flattenFolder(collapsed));
+ continue;
+ }
+
+ collapsed.children = this.collapseAndLimit(collapsed.children, opts, depth + 1);
+ result.push(collapsed);
+ }
+ return result;
+ }
+
+ private collapseSingleChildRun(
+ folder: ChangeTreeFolderNode,
+ opts: Required>,
+ ): ChangeTreeFolderNode {
+ if (!opts.collapseSingleChild) return folder;
+
+ let current = folder;
+ // Walk down while the current folder has exactly one child and it is a folder.
+ let onlyChild = current.children[0];
+ while (current.children.length === 1 && onlyChild && onlyChild.type === 'folder') {
+ current = this.mergeFolders(current, onlyChild);
+ onlyChild = current.children[0];
+ }
+ return current;
+ }
+
+ private mergeFolders(parent: ChangeTreeFolderNode, child: ChangeTreeFolderNode): ChangeTreeFolderNode {
+ return {
+ type: 'folder',
+ name: `${parent.name}/${child.name}`,
+ path: child.path,
+ children: child.children,
+ };
+ }
+
+ private flattenFolder(folder: ChangeTreeFolderNode): ChangeTreeFolderNode {
+ const files = this.collectFiles(folder);
+ return {
+ type: 'folder',
+ name: folder.name,
+ path: folder.path,
+ children: files,
+ };
+ }
+
+ private collectFiles(folder: ChangeTreeFolderNode): ChangeTreeNode[] {
+ const files: ChangeTreeNode[] = [];
+ for (const child of folder.children) {
+ if (child.type === 'file') {
+ files.push(child);
+ } else {
+ files.push(...this.collectFiles(child));
+ }
+ }
+ return files;
+ }
+}
\ No newline at end of file
diff --git a/src/logic/source-control/FileStatusAdapter.ts b/src/logic/source-control/FileStatusAdapter.ts
new file mode 100644
index 0000000..4230017
--- /dev/null
+++ b/src/logic/source-control/FileStatusAdapter.ts
@@ -0,0 +1,56 @@
+import type { FileStatus, SyncStatus } from '../sync-status-service';
+import { toChangeId, type SyncChange, type SyncChangeKind } from './types';
+
+const KIND_BY_STATUS: Record = {
+ synced: 'synced',
+ modified: 'local-modified',
+ 'remote-modified': 'remote-modified',
+ unsynced: 'local-only',
+ 'remote-only': 'remote-only',
+ 'local-deleted': 'local-deleted',
+ moved: 'moved',
+};
+
+/**
+ * Projects `FileStatus[]` (the existing sync-status domain's flat status map,
+ * as exposed by `SyncWorkspace.getStatuses()`) into `SyncChange[]` for the
+ * Source Control `ChangeRepository` / `SourceControlViewModel` layer added in
+ * Phase 1.
+ *
+ * One known gap versus the full `SyncChangeKind` model, a pre-existing limit
+ * of `FileStatus` rather than anything introduced here:
+ *
+ * - No `FileStatus` value ever produces `'conflict'`: conflicts are only
+ * detected during `SyncManager.pushFiles` (via `SyncPlanner.classify`
+ * against a stored base sha) and resolved interactively through
+ * `ObsidianSyncInteraction`, not pre-computed for display. Widening this
+ * is out of scope for a UI/wiring cutover -- it would mean adding new
+ * sync classification behavior, not just rewiring existing behavior.
+ *
+ * `'local-deleted'` (a tracked file removed locally, remote still holds it)
+ * IS produced and maps to `'local-deleted'`, keeping a user deletion distinct
+ * from a never-tracked `'remote-only'` download candidate.
+ *
+ * `'checking'` rows (status still being resolved) are omitted rather than
+ * mapped to a placeholder kind, so they don't flash into a section and back
+ * out once resolved.
+ *
+ * `ChangeId` is derived from the current path: `FileStatus` itself has no
+ * rename-stable identity (`SyncStatusRefreshService.handleFileRenamed`
+ * re-keys its map to the new path), so a change's id also changes when the
+ * file is renamed. That's an existing limit of the underlying data, not a
+ * regression -- the legacy status map re-keyed on rename the same way.
+ */
+export function toSyncChanges(statuses: readonly FileStatus[]): SyncChange[] {
+ const changes: SyncChange[] = [];
+ for (const status of statuses) {
+ if (status.status === 'checking') continue;
+ changes.push({
+ id: toChangeId(status.path),
+ path: status.path,
+ previousPath: status.movedFrom,
+ kind: KIND_BY_STATUS[status.status],
+ });
+ }
+ return changes;
+}
diff --git a/src/logic/source-control/OperationState.ts b/src/logic/source-control/OperationState.ts
new file mode 100644
index 0000000..d03869e
--- /dev/null
+++ b/src/logic/source-control/OperationState.ts
@@ -0,0 +1,39 @@
+import type { ChangeId } from './types';
+
+export type OperationStatus = 'idle' | 'running' | 'success' | 'failed';
+
+/**
+ * Tracks in-flight per-change operation status, independent of both the
+ * change model and push selection.
+ *
+ * Keyed by ChangeId rather than path so a rename/move doesn't lose in-flight
+ * status, and so two different changes that happen to share a path (e.g. a
+ * delete followed by a re-add) don't cross-contaminate each other's state.
+ */
+export class OperationState {
+ private readonly status = new Map();
+
+ start(changeId: ChangeId): void {
+ this.status.set(changeId, 'running');
+ }
+
+ succeed(changeId: ChangeId): void {
+ this.status.set(changeId, 'success');
+ }
+
+ fail(changeId: ChangeId): void {
+ this.status.set(changeId, 'failed');
+ }
+
+ reset(changeId: ChangeId): void {
+ this.status.delete(changeId);
+ }
+
+ get(changeId: ChangeId): OperationStatus {
+ return this.status.get(changeId) ?? 'idle';
+ }
+
+ clear(): void {
+ this.status.clear();
+ }
+}
diff --git a/src/logic/source-control/RefreshReason.ts b/src/logic/source-control/RefreshReason.ts
new file mode 100644
index 0000000..c70b1a8
--- /dev/null
+++ b/src/logic/source-control/RefreshReason.ts
@@ -0,0 +1,17 @@
+/**
+ * Why a Source Control refresh was triggered. Carried through the unified
+ * refresh pipeline (`SyncStatusRefreshService.refresh` → `sync.status` →
+ * `ChangeRepository` → ViewModel → UI) so each trigger is observable instead
+ * of every refresh being an anonymous rescan.
+ *
+ * - `startup` — the plugin's initial refresh on load (when
+ * `autoRefreshOnStartup` is on).
+ * - `manual` — the user clicked the Refresh button.
+ * - `local-change` — a watched vault file was created/modified/deleted and the
+ * `LocalChangeObserver` requested a rescan to reclassify affected paths.
+ * - `remote-change` — reserved for a future remote-polling trigger (not yet
+ * wired; the refresh pipeline already accepts it).
+ * - `sync-complete` — a push/pull just finished and the view is re-syncing the
+ * status store against the new remote head.
+ */
+export type RefreshReason = 'startup' | 'manual' | 'local-change' | 'remote-change' | 'sync-complete';
\ No newline at end of file
diff --git a/src/logic/source-control/RefreshState.ts b/src/logic/source-control/RefreshState.ts
new file mode 100644
index 0000000..ac74604
--- /dev/null
+++ b/src/logic/source-control/RefreshState.ts
@@ -0,0 +1,57 @@
+import type { RefreshReason } from './RefreshReason';
+
+/**
+ * Single-value refresh status for the whole Source Control view, mirroring
+ * {@link OperationState}'s API shape (start/fail/succeed/get/clear) but for
+ * one global refresh rather than per-{@link ChangeId} operations.
+ *
+ * Holds no refresh logic of its own: {@link SourceControlViewModel.refresh}
+ * delegates to the injected `syncWorkspace.refresh()` and only drives this
+ * holder so the UI can show "Refreshing…" / a failed state. Keeping it a
+ * separate holder (rather than reusing `OperationState`) avoids conflating a
+ * view-wide background refresh with per-change push/pull operations.
+ *
+ * Also records the {@link RefreshReason} of the most recent refresh and the
+ * epoch-ms it completed, so the header can surface a "Last checked: …" line
+ * and the trigger is observable for debugging.
+ */
+export type RefreshStatus = 'idle' | 'loading' | 'failed';
+
+export class RefreshState {
+ private status: RefreshStatus = 'idle';
+ private reason: RefreshReason | undefined;
+ private lastCheckedAt = 0;
+
+ start(reason: RefreshReason = 'manual'): void {
+ this.status = 'loading';
+ this.reason = reason;
+ }
+
+ fail(): void {
+ this.status = 'failed';
+ }
+
+ succeed(): void {
+ this.status = 'idle';
+ this.lastCheckedAt = Date.now();
+ }
+
+ /** Resets back to idle, clearing a prior failure so the button no longer shows the error state. */
+ clear(): void {
+ this.status = 'idle';
+ }
+
+ get(): RefreshStatus {
+ return this.status;
+ }
+
+ /** Why the most recent refresh was triggered, or `undefined` before the first one. */
+ getReason(): RefreshReason | undefined {
+ return this.reason;
+ }
+
+ /** Epoch-ms the most recent refresh completed (succeeded), or `0` if none yet. */
+ getLastCheckedAt(): number {
+ return this.lastCheckedAt;
+ }
+}
\ No newline at end of file
diff --git a/src/logic/source-control/SourceControlActionService.ts b/src/logic/source-control/SourceControlActionService.ts
new file mode 100644
index 0000000..9cdb841
--- /dev/null
+++ b/src/logic/source-control/SourceControlActionService.ts
@@ -0,0 +1,350 @@
+import type { PlannedPushBatch } from '../sync/PushCoordinator';
+import type { SyncWorkspace } from '../sync/SyncWorkspace';
+import { isSyncPlanEmpty, type DeleteQueueEntry, type PushResults, type SyncPlan, type SyncPlanEntry } from '../sync/types';
+import { type SyncExecutionResult, type SyncResultNotificationPort } from './SyncResultNotifier';
+import { defaultSyncAction } from './ChangeActionPolicy';
+import type { ChangeRepository } from './ChangeRepository';
+import type { OperationState } from './OperationState';
+import type { SourceControlItem } from './SourceControlViewModel';
+import type { ChangeId, SyncChange } from './types';
+
+/** Which side wins when resolving a change in the 'conflict' state. */
+export type ConflictResolution = 'local' | 'remote';
+
+/** Diff payload the Source Control diff pane can render directly (text-only; binary/symlink changes resolve to `null`). */
+export interface SourceControlDiffContent {
+ remote: string;
+ local: string;
+}
+
+/**
+ * Converts Source Control user intent (push / pull / delete-remote /
+ * delete-local / resolve-conflict on one or more `ChangeId`s) into calls
+ * against `SyncWorkspace` — the existing `SyncManager`-backed execution
+ * boundary already used by the sync-status UI — per
+ * docs/source-control-refactor/phase-2-action-unification.md.
+ *
+ * Per that doc's rules, this service DOES convert user intent into the call
+ * `SyncWorkspace`/`SyncManager` need (effectively "build the SyncPlan"), but
+ * it never talks to a Git provider directly and never (re-)classifies
+ * changes — it only resolves `ChangeId` -> `SyncChange` via the Phase 1
+ * `ChangeRepository` and reports per-change outcome through the Phase 1
+ * `OperationState`. Unknown/stale `ChangeId`s (e.g. a change that dropped out
+ * between the UI snapshot and the click) are silently skipped rather than
+ * throwing, since the repository is the single source of truth for what's
+ * still actionable.
+ */
+export class SourceControlActionService {
+ constructor(
+ private readonly changes: ChangeRepository,
+ private readonly operations: OperationState,
+ private readonly workspace: SyncWorkspace,
+ private readonly syncResultNotifier: SyncResultNotificationPort = { notify: () => {} },
+ ) {}
+
+ /** Pushes one or more changes (single push and batch push share this path). */
+ async push(changeIds: readonly ChangeId[]): Promise {
+ const targets = this.resolve(changeIds);
+ if (targets.length === 0) return;
+
+ this.startAll(targets);
+ try {
+ const results = await this.workspace.push(targets.map(target => target.path));
+ const failed = new Set(results.errors.map(error => error.file));
+ this.finishAll(targets, path => (failed.has(path) ? 'failed' : 'success'));
+ } catch {
+ this.failAll(targets);
+ }
+ }
+
+ /** Pulls one or more changes. */
+ async pull(changeIds: readonly ChangeId[]): Promise {
+ const targets = this.resolve(changeIds);
+ if (targets.length === 0) return;
+
+ this.startAll(targets);
+ try {
+ const results = await this.workspace.pull(targets.map(target => target.path));
+ const failed = new Set(results.errors.map(error => error.file));
+ this.finishAll(targets, path => (failed.has(path) ? 'failed' : 'success'));
+ } catch {
+ this.failAll(targets);
+ }
+ }
+
+ /** Deletes one or more changes from the remote only. */
+ async deleteRemote(changeIds: readonly ChangeId[]): Promise {
+ const targets = this.resolve(changeIds);
+ if (targets.length === 0) return;
+
+ this.startAll(targets);
+ try {
+ const result = await this.workspace.deleteRemote(targets.map(target => target.path));
+ const failed = new Set(result.errors.map(error => error.path));
+ this.finishAll(targets, path => (failed.has(path) ? 'failed' : 'success'));
+ } catch {
+ this.failAll(targets);
+ }
+ }
+
+ /**
+ * Syncs one or more changes as a single Sync Plan — the Sync Queue
+ * button's only entry point. Splits `changeIds` by
+ * {@link defaultSyncAction} into push/delete-remote/pull buckets, plans
+ * each without mutating anything, merges the result into one `SyncPlan`,
+ * shows exactly one confirm, and — if confirmed — commits the whole
+ * remote mutation set (pushes + moves + deletions) through
+ * `SyncWorkspace.commitResolvedBatch` as one provider call, then applies
+ * the pull bucket (zero-commit, local-only) separately. This is the fix
+ * for the "one Sync produces two remote commits" bug: previously the
+ * Sync Queue routed push/pull/delete-remote through three independent
+ * `SyncWorkspace` calls, each committing on its own.
+ */
+ async sync(changeIds: readonly ChangeId[]): Promise {
+ const targets = this.resolve(changeIds);
+ if (targets.length === 0) return;
+
+ const pushTargets: SyncChange[] = [];
+ const deleteTargets: SyncChange[] = [];
+ const pullTargets: SyncChange[] = [];
+ for (const target of targets) {
+ const action = defaultSyncAction(target.kind);
+ if (action === 'pull') pullTargets.push(target);
+ else if (action === 'delete-remote') deleteTargets.push(target);
+ else pushTargets.push(target);
+ }
+
+ let plan: { planned: PlannedPushBatch; confirmed: boolean } | null;
+ try {
+ plan = await this.planSync(pushTargets, pullTargets, deleteTargets);
+ } catch {
+ this.failAll(targets);
+ this.syncResultNotifier.notify({ ...SourceControlActionService.emptyExecutionResult(), failed: targets.length });
+ return;
+ }
+ if (!plan || !plan.confirmed) return;
+ const { planned } = plan;
+
+ this.startAll(targets);
+ const summary = SourceControlActionService.emptyExecutionResult();
+ if (planned.pushes.length > 0 || planned.moves.length > 0 || planned.keepRemote.length > 0 || planned.keepLocal.length > 0 || deleteTargets.length > 0) {
+ await this.commitRemoteBucket(planned, pushTargets, deleteTargets, summary);
+ }
+ if (pullTargets.length > 0) {
+ await this.applyPullBucket(pullTargets, summary);
+ }
+ this.syncResultNotifier.notify(summary);
+ }
+
+ /** Builds and confirms the merged Sync Plan; returns null if there's nothing to do or the user cancelled. */
+ private async planSync(
+ pushTargets: readonly SyncChange[],
+ pullTargets: readonly SyncChange[],
+ deleteTargets: readonly SyncChange[],
+ ): Promise<{ planned: PlannedPushBatch; confirmed: boolean } | null> {
+ const planned = pushTargets.length > 0
+ ? await this.workspace.planPush(pushTargets.map(target => target.path))
+ : SourceControlActionService.emptyPlannedBatch();
+ // A cancelled batch-conflict resolution is a separate interactive
+ // step that happens before the merged plan is even shown; honor it
+ // the same way pushFiles() does, without touching anything.
+ if (planned.cancelled) return null;
+
+ const pullPlan = pullTargets.length > 0
+ ? await this.workspace.planPull(pullTargets.map(target => target.path))
+ : SourceControlActionService.emptyPlan();
+
+ const deletions: SyncPlanEntry[] = deleteTargets.map(target => ({ path: target.path, name: basename(target.path) }));
+ const mergedPlan: SyncPlan = {
+ additions: planned.reviewPlan.additions,
+ modifications: planned.reviewPlan.modifications,
+ moves: planned.reviewPlan.moves,
+ deletions,
+ downloads: [...pullPlan.additions, ...pullPlan.modifications],
+ acceptedRemote: planned.reviewPlan.acceptedRemote,
+ skippedConflicts: planned.reviewPlan.skippedConflicts,
+ };
+ if (isSyncPlanEmpty(mergedPlan)) return null;
+
+ const confirmed = await this.workspace.confirmPlan(mergedPlan, 'sync');
+ return { planned, confirmed };
+ }
+
+ /** Commits the merged push/move/delete-remote bucket; a failure here only fails that bucket, not any already-applied pull. */
+ private async commitRemoteBucket(
+ planned: PlannedPushBatch,
+ pushTargets: readonly SyncChange[],
+ deleteTargets: readonly SyncChange[],
+ summary: SyncExecutionResult,
+ ): Promise {
+ try {
+ const deleteEntries: DeleteQueueEntry[] = deleteTargets.map(target => ({
+ path: target.path,
+ name: basename(target.path),
+ repoPath: this.workspace.toRepoPath(target.path),
+ }));
+ const results: PushResults = {
+ success: planned.immediate.success,
+ added: 0,
+ updated: planned.immediate.updated,
+ failed: planned.immediate.failed,
+ conflicts: 0,
+ resolvedConflicts: 0,
+ skippedConflicts: 0,
+ errors: [...planned.immediate.errors],
+ syncedPaths: [...planned.immediate.syncedPaths],
+ };
+ await this.workspace.commitResolvedBatch(planned.pushes, planned.moves, deleteEntries, planned.keepRemote, planned.keepLocal, results);
+ const failed = new Set(results.errors.map(error => error.file));
+ this.finishAll([...pushTargets, ...deleteTargets], path => (failed.has(path) ? 'failed' : 'success'));
+ this.addRemoteResult(summary, planned, deleteEntries, results);
+ } catch {
+ this.failAll([...pushTargets, ...deleteTargets]);
+ summary.failed += pushTargets.length + deleteTargets.length;
+ }
+ }
+
+ /** Applies the zero-commit pull bucket; a failure here only fails the pull targets, not any already-committed remote bucket. */
+ private async applyPullBucket(pullTargets: readonly SyncChange[], summary: SyncExecutionResult): Promise {
+ try {
+ const pullResults = await this.workspace.applyPull(pullTargets.map(target => target.path), { notify: false });
+ const failed = new Set(pullResults.errors.map(error => error.file));
+ this.finishAll(pullTargets, path => (failed.has(path) ? 'failed' : 'success'));
+ summary.downloaded += pullResults.added + pullResults.updated;
+ summary.failed += pullResults.failed;
+ summary.conflicts += pullResults.conflicts;
+ summary.errors.push(...pullResults.errors);
+ } catch {
+ this.failAll(pullTargets);
+ summary.failed += pullTargets.length;
+ }
+ }
+
+ private static emptyPlannedBatch(): PlannedPushBatch {
+ return {
+ reviewPlan: { additions: [], modifications: [], deletions: [], moves: [] },
+ pushes: [],
+ moves: [],
+ keepRemote: [],
+ keepLocal: [],
+ skippedConflicts: 0,
+ conflictedPaths: [],
+ cancelled: false,
+ immediate: { success: 0, updated: 0, failed: 0, errors: [], syncedPaths: [] },
+ };
+ }
+
+ private static emptyPlan(): SyncPlan {
+ return { additions: [], modifications: [], deletions: [], moves: [] };
+ }
+
+ private static emptyExecutionResult(): SyncExecutionResult {
+ return { added: 0, updated: 0, moved: 0, deleted: 0, downloaded: 0, acceptedRemote: 0, failed: 0, conflicts: 0, skippedConflicts: 0, errors: [] };
+ }
+
+ private addRemoteResult(
+ summary: SyncExecutionResult,
+ planned: PlannedPushBatch,
+ deletions: readonly DeleteQueueEntry[],
+ results: PushResults,
+ ): void {
+ const failedPaths = new Set(results.errors.map(error => error.file));
+ summary.added += planned.pushes.filter(entry => !entry.existingSha && !failedPaths.has(entry.path)).length;
+ summary.updated += planned.pushes.filter(entry => entry.existingSha && !failedPaths.has(entry.path)).length + planned.immediate.updated;
+ summary.moved += planned.moves.filter(entry => !failedPaths.has(entry.path)).length;
+ summary.deleted += deletions.filter(entry => !failedPaths.has(entry.path)).length;
+ summary.acceptedRemote += planned.keepRemote
+ .filter(conflict => !failedPaths.has(conflict.path))
+ .length;
+ summary.failed += results.failed;
+ summary.conflicts += results.conflicts;
+ summary.skippedConflicts += results.skippedConflicts;
+ summary.errors.push(...results.errors);
+ }
+
+ /** Deletes one or more changes from the local vault only. No batch primitive exists on `SyncWorkspace`, so each runs independently and one failure doesn't block the rest. */
+ async deleteLocal(changeIds: readonly ChangeId[]): Promise {
+ const targets = this.resolve(changeIds);
+ for (const target of targets) {
+ this.operations.start(target.id);
+ try {
+ await this.workspace.deleteLocal(target.path);
+ this.operations.succeed(target.id);
+ } catch {
+ this.operations.fail(target.id);
+ }
+ }
+ }
+
+ /**
+ * Resolves a single change in the 'conflict' state by pushing the local
+ * copy (local wins) or pulling the reviewed remote copy (remote wins).
+ * Remote resolution goes through the explicit acceptRemoteConflict
+ * boundary, which applies the reviewed remote blob without re-running the
+ * planner — so no second conflict modal can appear.
+ */
+ async resolveConflict(changeId: ChangeId, resolution: ConflictResolution): Promise {
+ const change = this.changes.getById(changeId);
+ if (!change) return;
+
+ this.operations.start(changeId);
+ try {
+ if (resolution === 'local') {
+ const results = await this.workspace.push([change.path]);
+ if (results.errors.length > 0) throw new Error(results.errors.map(error => error.error).join('; '));
+ this.operations.succeed(changeId);
+ this.syncResultNotifier.notify({ ...SourceControlActionService.emptyExecutionResult(), updated: 1 });
+ } else {
+ await this.workspace.acceptRemoteConflict(change.path);
+ this.operations.succeed(changeId);
+ this.syncResultNotifier.notify({ ...SourceControlActionService.emptyExecutionResult(), acceptedRemote: 1 });
+ }
+ } catch {
+ this.operations.fail(changeId);
+ this.syncResultNotifier.notify({ ...SourceControlActionService.emptyExecutionResult(), failed: 1 });
+ }
+ }
+
+ /**
+ * Supplies `SourceControlView`'s `loadDiffContent` callback: delegates to
+ * the existing `SyncWorkspace.getDiff`/`SyncDiffService` (no new diff
+ * logic) and resolves to `null` for binary/symlink changes, which the
+ * text-only diff pane can't render.
+ */
+ async loadDiffContent(item: SourceControlItem): Promise {
+ const diff = await this.workspace.getDiff(item.path);
+ if (typeof diff.remoteContent !== 'string' || typeof diff.localContent !== 'string') return null;
+ return { remote: diff.remoteContent, local: diff.localContent };
+ }
+
+ /** Resolves ChangeIds to their current SyncChange, dropping any that are no longer known to the repository. */
+ private resolve(changeIds: readonly ChangeId[]): SyncChange[] {
+ const targets: SyncChange[] = [];
+ for (const id of changeIds) {
+ const change = this.changes.getById(id);
+ if (change) targets.push(change);
+ }
+ return targets;
+ }
+
+ private startAll(targets: readonly SyncChange[]): void {
+ for (const target of targets) this.operations.start(target.id);
+ }
+
+ private finishAll(targets: readonly SyncChange[], statusFor: (path: string) => 'success' | 'failed'): void {
+ for (const target of targets) {
+ if (statusFor(target.path) === 'success') this.operations.succeed(target.id);
+ else this.operations.fail(target.id);
+ }
+ }
+
+ private failAll(targets: readonly SyncChange[]): void {
+ for (const target of targets) this.operations.fail(target.id);
+ }
+}
+
+/** Last path segment of a change path, for the Sync Plan's deletions section. */
+function basename(path: string): string {
+ const slash = path.lastIndexOf('/');
+ return slash === -1 ? path : path.slice(slash + 1);
+}
diff --git a/src/logic/source-control/SourceControlFilter.ts b/src/logic/source-control/SourceControlFilter.ts
new file mode 100644
index 0000000..2315872
--- /dev/null
+++ b/src/logic/source-control/SourceControlFilter.ts
@@ -0,0 +1,40 @@
+import type { SyncSelectionStore } from './SyncSelectionStore';
+import type { SyncChange, SyncChangeKind } from './types';
+
+export type SourceControlFilter =
+ | 'all'
+ | 'changes'
+ | 'ready-to-push'
+ | 'remote-changes'
+ | 'conflicts'
+ | 'synced';
+
+const LOCAL_KINDS: ReadonlySet = new Set(['local-only', 'local-modified', 'local-deleted', 'moved']);
+const REMOTE_KINDS: ReadonlySet = new Set(['remote-only', 'remote-modified']);
+
+/**
+ * Whether `change` belongs under `filter`. Filters are user-facing *action*
+ * semantics, not a raw mirror of Git status:
+ *
+ * - `all` — *actionable* changes only (everything except synced). A synced
+ * file needs no action, so it never appears under All. This keeps All from
+ * duplicating the Synced bucket.
+ * - `changes` — local-side changes only (local-only, local-modified,
+ * local-deleted, moved). Remote-only/conflict rows belong to their own
+ * filters, not Changes.
+ * - `ready-to-push` — defined purely by {@link SyncSelectionStore} membership;
+ * it's a user selection, not a fact derivable from the change's kind alone.
+ * - `remote-changes` — remote-only / remote-modified.
+ * - `conflicts` — conflict.
+ * - `synced` — synced (only surfaced when the user opts in via "Show synced").
+ */
+export function matchesFilter(change: SyncChange, filter: SourceControlFilter, selection: SyncSelectionStore): boolean {
+ switch (filter) {
+ case 'all': return change.kind !== 'synced';
+ case 'changes': return LOCAL_KINDS.has(change.kind);
+ case 'ready-to-push': return selection.isIncluded(change.id) && change.kind !== 'synced';
+ case 'remote-changes': return REMOTE_KINDS.has(change.kind);
+ case 'conflicts': return change.kind === 'conflict';
+ case 'synced': return change.kind === 'synced';
+ }
+}
\ No newline at end of file
diff --git a/src/logic/source-control/SourceControlSummary.ts b/src/logic/source-control/SourceControlSummary.ts
new file mode 100644
index 0000000..ad0e390
--- /dev/null
+++ b/src/logic/source-control/SourceControlSummary.ts
@@ -0,0 +1,89 @@
+import type { SyncSelectionStore } from './SyncSelectionStore';
+import type { SourceControlFilter } from './SourceControlFilter';
+import type { ChangeId, SyncChange, SyncChangeKind } from './types';
+
+/**
+ * Per-filter counts, keyed by the same {@link SourceControlFilter} values the
+ * filter menu renders. This is the single source of truth for every count the
+ * UI shows — the ViewModel passes it through unchanged and the view layer
+ * never recomputes a count itself.
+ *
+ * `synced` is the *rendered* count: it is `0` when synced changes are hidden
+ * (showSynced = false) so the UI can't display a synced count the user has
+ * asked to suppress. The raw synced bucket is still available on
+ * {@link SourceControlSummary.synced} for callers that need the actual figure.
+ */
+export type SourceControlCounts = Record;
+
+/**
+ * The complete presentation projection of a pending-change set: the raw
+ * buckets the UI renders from, plus the single {@link counts} object every
+ * count label reads from.
+ *
+ * Buckets are disjoint and exhaustive over {@link SyncChangeKind}:
+ * - {@link localChanges}: local-only, local-modified, local-deleted, moved
+ * - {@link remoteChanges}: remote-only, remote-modified
+ * - {@link conflicts}: conflict
+ * - {@link synced}: synced
+ * - {@link all}: the union of the three actionable buckets (everything except
+ * synced) — "All" means *actionable*, not "every row", so a synced file never
+ * appears under All.
+ * - {@link readyToPush}: the subset of actionable changes the user has selected
+ * for push (membership in {@link SyncSelectionStore}); it overlaps the other
+ * actionable buckets by design, since "ready to push" is a selection, not a
+ * change kind.
+ */
+export interface SourceControlSummary {
+ all: SyncChange[];
+ localChanges: SyncChange[];
+ remoteChanges: SyncChange[];
+ readyToPush: SyncChange[];
+ conflicts: SyncChange[];
+ synced: SyncChange[];
+ counts: SourceControlCounts;
+}
+
+const LOCAL_KINDS: ReadonlySet = new Set(['local-only', 'local-modified', 'local-deleted', 'moved']);
+const REMOTE_KINDS: ReadonlySet = new Set(['remote-only', 'remote-modified']);
+
+function isLocal(change: SyncChange): boolean { return LOCAL_KINDS.has(change.kind); }
+function isRemote(change: SyncChange): boolean { return REMOTE_KINDS.has(change.kind); }
+function isConflict(change: SyncChange): boolean { return change.kind === 'conflict'; }
+function isSynced(change: SyncChange): boolean { return change.kind === 'synced'; }
+function isActionable(change: SyncChange): boolean { return change.kind !== 'synced'; }
+
+/**
+ * Builds the single presentation projection the Source Control UI consumes.
+ * Pure: given the same `changes` + `selection` + `showSynced` it always
+ * produces the same {@link SourceControlSummary}, with no side effects on the
+ * store. Callers (the ViewModel) hold no count logic of their own.
+ *
+ * @param showSynced when false, {@link SourceControlCounts.synced} is reported
+ * as `0` (the UI hides the synced bucket) while {@link SourceControlSummary.synced}
+ * still holds the raw synced changes.
+ */
+export function buildSummary(
+ changes: readonly SyncChange[],
+ selection: SyncSelectionStore,
+ showSynced: boolean,
+): SourceControlSummary {
+ const localChanges = changes.filter(isLocal);
+ const remoteChanges = changes.filter(isRemote);
+ const conflicts = changes.filter(isConflict);
+ const synced = changes.filter(isSynced);
+ const all = changes.filter(isActionable);
+
+ const selectedIds = new Set(selection.getSelectedChangeIds());
+ const readyToPush = all.filter(change => selectedIds.has(change.id));
+
+ const counts: SourceControlCounts = {
+ all: all.length,
+ changes: localChanges.length,
+ 'ready-to-push': readyToPush.length,
+ 'remote-changes': remoteChanges.length,
+ conflicts: conflicts.length,
+ synced: showSynced ? synced.length : 0,
+ };
+
+ return { all, localChanges, remoteChanges, readyToPush, conflicts, synced, counts };
+}
\ No newline at end of file
diff --git a/src/logic/source-control/SourceControlViewModel.ts b/src/logic/source-control/SourceControlViewModel.ts
new file mode 100644
index 0000000..d457c9d
--- /dev/null
+++ b/src/logic/source-control/SourceControlViewModel.ts
@@ -0,0 +1,131 @@
+import type { ChangeRepository } from './ChangeRepository';
+import { buildSummary, type SourceControlCounts } from './SourceControlSummary';
+import type { OperationState, OperationStatus } from './OperationState';
+import type { RefreshReason } from './RefreshReason';
+import type { RefreshState, RefreshStatus } from './RefreshState';
+import type { SyncSelectionStore } from './SyncSelectionStore';
+import { matchesFilter, type SourceControlFilter } from './SourceControlFilter';
+import type { ChangeId, SyncChange, SyncChangeKind } from './types';
+
+/** One row of UI-ready state for a change: its own facts plus derived selection/operation status. */
+export interface SourceControlItem {
+ id: ChangeId;
+ path: string;
+ previousPath?: string;
+ kind: SyncChangeKind;
+ isSelectedForSync: boolean;
+ operationStatus: OperationStatus;
+}
+
+/** The complete state the Source Control UI needs to render for a given filter. */
+export interface SourceControlViewState {
+ filter: SourceControlFilter;
+ items: SourceControlItem[];
+ /**
+ * The actionable changes the user has currently selected for push, as
+ * full row items — the working sync queue. Empty when nothing is
+ * selected. Reuses the same `selected + non-synced` definition as
+ * `buildSummary.readyToPush` so the "SYNC QUEUE (N)" section and the Sync
+ * button count can't drift.
+ */
+ syncQueue: SourceControlItem[];
+ /** Current view-wide refresh status, surfaced so the header can render its states. */
+ refreshStatus: RefreshStatus;
+ /** Single-source counts from {@link buildSummary} — the view never recomputes these. */
+ counts: SourceControlCounts;
+}
+
+/**
+ * Combines `SyncChange[]` (via `ChangeRepository`), `SyncSelectionStore`, and
+ * `OperationState` into a single UI-ready snapshot. Holds no sync behavior of
+ * its own — it's a pure projection, so `SyncManager`/`SyncPlanner`/`SyncExecutor`
+ * stay untouched and the UI never needs to reach past this layer.
+ *
+ * Every count the UI shows comes from one place: {@link buildSummary}. The
+ * ViewModel only projects items for the active filter and forwards the
+ * summary's counts unchanged, so the filter menu, section headers, and tree
+ * can never drift apart.
+ *
+ * `showSynced` governs whether the synced bucket is surfaced: when false the
+ * synced count is reported as `0` and the `synced` filter yields no items,
+ * matching the "Show synced" toggle (default off).
+ *
+ * The one non-projection responsibility is {@link refresh}: it delegates to an
+ * injected refresh callback (wired to `SyncWorkspace.refresh()` in `main.ts`)
+ * and drives the injected {@link RefreshState} holder so the UI can surface
+ * loading/failed states. It holds no provider or refresh logic of its own,
+ * keeping the event-driven pipeline (`sync.status` → `ChangeRepository` →
+ * ViewModel → UI) intact — refresh never becomes a second population path.
+ */
+export class SourceControlViewModel {
+ constructor(
+ private readonly changes: ChangeRepository,
+ private readonly selectionStore: SyncSelectionStore,
+ private readonly operations: OperationState,
+ private readonly refreshSource: () => Promise,
+ private readonly refreshState: RefreshState,
+ ) {}
+
+ /**
+ * The sync-selection store, exposed so the view can toggle/clear
+ * selection without holding its own reference and reaching past the
+ * ViewModel. Reached via `viewModel.selection`
+ * (`selectForSync`/`deselectFromSync`/`selectMany`/`deselectMany`/
+ * `getSelectedChangeIds`).
+ */
+ get selection(): SyncSelectionStore { return this.selectionStore; }
+
+ getState(filter: SourceControlFilter = 'all', showSynced = false): SourceControlViewState {
+ const all = this.changes.getAll();
+ const summary = buildSummary(all, this.selectionStore, showSynced);
+ const items = all
+ .filter(change => matchesFilter(change, filter, this.selectionStore))
+ .filter(() => this.isRenderable(filter, showSynced))
+ .map(change => this.toItem(change));
+ const syncQueue = summary.readyToPush.map(change => this.toItem(change));
+ return { filter, items, syncQueue, refreshStatus: this.refreshState.get(), counts: summary.counts };
+ }
+
+ /**
+ * Triggers a view-wide refresh by delegating to the injected refresh
+ * source (the Sync Status service boundary) and tracking its lifecycle on
+ * the {@link RefreshState} holder so the header can render "Refreshing…"
+ * / a failed state. Refresh republishes `sync.status`, so the existing
+ * subscription repopulates `ChangeRepository` — this never becomes a
+ * second population path.
+ *
+ * The {@link RefreshReason} is recorded on the {@link RefreshState}
+ * holder purely for observability ("Last checked" + why); it does not
+ * change what the refresh does. Defaults to `'manual'` (the Refresh
+ * button); callers pass `'startup'`/`'local-change'`/`'sync-complete'` to
+ * surface a non-manual trigger.
+ */
+ async refresh(reason: RefreshReason = 'manual'): Promise {
+ this.refreshState.start(reason);
+ try {
+ await this.refreshSource();
+ this.refreshState.succeed();
+ } catch (error) {
+ this.refreshState.fail();
+ throw error;
+ }
+ }
+
+ private isRenderable(filter: SourceControlFilter, showSynced: boolean): boolean {
+ // Synced rows only render under the `synced` filter, and only when the
+ // user has opted in via "Show synced". `all`/`changes`/etc. already
+ // exclude synced via matchesFilter, so this only gates the synced view.
+ return !(filter === 'synced' && !showSynced);
+ }
+
+ private toItem(change: SyncChange): SourceControlItem {
+ return {
+ id: change.id,
+ path: change.path,
+ previousPath: change.previousPath,
+ kind: change.kind,
+ isSelectedForSync: this.selectionStore.isIncluded(change.id),
+ operationStatus: this.operations.get(change.id),
+ };
+ }
+}
\ No newline at end of file
diff --git a/src/logic/source-control/SyncResultNotifier.ts b/src/logic/source-control/SyncResultNotifier.ts
new file mode 100644
index 0000000..5d617c4
--- /dev/null
+++ b/src/logic/source-control/SyncResultNotifier.ts
@@ -0,0 +1,73 @@
+import { t } from '../../i18n';
+import type { SyncFailure } from '../sync/types';
+
+const COUNT_KEYS = {
+ added: 'sourceControl.notice.sync.added',
+ updated: 'sourceControl.notice.sync.updated',
+ moved: 'sourceControl.notice.sync.moved',
+ deleted: 'sourceControl.notice.sync.deleted',
+ downloaded: 'sourceControl.notice.sync.downloaded',
+ acceptedRemote: 'sourceControl.notice.sync.acceptedRemote',
+ failed: 'sourceControl.notice.sync.failedCount',
+ conflicts: 'sourceControl.notice.sync.conflicts',
+ skippedConflicts: 'sourceControl.notice.sync.skippedConflicts',
+} as const;
+
+export interface SyncExecutionResult {
+ added: number;
+ updated: number;
+ moved: number;
+ deleted: number;
+ downloaded: number;
+ acceptedRemote: number;
+ failed: number;
+ conflicts: number;
+ skippedConflicts: number;
+ errors: SyncFailure[];
+}
+
+export interface SyncResultNotificationPort {
+ notify(result: SyncExecutionResult): void;
+}
+
+/** Presents the single completion outcome owned by a unified Sync transaction. */
+export class SyncResultNotifier implements SyncResultNotificationPort {
+ constructor(private readonly showNotice: (message: string) => void) {}
+
+ notify(result: SyncExecutionResult): void {
+ const details = this.summary(result);
+ if (!details) return;
+ this.showNotice(t(this.messageKey(result), { details }));
+ }
+
+ private messageKey(result: SyncExecutionResult): 'sourceControl.notice.sync.success' | 'sourceControl.notice.sync.partial' | 'sourceControl.notice.sync.failed' {
+ if (result.failed > 0 || result.conflicts > 0 || result.skippedConflicts > 0) {
+ return this.hasSuccessfulWork(result) ? 'sourceControl.notice.sync.partial' : 'sourceControl.notice.sync.failed';
+ }
+ return 'sourceControl.notice.sync.success';
+ }
+
+ private hasSuccessfulWork(result: SyncExecutionResult): boolean {
+ return result.added + result.updated + result.moved + result.deleted + result.downloaded + result.acceptedRemote > 0;
+ }
+
+ private summary(result: SyncExecutionResult): string {
+ const parts = [
+ this.count(result.added, 'added'),
+ this.count(result.updated, 'updated'),
+ this.count(result.moved, 'moved'),
+ this.count(result.deleted, 'deleted'),
+ this.count(result.downloaded, 'downloaded'),
+ this.count(result.acceptedRemote, 'acceptedRemote'),
+ this.count(result.failed, 'failed'),
+ this.count(result.conflicts, 'conflicts'),
+ this.count(result.skippedConflicts, 'skippedConflicts'),
+ ].filter((part): part is string => part !== undefined);
+ return parts.join(', ');
+ }
+
+ private count(value: number, kind: 'added' | 'updated' | 'moved' | 'deleted' | 'downloaded' | 'acceptedRemote' | 'failed' | 'conflicts' | 'skippedConflicts'): string | undefined {
+ if (value === 0) return undefined;
+ return t(COUNT_KEYS[kind], { count: value });
+ }
+}
diff --git a/src/logic/source-control/SyncSelectionStore.ts b/src/logic/source-control/SyncSelectionStore.ts
new file mode 100644
index 0000000..b1a7060
--- /dev/null
+++ b/src/logic/source-control/SyncSelectionStore.ts
@@ -0,0 +1,53 @@
+import type { ChangeId } from './types';
+
+/**
+ * Tracks which pending sync changes are selected for the Sync Queue —
+ * independent of the underlying change/plan model and of any UI. Named for
+ * "selected for sync" rather than "push" since the Sync Queue it backs holds
+ * push, pull, and delete-remote candidates alike (a queued `remote-only` row
+ * pulls, a queued `local-deleted` row deletes remotely by default). Also
+ * deliberately avoids VCS stage/unstage terminology since this isn't a
+ * staging area.
+ *
+ * Keyed by ChangeId rather than path so a rename/move doesn't drop the
+ * selection.
+ */
+export class SyncSelectionStore {
+ private readonly selected = new Set();
+
+ selectForSync(changeId: ChangeId): void {
+ this.selected.add(changeId);
+ }
+
+ deselectFromSync(changeId: ChangeId): void {
+ this.selected.delete(changeId);
+ }
+
+ /** Selects a batch of changes for sync in one call (folder "select all"). */
+ selectMany(changeIds: readonly ChangeId[]): void {
+ for (const id of changeIds) this.selected.add(id);
+ }
+
+ /** Deselects a batch of changes from sync in one call ("clear queue" / folder deselect). */
+ deselectMany(changeIds: readonly ChangeId[]): void {
+ for (const id of changeIds) this.selected.delete(id);
+ }
+
+ isIncluded(changeId: ChangeId): boolean {
+ return this.selected.has(changeId);
+ }
+
+ getSelectedChangeIds(): ChangeId[] {
+ return [...this.selected];
+ }
+
+ /** Drops selections for change ids that are no longer present, keeping the rest. */
+ refresh(currentChangeIds: readonly ChangeId[]): void {
+ const present = new Set(currentChangeIds);
+ for (const changeId of this.selected) {
+ if (!present.has(changeId)) {
+ this.selected.delete(changeId);
+ }
+ }
+ }
+}
diff --git a/src/logic/source-control/types.ts b/src/logic/source-control/types.ts
new file mode 100644
index 0000000..3cec8d5
--- /dev/null
+++ b/src/logic/source-control/types.ts
@@ -0,0 +1,49 @@
+declare const changeIdBrand: unique symbol;
+
+/**
+ * Identity for a pending sync change, used as the key for selection and
+ * operation state instead of a bare path string. Currently minted from the
+ * file path itself (see `FileStatusAdapter.toChangeId`), so it does NOT yet
+ * survive a rename/move — a renamed file gets a new id like any other path
+ * change. The type exists to give callers a single seam to make identity
+ * genuinely path-independent later without touching every call site.
+ *
+ * Branded (rather than a plain `string` alias) so callers can't pass a raw
+ * file path where a ChangeId is expected.
+ */
+export type ChangeId = string & { readonly [changeIdBrand]: never };
+
+/** Wraps a raw id string as a ChangeId at the one place it's minted. */
+export function toChangeId(id: string): ChangeId {
+ return id as ChangeId;
+}
+
+/**
+ * How a pending change relates local and remote state, independent of any
+ * push/pull selection or in-flight operation. Mirrors `SyncClassification`
+ * from the sync domain plus `moved`, since a tracked rename/move is a
+ * distinct case the Source Control UI must render differently.
+ */
+export type SyncChangeKind =
+ | 'local-only'
+ | 'local-modified'
+ | 'local-deleted'
+ | 'remote-only'
+ | 'remote-modified'
+ | 'moved'
+ | 'conflict'
+ | 'synced';
+
+/**
+ * A single pending sync change as consumed by the Source Control ViewModel
+ * layer. Deliberately decoupled from `PlannedFileAction`/`FileStatus` in the
+ * sync domain: this is the read-only projection the UI layer works with, keyed
+ * by the stable `ChangeId` rather than path.
+ */
+export interface SyncChange {
+ id: ChangeId;
+ path: string;
+ /** Present when this change is a tracked rename/move, for display only. */
+ previousPath?: string;
+ kind: SyncChangeKind;
+}
diff --git a/src/logic/sync-status-service.ts b/src/logic/sync-status-service.ts
index fce41a6..0758a74 100644
--- a/src/logic/sync-status-service.ts
+++ b/src/logic/sync-status-service.ts
@@ -1,7 +1,18 @@
import type { TFile } from 'obsidian';
-/** A resolved status shown for a file after sync facts have been compared. */
-export type SyncStatus = 'synced' | 'modified' | 'unsynced' | 'remote-only' | 'moved';
+/**
+ * A resolved status shown for a file after sync facts have been compared.
+ *
+ * `local-deleted` is distinct from `remote-only`: both have a file on the
+ * remote and no local file, but `local-deleted` means the file was previously
+ * tracked locally (sync metadata exists for the path) and the user has since
+ * removed it -- a potential remote deletion to push -- whereas `remote-only`
+ * means the file was never tracked locally, so it's simply available to
+ * download. Keeping them apart lets the Source Control UI badge one as
+ * "Deleted locally" and the other as "Remote available" instead of conflating
+ * the two under a single `remote-only` state.
+ */
+export type SyncStatus = 'synced' | 'modified' | 'remote-modified' | 'unsynced' | 'remote-only' | 'local-deleted' | 'moved';
/** The complete status record presented by a sync-status view. */
export interface FileStatus {
@@ -19,12 +30,23 @@ export interface FileStatus {
* Facts needed to resolve a file's status. A tracked move is intentionally
* independent of file-content facts: a rename plus an edit remains a move
* until that move has been pushed or reverted.
+ *
+ * The remote-only case carries an optional `wasTracked` flag: when true the
+ * file was previously synced locally (sync metadata exists for the path) and
+ * has since been removed, so it classifies as `local-deleted` rather than
+ * `remote-only`.
+ *
+ * When both sides exist and differ, `localChanged`/`remoteChanged` (each
+ * relative to the last-synced baseline sha) let `classify` tell "only the
+ * remote side moved" apart from "the local side moved" or "both did" —
+ * without them (no baseline on record) the two-sided diff falls back to the
+ * direction-blind `modified`.
*/
export type SyncStatusFacts =
| { movedFrom: string }
| { localExists: true; remoteExists: false }
- | { localExists: false; remoteExists: true }
- | { localExists: true; remoteExists: true; contentsEqual: boolean };
+ | { localExists: false; remoteExists: true; wasTracked?: boolean }
+ | { localExists: true; remoteExists: true; contentsEqual: boolean; localChanged?: boolean; remoteChanged?: boolean };
/** Resolves sync facts into the one status the UI may present for a file. */
export class SyncStatusService {
@@ -34,8 +56,10 @@ export class SyncStatusService {
classify(facts: SyncStatusFacts): SyncStatus {
if ('movedFrom' in facts) return 'moved';
if (facts.localExists && !facts.remoteExists) return 'unsynced';
- if (!facts.localExists && facts.remoteExists) return 'remote-only';
- return facts.contentsEqual ? 'synced' : 'modified';
+ if (!facts.localExists && facts.remoteExists) return facts.wasTracked ? 'local-deleted' : 'remote-only';
+ if (facts.contentsEqual) return 'synced';
+ if (facts.localChanged === false && facts.remoteChanged === true) return 'remote-modified';
+ return 'modified';
}
get size(): number { return this.statuses.size; }
diff --git a/src/logic/sync/PullCoordinator.ts b/src/logic/sync/PullCoordinator.ts
index 59fb332..9aec22f 100644
--- a/src/logic/sync/PullCoordinator.ts
+++ b/src/logic/sync/PullCoordinator.ts
@@ -4,13 +4,14 @@ import type { GitFile, GitServiceInterface, GitTreeEntry } from '../../services/
import { gitBlobSha } from '../../utils/git-blob-sha';
import { logger } from '../../utils/logger';
import { contentsEqual, isBinaryPath } from '../../utils/path';
+import { t } from '../../i18n';
import type { PullExecutor } from './PullExecutor';
import type { SyncScanner } from './SyncScanner';
import { SyncPlanner } from './SyncPlanner';
-import type { PlannedFileAction, SyncPlan, SyncPlanEntry, SyncResult } from './types';
+import type { PlannedFileAction, PullExecutionOptions, SyncPlan, SyncPlanEntry, SyncResult } from './types';
import { isSyncPlanEmpty } from './types';
-type BatchOutcome = 'done' | 'unchanged' | 'conflict';
+type BatchOutcome = 'added' | 'updated' | 'unchanged' | 'conflict';
type PlanKind = 'addition' | 'modification' | 'unchanged' | 'conflict' | 'skip';
export interface PullCoordinatorDependencies {
@@ -40,11 +41,27 @@ export class PullCoordinator {
const tree = await this.resolveTree(remoteTree);
const plan = await this.planPullBatch(files, tree);
if (!isSyncPlanEmpty(plan) && !await this.dependencies.confirmPlan(plan)) {
- return { success: 0, failed: 0, conflicts: 0, errors: [] };
+ return { success: 0, added: 0, updated: 0, failed: 0, conflicts: 0, errors: [] };
}
return this.processBatch(files, onProgress, tree);
}
+ /**
+ * Applies an already-planned/confirmed pull batch without showing its own
+ * confirm modal — for a unified Sync Plan orchestrator that already got
+ * one confirmation covering the whole plan (pushes/moves/deletions and
+ * this download set together), so pulling shouldn't prompt a second time.
+ */
+ async applyPullBatch(
+ files: Array,
+ onProgress?: (current: number, total: number, fileName: string) => void,
+ remoteTree?: GitTreeEntry[],
+ options: PullExecutionOptions = {},
+ ): Promise {
+ const tree = await this.resolveTree(remoteTree);
+ return this.processBatch(files, onProgress, tree, options);
+ }
+
async planPullBatch(files: Array, remoteTree?: GitTreeEntry[]): Promise {
const tree = remoteTree ? new Map(remoteTree.map(entry => [entry.path, entry])) : undefined;
const plan: SyncPlan = { additions: [], modifications: [], deletions: [], moves: [] };
@@ -74,8 +91,9 @@ export class PullCoordinator {
files: Array,
onProgress?: (current: number, total: number, fileName: string) => void,
remoteTree?: GitTreeEntry[],
+ options: PullExecutionOptions = {},
): Promise {
- const results: SyncResult = { success: 0, failed: 0, conflicts: 0, errors: [] };
+ const results: SyncResult = { success: 0, added: 0, updated: 0, failed: 0, conflicts: 0, errors: [] };
const tree = remoteTree ? new Map(remoteTree.map(entry => [entry.path, entry])) : undefined;
for (let index = 0; index < files.length; index += 1) {
const file = files[index];
@@ -84,7 +102,8 @@ export class PullCoordinator {
onProgress?.(index + 1, files.length, name);
try {
const outcome = await this.processFile(file, path, name, isString, tree);
- if (outcome === 'done') results.success += 1;
+ if (outcome === 'added') { results.success += 1; results.added += 1; }
+ else if (outcome === 'updated') { results.success += 1; results.updated += 1; }
else if (outcome === 'conflict') results.conflicts += 1;
} catch (error) {
logger.error(`Failed to pull ${path}:`, error);
@@ -93,7 +112,7 @@ export class PullCoordinator {
}
}
await this.dependencies.saveSettings();
- this.notifyResult(results);
+ if (options.notify !== false) this.notifyResult(results);
return results;
}
@@ -135,7 +154,7 @@ export class PullCoordinator {
if (decision.action === 'resolve-conflict') return 'conflict';
const target = typeof file === 'string' ? { path, name } : file;
await this.dependencies.executor.pull(target, remote.content, remote.sha, true, this.symlinkTarget(remote));
- return 'done';
+ return decision.action === 'pull-create' ? 'added' : 'updated';
}
private async classifyFromTree(
@@ -218,13 +237,22 @@ export class PullCoordinator {
}
private notifyResult(result: SyncResult): void {
- if (result.success > 0) this.dependencies.notify(`Pulled ${result.success} file(s) to ${this.dependencies.serviceName()}`);
+ if (result.success > 0) {
+ const service = this.dependencies.serviceName();
+ const key = this.pullSummaryKey(result.added, result.updated);
+ this.dependencies.notify(t(key, { service, added: result.added, updated: result.updated }));
+ }
if (result.conflicts > 0) {
this.dependencies.notify(`Skipped ${result.conflicts} file(s) with conflicting changes on both sides. Push or pull each one individually to resolve.`, 8000);
}
if (result.failed > 0) this.dependencies.notify(`Failed to pull ${result.failed} file(s). Check console for details.`);
}
+ private pullSummaryKey(added: number, updated: number): 'sync.notice.pullSummary' | 'sync.notice.pullAddedOnly' | 'sync.notice.pullUpdatedOnly' {
+ if (added > 0 && updated > 0) return 'sync.notice.pullSummary';
+ return added > 0 ? 'sync.notice.pullAddedOnly' : 'sync.notice.pullUpdatedOnly';
+ }
+
private errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
diff --git a/src/logic/sync/PullExecutor.ts b/src/logic/sync/PullExecutor.ts
index e2377f4..1bafef4 100644
--- a/src/logic/sync/PullExecutor.ts
+++ b/src/logic/sync/PullExecutor.ts
@@ -45,7 +45,12 @@ export class PullExecutor {
if (!silent) this.notify(`Pulled ${file.name} from ${this.getServiceName()}`);
}
- private async write(file: TFile | PullFileTarget, content: string | ArrayBuffer): Promise {
+ private resolveExistingFile(target: TFile | PullFileTarget): TFile | PullFileTarget {
+ return target instanceof TFile ? target : this.app.vault.getFileByPath(target.path) ?? target;
+ }
+
+ private async write(target: TFile | PullFileTarget, content: string | ArrayBuffer): Promise {
+ const file = this.resolveExistingFile(target);
if (typeof content !== 'string') {
if (file instanceof TFile) await this.app.vault.modifyBinary(file, content);
else await this.app.vault.adapter.writeBinary(file.path, content);
diff --git a/src/logic/sync/PushCoordinator.ts b/src/logic/sync/PushCoordinator.ts
index c5923e8..0c6ce95 100644
--- a/src/logic/sync/PushCoordinator.ts
+++ b/src/logic/sync/PushCoordinator.ts
@@ -5,12 +5,14 @@ import { gitBlobSha } from '../../utils/git-blob-sha';
import { logger } from '../../utils/logger';
import { contentsEqual, isBinaryPath } from '../../utils/path';
import { readLocalSymlinkTarget } from '../../utils/symlink';
+import { t } from '../../i18n';
import type { ConflictResolver } from './ConflictResolver';
import type { PushExecutor } from './PushExecutor';
import type { SyncScanner } from './SyncScanner';
import { SyncPlanner } from './SyncPlanner';
import {
type BatchPushConflict,
+ type DeleteQueueEntry,
type MoveQueueEntry,
type PushQueueEntry,
type PushResults,
@@ -28,6 +30,19 @@ interface BatchPushPlan {
autoSkipped: SyncPlanEntry[];
}
+/** The classified-and-conflict-resolved result of {@link PushCoordinator.planSyncBatch}. */
+export interface PlannedPushBatch {
+ reviewPlan: SyncPlan;
+ pushes: PushQueueEntry[];
+ moves: MoveQueueEntry[];
+ keepRemote: BatchPushConflict[];
+ keepLocal: BatchPushConflict[];
+ skippedConflicts: number;
+ conflictedPaths: string[];
+ cancelled: boolean;
+ immediate: { success: number; updated: number; failed: number; errors: Array<{ file: string; error: string }>; syncedPaths: Array<{ path: string; sha?: string }> };
+}
+
interface PushCoordinatorDependencies {
app: App;
gitService(): GitServiceInterface;
@@ -37,7 +52,7 @@ interface PushCoordinatorDependencies {
conflicts: ConflictResolver;
isPathIgnored(path: string): boolean;
confirmPlan(plan: SyncPlan): Promise;
- resolveConflicts(conflicts: BatchPushConflict[], totalFiles: number, safeCount: number): Promise;
+ resolveConflicts(conflicts: BatchPushConflict[], safeCount: number): Promise;
updateMetadata(path: string, sha: string): Promise;
migrateBaseline(path: string, repoPath: string, entry: GitTreeEntry | undefined): Promise;
saveSettings(): Promise;
@@ -64,6 +79,7 @@ export class PushCoordinator {
const results: PushResults = {
...this.emptyResults(),
success: immediate.success,
+ updated: immediate.updated,
failed: immediate.failed,
conflicts: plan.conflicts.length + plan.autoSkipped.length,
errors: immediate.errors,
@@ -73,7 +89,7 @@ export class PushCoordinator {
const keepRemote: BatchPushConflict[] = [];
const keepLocal: BatchPushConflict[] = [];
- if (!await this.resolvePlanConflicts(plan, syncableFiles.length, results, skipped, keepRemote, keepLocal)) {
+ if (!await this.resolvePlanConflicts(plan, results, skipped, keepRemote, keepLocal)) {
return results;
}
@@ -86,20 +102,79 @@ export class PushCoordinator {
return results;
}
- await this.commitResolvedBatch(plan.pushes, plan.moves, keepRemote, keepLocal, results);
+ await this.commitResolvedBatch(plan.pushes, plan.moves, [], keepRemote, keepLocal, results);
results.skippedConflicts = skipped.length + plan.autoSkipped.length;
await this.dependencies.saveSettings();
this.notifyResult(results);
return results;
}
+ /**
+ * Classifies and conflict-resolves a batch without confirming or
+ * committing — the plan-building half of `pushFiles()`, exposed so a
+ * caller merging this with other change kinds (deletions, downloads)
+ * into one combined Sync Plan can show a single review/confirm step
+ * before calling {@link commitResolvedBatch} itself. `pushFiles()` keeps
+ * its own inline classify→confirm→commit flow for standalone push-only
+ * callers rather than routing through this, so existing single-purpose
+ * push behavior is untouched.
+ */
+ async planSyncBatch(
+ files: Array,
+ onProgress?: (current: number, total: number, fileName: string) => void,
+ remoteTree?: GitTreeEntry[],
+ ): Promise {
+ const syncableFiles = files.filter(file => file && !this.dependencies.isPathIgnored(this.fileInfo(file).path));
+ if (syncableFiles.length === 0) {
+ return {
+ reviewPlan: { additions: [], modifications: [], deletions: [], moves: [] },
+ pushes: [],
+ moves: [],
+ keepRemote: [],
+ keepLocal: [],
+ skippedConflicts: 0,
+ conflictedPaths: [],
+ cancelled: false,
+ immediate: { success: 0, updated: 0, failed: 0, errors: [], syncedPaths: [] },
+ };
+ }
+
+ const tree = remoteTree ?? await this.dependencies.gitService().listFilesDetailed(this.dependencies.settings.branch, false);
+ const { plan, immediate } = await this.buildPlan(syncableFiles, onProgress, tree);
+ const results: PushResults = {
+ ...this.emptyResults(),
+ success: immediate.success,
+ updated: immediate.updated,
+ failed: immediate.failed,
+ conflicts: plan.conflicts.length + plan.autoSkipped.length,
+ errors: immediate.errors,
+ syncedPaths: immediate.syncedPaths,
+ };
+ const skipped: BatchPushConflict[] = [];
+ const keepRemote: BatchPushConflict[] = [];
+ const keepLocal: BatchPushConflict[] = [];
+ const resolved = await this.resolvePlanConflicts(plan, results, skipped, keepRemote, keepLocal);
+ const reviewPlan = this.buildReviewPlan(plan, skipped, keepRemote);
+
+ return {
+ reviewPlan,
+ pushes: plan.pushes,
+ moves: plan.moves,
+ keepRemote,
+ keepLocal,
+ skippedConflicts: skipped.length + plan.autoSkipped.length,
+ conflictedPaths: this.conflictedPaths(plan),
+ cancelled: !resolved,
+ immediate,
+ };
+ }
+
private emptyResults(): PushResults {
- return { success: 0, failed: 0, conflicts: 0, resolvedConflicts: 0, skippedConflicts: 0, errors: [], syncedPaths: [] };
+ return { success: 0, added: 0, updated: 0, failed: 0, conflicts: 0, resolvedConflicts: 0, skippedConflicts: 0, errors: [], syncedPaths: [] };
}
private async resolvePlanConflicts(
plan: BatchPushPlan,
- totalFiles: number,
results: PushResults,
skipped: BatchPushConflict[],
keepRemote: BatchPushConflict[],
@@ -108,7 +183,6 @@ export class PushCoordinator {
if (plan.conflicts.length === 0) return true;
const resolved = await this.dependencies.resolveConflicts(
plan.conflicts,
- totalFiles,
plan.pushes.length + plan.moves.length,
);
if (!resolved) {
@@ -159,22 +233,31 @@ export class PushCoordinator {
return [...plan.conflicts.map(conflict => conflict.path), ...plan.autoSkipped.map(entry => entry.path)];
}
- private async commitResolvedBatch(
+ /**
+ * Commits the resolved pushes/moves/deletions as one provider mutation
+ * set via `PushExecutor.commitBatch` — public so a unified Sync Plan
+ * orchestrator (which merges pushes/moves from {@link planSyncBatch} with
+ * deletions from elsewhere) can commit everything through a single call
+ * after its own single confirm step, rather than each change kind
+ * committing separately.
+ */
+ async commitResolvedBatch(
pushes: PushQueueEntry[],
moves: MoveQueueEntry[],
+ deletions: DeleteQueueEntry[],
keepRemote: BatchPushConflict[],
keepLocal: BatchPushConflict[],
results: PushResults,
): Promise {
const stale = keepLocal.length > 0 ? await this.dependencies.conflicts.findStale(keepLocal) : [];
if (stale.length > 0) {
- this.recordStaleFailure(pushes, moves, stale, results);
+ this.recordStaleFailure(pushes, moves, deletions, stale, results);
return;
}
- const hadWork = pushes.length > 0 || moves.length > 0;
+ const hadWork = pushes.length > 0 || moves.length > 0 || deletions.length > 0;
const failedBefore = results.failed;
- if (hadWork) await this.dependencies.executor.commitBatch(pushes, moves, results);
+ if (hadWork) await this.dependencies.executor.commitBatch(pushes, moves, deletions, results);
if (hadWork && results.failed !== failedBefore) return;
const keepLocalPaths = new Set(keepLocal.map(conflict => conflict.path));
@@ -185,11 +268,12 @@ export class PushCoordinator {
private recordStaleFailure(
pushes: PushQueueEntry[],
moves: MoveQueueEntry[],
+ deletions: DeleteQueueEntry[],
stale: BatchPushConflict[],
results: PushResults,
): void {
const message = `Remote content changed since you reviewed this conflict (${stale.map(conflict => conflict.path).join(', ')}). Nothing was pushed — resolve the conflict again.`;
- for (const item of [...pushes, ...moves]) {
+ for (const item of [...pushes, ...moves, ...deletions]) {
results.failed += 1;
results.errors.push({ file: item.path, error: message });
}
@@ -201,10 +285,10 @@ export class PushCoordinator {
remoteTree: GitTreeEntry[],
): Promise<{
plan: BatchPushPlan;
- immediate: { success: number; failed: number; errors: Array<{ file: string; error: string }>; syncedPaths: Array<{ path: string; sha?: string }> };
+ immediate: { success: number; updated: number; failed: number; errors: Array<{ file: string; error: string }>; syncedPaths: Array<{ path: string; sha?: string }> };
}> {
const plan: BatchPushPlan = { pushes: [], moves: [], conflicts: [], autoSkipped: [] };
- const immediate = { success: 0, failed: 0, errors: [] as Array<{ file: string; error: string }>, syncedPaths: [] as Array<{ path: string; sha?: string }> };
+ const immediate = { success: 0, updated: 0, failed: 0, errors: [] as Array<{ file: string; error: string }>, syncedPaths: [] as Array<{ path: string; sha?: string }> };
const tree = new Map(remoteTree.map(entry => [entry.path, entry]));
const hasOrphans = this.hasOrphanedRenameMetadata();
@@ -217,6 +301,7 @@ export class PushCoordinator {
const outcome = await this.classifyCandidate(file, info, tree, plan, hasOrphans);
if (outcome === 'done') {
immediate.success += 1;
+ immediate.updated += 1;
immediate.syncedPaths.push({ path: info.path });
}
} catch (error) {
@@ -417,14 +502,21 @@ export class PushCoordinator {
private notifyResult(results: PushResults): void {
if (results.success > 0) {
- const commitNote = results.resolvedConflicts > 0 ? ' in one commit' : '';
- this.dependencies.notify(`Pushed ${results.success} file(s) to ${this.dependencies.serviceName()}${commitNote}.`);
+ const commitNote = results.resolvedConflicts > 0 ? t('sync.notice.pushCommitNote') : '';
+ const service = this.dependencies.serviceName();
+ const key = this.pushSummaryKey(results.added, results.updated);
+ this.dependencies.notify(t(key, { service, added: results.added, updated: results.updated, commitNote }));
}
if (results.resolvedConflicts > 0) this.dependencies.notify(`Resolved ${results.resolvedConflicts} conflict(s).`);
if (results.skippedConflicts > 0) this.dependencies.notify(`Skipped ${results.skippedConflicts} conflict(s).`, 8000);
if (results.failed > 0) this.dependencies.notify(`Failed to push ${results.failed} file(s). Check console for details.`);
}
+ private pushSummaryKey(added: number, updated: number): 'sync.notice.pushSummary' | 'sync.notice.pushAddedOnly' | 'sync.notice.pushUpdatedOnly' {
+ if (added > 0 && updated > 0) return 'sync.notice.pushSummary';
+ return added > 0 ? 'sync.notice.pushAddedOnly' : 'sync.notice.pushUpdatedOnly';
+ }
+
private fileInfo(file: TFile | string): ReturnType {
return this.dependencies.scanner.fileInfo(file);
}
diff --git a/src/logic/sync/PushExecutor.ts b/src/logic/sync/PushExecutor.ts
index c003568..6215c49 100644
--- a/src/logic/sync/PushExecutor.ts
+++ b/src/logic/sync/PushExecutor.ts
@@ -1,7 +1,7 @@
import type { GitServiceInterface } from '../../services/git-service-interface';
import { gitBlobSha } from '../../utils/git-blob-sha';
import { MAX_BATCH_PUSH_SIZE } from '../../services/git-service-base';
-import type { MoveQueueEntry, PushQueueEntry, PushResults } from './types';
+import type { DeleteQueueEntry, MoveQueueEntry, PushQueueEntry, PushResults } from './types';
export interface PushFileTarget {
path: string;
@@ -24,6 +24,7 @@ export class PushExecutor {
private readonly getServiceName: () => string,
private readonly notify: (message: string) => void = () => undefined,
private readonly clearMovedSource: (path: string) => void = () => undefined,
+ private readonly clearMetadata: (path: string) => Promise = () => Promise.resolve(),
) {}
async push(
@@ -42,7 +43,7 @@ export class PushExecutor {
existingRevision,
);
const sha = result.sha ?? await gitBlobSha(content);
- await this.updateMetadata(file.path, sha);
+ await this.persistMetadata(file.path, sha, `Pushed ${file.name} to ${this.getServiceName()}`);
if (!silent) this.notify(`Pushed ${file.name} to ${this.getServiceName()}`);
return sha;
}
@@ -66,14 +67,21 @@ export class PushExecutor {
this.getBranch(),
`Update ${file.name} from Obsidian`,
);
- if (result.sha) await this.updateMetadata(file.path, result.sha);
+ if (result.sha) await this.persistMetadata(file.path, result.sha, `Pushed symlink ${file.name} to ${this.getServiceName()}`);
if (!silent) this.notify(`Pushed symlink ${file.name} to ${this.getServiceName()}`);
return { handled: true, synced: true, sha: result.sha };
}
- async commitBatch(toPush: PushQueueEntry[], toMove: MoveQueueEntry[], results: PushResults): Promise {
+ /**
+ * Commits pushes, moves, and plain deletions as one provider mutation set
+ * per MAX_BATCH_PUSH_SIZE-sized chunk — the application-layer half of the
+ * "one Sync Plan, one remote commit" contract. Deletions force the
+ * commitBatch path (never the pushBatch-only fast path) since pushBatch
+ * has no way to carry a deletion in the same request.
+ */
+ async commitBatch(toPush: PushQueueEntry[], toMove: MoveQueueEntry[], toDelete: DeleteQueueEntry[], results: PushResults): Promise {
const service = this.getGitService();
- if (toMove.length === 0) {
+ if (toMove.length === 0 && toDelete.length === 0) {
if (!service.pushBatch) return this.pushSequentially(toPush, results);
for (let index = 0; index < toPush.length; index += MAX_BATCH_PUSH_SIZE) {
await this.commitPushChunk(toPush.slice(index, index + MAX_BATCH_PUSH_SIZE), results);
@@ -82,14 +90,20 @@ export class PushExecutor {
}
if (!service.commitBatch) {
+ await this.deleteSequentially(toDelete, results);
await this.moveSequentially(toMove, results);
await this.pushSequentially(toPush, results);
return;
}
- const combined: Array<{ kind: 'push'; entry: PushQueueEntry } | { kind: 'move'; entry: MoveQueueEntry }> = [
+ type CombinedItem =
+ | { kind: 'push'; entry: PushQueueEntry }
+ | { kind: 'move'; entry: MoveQueueEntry }
+ | { kind: 'delete'; entry: DeleteQueueEntry };
+ const combined: CombinedItem[] = [
...toPush.map(entry => ({ kind: 'push' as const, entry })),
...toMove.map(entry => ({ kind: 'move' as const, entry })),
+ ...toDelete.map(entry => ({ kind: 'delete' as const, entry })),
];
for (let index = 0; index < combined.length; index += MAX_BATCH_PUSH_SIZE) {
await this.commitCombinedChunk(combined.slice(index, index + MAX_BATCH_PUSH_SIZE), results);
@@ -100,7 +114,7 @@ export class PushExecutor {
for (const entry of entries) {
try {
const sha = await this.push(entry, entry.content, entry.existingSha, entry.existingRevision, true);
- this.recordSuccess(entry.path, sha, results);
+ this.recordSuccess(entry.path, sha, results, !!entry.existingSha);
} catch (error) {
this.recordFailure(entry.path, error, results);
}
@@ -118,9 +132,23 @@ export class PushExecutor {
await service.deleteFile(
entry.oldRepoPath, this.getBranch(), `Remove ${entry.oldRepoPath} (moved to ${entry.repoPath})`,
);
- await this.updateMetadata(entry.path, sha);
+ await this.persistMetadata(entry.path, sha, `Moved ${entry.oldRepoPath} to ${entry.repoPath}`);
this.clearMovedSource(entry.oldPath);
- this.recordSuccess(entry.path, sha, results);
+ this.recordSuccess(entry.path, sha, results, true);
+ } catch (error) {
+ this.recordFailure(entry.path, error, results);
+ }
+ }
+ }
+
+ private async deleteSequentially(entries: DeleteQueueEntry[], results: PushResults): Promise {
+ const service = this.getGitService();
+ for (const entry of entries) {
+ try {
+ await service.deleteFile(entry.repoPath, this.getBranch(), `Delete ${entry.repoPath} from Obsidian`);
+ await this.persistMetadataClear(entry.path, `Deleted ${entry.repoPath} from ${this.getServiceName()}`);
+ results.success += 1;
+ results.syncedPaths.push({ path: entry.path });
} catch (error) {
this.recordFailure(entry.path, error, results);
}
@@ -142,8 +170,8 @@ export class PushExecutor {
const shaByPath = new Map(batchResults.map(result => [result.path, result.sha]));
for (const entry of entries) {
const sha = shaByPath.get(entry.repoPath) ?? await gitBlobSha(entry.content);
- await this.updateMetadata(entry.path, sha);
- this.recordSuccess(entry.path, sha, results);
+ await this.persistMetadata(entry.path, sha, `Pushed ${entry.name} to ${this.getServiceName()}`);
+ this.recordSuccess(entry.path, sha, results, !!entry.existingSha);
}
} catch (error) {
for (const entry of entries) this.recordFailure(entry.path, error, results);
@@ -151,47 +179,94 @@ export class PushExecutor {
}
private async commitCombinedChunk(
- chunk: Array<{ kind: 'push'; entry: PushQueueEntry } | { kind: 'move'; entry: MoveQueueEntry }>,
+ chunk: Array<
+ | { kind: 'push'; entry: PushQueueEntry }
+ | { kind: 'move'; entry: MoveQueueEntry }
+ | { kind: 'delete'; entry: DeleteQueueEntry }
+ >,
results: PushResults,
): Promise {
const pushes = chunk.filter((item): item is { kind: 'push'; entry: PushQueueEntry } => item.kind === 'push').map(item => item.entry);
const moves = chunk.filter((item): item is { kind: 'move'; entry: MoveQueueEntry } => item.kind === 'move').map(item => item.entry);
+ const deletes = chunk.filter((item): item is { kind: 'delete'; entry: DeleteQueueEntry } => item.kind === 'delete').map(item => item.entry);
try {
const batchResults = await this.getGitService().commitBatch!(
- pushes.map(entry => ({ path: entry.repoPath, content: entry.content, existedRemotely: !!entry.existingSha, revision: entry.existingRevision })),
- moves.map(entry => ({ oldPath: entry.oldRepoPath, newPath: entry.repoPath, content: entry.content, oldRevision: entry.oldRevision })),
+ {
+ writes: pushes.map(entry => ({ path: entry.repoPath, content: entry.content, existedRemotely: !!entry.existingSha, revision: entry.existingRevision })),
+ moves: moves.map(entry => ({ oldPath: entry.oldRepoPath, newPath: entry.repoPath, content: entry.content, oldRevision: entry.oldRevision })),
+ deletions: deletes.map(entry => entry.repoPath),
+ },
this.getBranch(),
- this.combinedCommitMessage(pushes.length, moves.length),
+ this.combinedCommitMessage(pushes.length, moves.length, deletes.length),
);
const shaByPath = new Map(batchResults.map(result => [result.path, result.sha]));
- for (const entry of pushes) await this.recordCommittedEntry(entry, shaByPath, results);
+ for (const entry of pushes) await this.recordCommittedEntry(entry, shaByPath, results, !!entry.existingSha);
for (const entry of moves) {
- await this.recordCommittedEntry(entry, shaByPath, results);
+ await this.recordCommittedEntry(entry, shaByPath, results, true);
this.clearMovedSource(entry.oldPath);
}
+ for (const entry of deletes) await this.recordCommittedDeletion(entry, results);
} catch (error) {
for (const item of chunk) this.recordFailure(item.entry.path, error, results);
}
}
+ private async recordCommittedDeletion(entry: DeleteQueueEntry, results: PushResults): Promise {
+ await this.persistMetadataClear(entry.path, `Deleted ${entry.repoPath} from ${this.getServiceName()}`);
+ results.success += 1;
+ results.syncedPaths.push({ path: entry.path });
+ }
+
private async recordCommittedEntry(
entry: PushQueueEntry | MoveQueueEntry,
shaByPath: ReadonlyMap,
results: PushResults,
+ isUpdate: boolean,
): Promise {
const sha = shaByPath.get(entry.repoPath) ?? await gitBlobSha(entry.content);
- await this.updateMetadata(entry.path, sha);
- this.recordSuccess(entry.path, sha, results);
+ await this.persistMetadata(entry.path, sha, `Pushed ${entry.name} to ${this.getServiceName()}`);
+ this.recordSuccess(entry.path, sha, results, isUpdate);
+ }
+
+ /**
+ * The remote mutation (commit/push/delete) already succeeded by the time
+ * this runs; a failure here is local bookkeeping only (the sha cache used
+ * to skip redundant pushes next time), not a sync failure — the file must
+ * still count as synced, or a retry would re-push a file the remote
+ * already has and could produce a spurious conflict.
+ */
+ private async persistMetadata(path: string, sha: string, successContext: string): Promise {
+ try {
+ await this.updateMetadata(path, sha);
+ } catch (error) {
+ this.notify(`${successContext}, but failed to save local sync state: ${error instanceof Error ? error.message : String(error)}`);
+ }
+ }
+
+ private async persistMetadataClear(path: string, successContext: string): Promise {
+ try {
+ await this.clearMetadata(path);
+ } catch (error) {
+ this.notify(`${successContext}, but failed to clear local sync state: ${error instanceof Error ? error.message : String(error)}`);
+ }
}
- private combinedCommitMessage(pushCount: number, moveCount: number): string {
- if (moveCount === 0) return `Push ${pushCount} file(s) from Obsidian`;
- if (pushCount === 0) return `Move ${moveCount} file(s) from Obsidian`;
- return `Push ${pushCount} file(s) and move ${moveCount} file(s) from Obsidian`;
+ private combinedCommitMessage(pushCount: number, moveCount: number, deleteCount: number = 0): string {
+ const parts: string[] = [];
+ if (pushCount > 0) parts.push(`push ${pushCount} file(s)`);
+ if (moveCount > 0) parts.push(`move ${moveCount} file(s)`);
+ if (deleteCount > 0) parts.push(`delete ${deleteCount} file(s)`);
+ const joined = parts.length <= 1
+ ? parts.join('')
+ : `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}`;
+ const message = joined || 'sync';
+ return `${message.charAt(0).toUpperCase()}${message.slice(1)} from Obsidian`;
}
- private recordSuccess(path: string, sha: string, results: PushResults): void {
+ private recordSuccess(path: string, sha: string, results: PushResults, isUpdate: boolean): void {
results.success += 1;
+ if (isUpdate) results.updated += 1;
+ else results.added += 1;
results.syncedPaths.push({ path, sha });
}
diff --git a/src/logic/sync/SyncDiffService.ts b/src/logic/sync/SyncDiffService.ts
index ab362ac..27e1487 100644
--- a/src/logic/sync/SyncDiffService.ts
+++ b/src/logic/sync/SyncDiffService.ts
@@ -1,11 +1,44 @@
import type { SyncStatusService } from '../sync-status-service';
import { isBinaryPath } from '../../utils/path';
import type { FileDiff } from './types';
+import { computeDiffStat } from '../../ui/source-control/ChangePresentation';
+import type { DiffStatLoadResult } from '../../ui/source-control/DiffStatProvider';
export type BlobReader = (sha: string, path: string) => Promise<{ content: string | ArrayBuffer }>;
-/** Builds the only diff DTO exposed across the UI/domain boundary. */
+/** Cache key for an in-flight remote blob fetch: the blob identifies the content, not just the path. */
+function remoteContentKey(remoteSha: string, path: string): string {
+ return `${remoteSha}:${path}`;
+}
+
+/**
+ * Builds the only diff DTO exposed across the UI/domain boundary.
+ *
+ * `getDiff` also defines one-sided diff semantics for the diff pane, so the
+ * UI never has to branch per change kind when rendering sides:
+ * - `local-only` (A): remote side renders as '' — everything in the local
+ * content shows as +N additions in the pane.
+ * - `remote-only` (↓) / `local-deleted` (D): local side renders as '' — the
+ * remote content relands entirely with no phantom deletions, and no
+ * separate blob-download round-trip per consumer.
+ * - two-sided kinds (`modified` / `moved`): both sides as stored.
+ *
+ * NOTE: the FileDiff sides are the PANE's semantics (what you'd see after
+ * the action), not the row stat's direction. Both one-sided remote kinds
+ * produce local=''/remote=content, which a plain LCS count reads as -N; the
+ * row stat instead applies the UX direction (+N for a download, -N for a
+ * local deletion) in `SourceControlItemView.loadDiffStat` via
+ * `addedContentStat`/`deletedContentStat`.
+ *
+ * Concurrent requests for the same remote blob (a background stat loader
+ * racing a user-opened diff) are coalesced onto one `readBlob` call by an
+ * in-flight memoization keyed by `remoteSha:path`; the entry is reaped when
+ * the shared promise settles.
+ */
export class SyncDiffService {
+ /** In-flight remote blob reads, keyed by `remoteSha:path`, so concurrent consumers share one fetch. */
+ private readonly pendingRemoteContent = new Map>();
+
constructor(
private readonly statuses: SyncStatusService,
private readonly readBlob: BlobReader,
@@ -15,18 +48,114 @@ export class SyncDiffService {
const status = this.statuses.get(path);
if (!status) throw new Error(`No sync status for ${path}`);
- if (status.remoteContent === undefined && status.remoteSha) {
- const blob = await this.readBlob(status.remoteSha, status.movedFrom ?? status.path);
- status.remoteContent = blob.content;
- }
-
- let kind: FileDiff['kind'] = isBinaryPath(status.path) ? 'binary' : 'text';
+ const remoteContent = await this.resolveRemoteContent(status);
+ let kind: FileDiff['kind'] = 'text';
if (status.isSymlink) kind = 'symlink';
+ else if (isBinaryPath(status.path)) kind = 'binary';
+
+ // One-sided semantics: whichever side doesn't exist renders empty so
+ // a one-sided stat computes as pure additions (+N) without either
+ // consumer (diff pane or stat loader) special-casing the kind.
+ const localExists = status.status !== 'remote-only' && status.status !== 'local-deleted';
+ const remoteExists = status.status !== 'unsynced';
return {
path: status.path,
- localContent: status.localContent,
- remoteContent: status.remoteContent,
+ localContent: localExists ? status.localContent ?? '' : '',
+ remoteContent: remoteExists ? remoteContent ?? '' : '',
kind,
};
}
-}
+
+ /**
+ * Resolves both sides of one batch-conflict row for the user-opened
+ * diff: the reviewed remote blob and the (already in-memory) local
+ * content. This is the single data path for the batch modal's "View
+ * Diff" — the modal never calls `getBlob` itself, so a background
+ * summary stat and a user-opened diff race onto the SAME in-flight
+ * memoization (`remoteSha:path`) and share one round-trip.
+ *
+ * Binary conflicts (by path or non-string local content) resolve as
+ * `undefined` — the caller shows its binary presentation instead of a
+ * text comparison, and no remote fetch is attempted.
+ */
+ async getConflictDiff(conflict: {
+ path: string;
+ localContent: string | ArrayBuffer;
+ remoteSha: string;
+ repoPath: string;
+ }): Promise<{ localContent: string | ArrayBuffer; remoteContent: string | ArrayBuffer } | undefined> {
+ if (isBinaryPath(conflict.path) || typeof conflict.localContent !== 'string') {
+ return undefined;
+ }
+ const remoteContent = await this.resolveRemoteContent({
+ path: conflict.path,
+ status: 'conflict',
+ remoteSha: conflict.remoteSha,
+ movedFrom: conflict.repoPath,
+ });
+ if (remoteContent === undefined) return undefined;
+ return { localContent: conflict.localContent, remoteContent };
+ }
+
+ /**
+ * Resolves the +/- diff stat for one batch-conflict row — the data side
+ * of the conflict modal's `ConflictDiffStatLoader` (the modal itself
+ * stays presentation-only; SyncDiffService owns remote/local diff data).
+ *
+ * Built on {@link getConflictDiff}: binary files have no line diff and
+ * are terminally `unavailable`; text conflicts fetch the reviewed
+ * remote blob by SHA (sharing the in-flight memoization with `getDiff`
+ * and `getConflictDiff`, so a stat racing a user-opened diff is one
+ * round-trip) and count additions/deletions with the same
+ * `computeDiffStat` the Source Control rows use.
+ */
+ async getConflictStat(conflict: {
+ path: string;
+ localContent: string | ArrayBuffer;
+ remoteSha: string;
+ repoPath: string;
+ }): Promise {
+ if (isBinaryPath(conflict.path) || typeof conflict.localContent !== 'string') {
+ return { status: 'unavailable' };
+ }
+ const diff = await this.getConflictDiff(conflict);
+ if (!diff || typeof diff.remoteContent !== 'string' || typeof diff.localContent !== 'string') {
+ return { status: 'unavailable' };
+ }
+ return { status: 'ready', stat: computeDiffStat(diff.remoteContent, diff.localContent) };
+ }
+
+ /** Fetches the remote blob once per `remoteSha:path`, coalescing concurrent consumers. */
+ private async resolveRemoteContent(status: {
+ path: string;
+ status: string;
+ remoteContent?: string | ArrayBuffer;
+ remoteSha?: string;
+ movedFrom?: string;
+ }): Promise {
+ if (status.remoteContent !== undefined) return status.remoteContent;
+ if (!status.remoteSha) {
+ // A one-sided local row has no remote content to fetch — its
+ // diff side resolves to empty rather than staying undefined,
+ // which would otherwise force consumers into `unavailable`.
+ return undefined;
+ }
+ const key = remoteContentKey(status.remoteSha, status.movedFrom ?? status.path);
+ let pending = this.pendingRemoteContent.get(key);
+ if (!pending) {
+ pending = this.readBlob(status.remoteSha, status.movedFrom ?? status.path).then(blob => blob.content);
+ this.pendingRemoteContent.set(key, pending);
+ // Fire-and-forget reap once the shared promise settles: the next
+ // consumer after that starts a fresh read (this is in-flight
+ // deduplication, not a long-lived content cache). The catch keeps
+ // the reap chain from rejecting with nothing attached.
+ void pending.catch(() => {}).then(() => this.pendingRemoteContent.delete(key));
+ }
+ const content = await pending;
+ // Still write through to the status so the diff the user opened and
+ // the stat both carry the fetched content forward (same contract as
+ // the previous eager-write implementation).
+ if (status.remoteContent === undefined) status.remoteContent = content;
+ return content;
+ }
+}
\ No newline at end of file
diff --git a/src/logic/sync/SyncInteractionPort.ts b/src/logic/sync/SyncInteractionPort.ts
index 07acaaf..1baeaec 100644
--- a/src/logic/sync/SyncInteractionPort.ts
+++ b/src/logic/sync/SyncInteractionPort.ts
@@ -1,9 +1,35 @@
-import type { GitServiceInterface } from '../../services/git-service-interface';
+import type { DiffStatLoadResult } from '../../ui/source-control/DiffStatProvider';
import type { BatchPushConflict, SyncPlan } from './types';
-export type SyncPlanDirection = 'push' | 'pull' | 'delete';
+export type SyncPlanDirection = 'push' | 'pull' | 'delete' | 'sync';
export type SingleConflictChoice = 'local' | 'remote';
+/**
+ * Resolves the +/- diff stat for one batch-conflict row. Consumers should
+ * treat this as progressive: cheap sources (already-in-memory content)
+ * resolve immediately, remote-backed sources may go out to the provider.
+ * Returning `pending` lets the caller's cache retry later, `unavailable`
+ * gives up permanently (binary files, fetch failures that are terminal).
+ */
+export type ConflictDiffStatLoader = (conflict: {
+ path: string;
+ localContent: string | ArrayBuffer;
+ remoteSha: string;
+ repoPath: string;
+}) => Promise;
+
+/**
+ * Loads both sides of one conflict for the batch modal's "View Diff",
+ * with remote content served (fetched/cached/deduped) by the shared diff
+ * service. `undefined` means the row has no viewable text diff (binary).
+ */
+export type ConflictDiffLoader = (conflict: {
+ path: string;
+ localContent: string | ArrayBuffer;
+ remoteSha: string;
+ repoPath: string;
+}) => Promise<{ localContent: string | ArrayBuffer; remoteContent: string | ArrayBuffer } | undefined>;
+
/** User interaction required by sync workflows, supplied by the composition layer. */
export interface SyncInteractionPort {
confirmPlan(plan: SyncPlan, direction: SyncPlanDirection): Promise;
@@ -14,10 +40,20 @@ export interface SyncInteractionPort {
onChoose: (choice: SingleConflictChoice) => void,
): void;
resolveBatchConflicts(
- gitService: GitServiceInterface,
conflicts: BatchPushConflict[],
- totalFiles: number,
safeCount: number,
+ /**
+ * Optional lazy data source for a row's "View Diff". Omit to make
+ * the detailed comparison unavailable (radios still work).
+ */
+ loadConflictDiff?: ConflictDiffLoader,
+ /**
+ * Optional progressive +/- diff-stat source for the batch conflict
+ * modal's rows. Omit to render rows without stats. Must not block
+ * modal opening — the modal renders immediately and stats land
+ * asynchronously.
+ */
+ diffStatLoader?: ConflictDiffStatLoader,
): Promise;
notify(message: string, duration?: number): void;
}
diff --git a/src/logic/sync/SyncManager.ts b/src/logic/sync/SyncManager.ts
index d0a6d44..f4c5ad9 100644
--- a/src/logic/sync/SyncManager.ts
+++ b/src/logic/sync/SyncManager.ts
@@ -3,6 +3,8 @@ import { GitServiceInterface, GitTreeEntry } from '../../services/git-service-in
import { GitLabFilesPushSettings, getServiceName } from '../../settings';
import {
type PushResults,
+ type PullExecutionOptions,
+ type SyncResult,
SyncPlan,
SyncPlanEntry,
isSyncPlanEmpty,
@@ -22,6 +24,8 @@ import { PushCoordinator } from './PushCoordinator';
import { SyncPlanner } from './SyncPlanner';
import {
HeadlessSyncInteraction,
+ type ConflictDiffLoader,
+ type ConflictDiffStatLoader,
type SyncInteractionPort,
type SyncPlanDirection,
} from './SyncInteractionPort';
@@ -39,6 +43,10 @@ export class SyncManager {
private readonly pushCoordinator: PushCoordinator;
private readonly planner = new SyncPlanner();
private readonly interaction: SyncInteractionPort;
+ /** Optional progressive +/- diff-stat source handed to the batch conflict modal. */
+ private diffStatLoader?: ConflictDiffStatLoader;
+ /** Optional lazy diff data source for the batch conflict modal's "View Diff". */
+ private conflictDiffLoader?: ConflictDiffLoader;
readonly status: SyncStatusService;
constructor(
@@ -67,6 +75,7 @@ export class SyncManager {
() => this.serviceName,
message => this.interaction.notify(message),
oldPath => { delete this.settings.syncMetadata[oldPath]; },
+ path => this.clearMetadata(path),
);
const pullExecutor = new PullExecutor(
this.app,
@@ -102,8 +111,8 @@ export class SyncManager {
conflicts: conflictResolver,
isPathIgnored: path => this.isPathIgnored(path),
confirmPlan: plan => this.confirmPlan(plan, 'push'),
- resolveConflicts: (conflicts, totalFiles, safeCount) => (
- this.interaction.resolveBatchConflicts(this.gitService, conflicts, totalFiles, safeCount)
+ resolveConflicts: (conflicts, safeCount) => (
+ this.interaction.resolveBatchConflicts(conflicts, safeCount, this.conflictDiffLoader, this.diffStatLoader)
),
updateMetadata: (path, sha) => this.updateMetadata(path, sha),
migrateBaseline: (path, repoPath, entry) => this.migrateGitLabLegacyBaseline(path, repoPath, entry),
@@ -150,6 +159,16 @@ export class SyncManager {
this.gitService = gitService;
}
+ /** Wires the composition layer's progressive diff-stat source into the batch conflict modal. */
+ setConflictDiffStatLoader(loader: ConflictDiffStatLoader | undefined): void {
+ this.diffStatLoader = loader;
+ }
+
+ /** Wires the shared diff-service-backed loader for the batch modal's "View Diff". */
+ setConflictDiffLoader(loader: ConflictDiffLoader | undefined): void {
+ this.conflictDiffLoader = loader;
+ }
+
/** A plan with exactly one entry, for a single-file push/pull's confirm step. */
private singleEntryPlan(kind: 'addition' | 'modification', path: string, name: string): SyncPlan {
const plan: SyncPlan = { additions: [], modifications: [], deletions: [], moves: [] };
@@ -164,7 +183,8 @@ export class SyncManager {
* already in sync or skipped as a conflict) resolves immediately without
* showing anything — there is nothing to review.
*/
- private confirmPlan(plan: SyncPlan, direction: SyncPlanDirection): Promise {
+ /** Public per docs/source-control-refactor: a unified Sync Plan orchestrator confirms the whole merged plan through this one call rather than through push/pull's own internal confirm. */
+ confirmPlan(plan: SyncPlan, direction: SyncPlanDirection): Promise {
if (isSyncPlanEmpty(plan)) return Promise.resolve(true);
return this.interaction.confirmPlan(plan, direction);
}
@@ -247,6 +267,21 @@ export class SyncManager {
await this.executor.pull.pull(file, remoteContent, remoteSha, silent, symlinkTarget);
}
+ /**
+ * Applies the reviewed remote version of a conflicted path directly: no
+ * planner re-run (which would re-open a conflict modal), no fallback to
+ * latest remote HEAD — the exact reviewed blob is fetched by SHA.
+ */
+ public async acceptRemoteConflict(path: string): Promise {
+ const { path: vaultPath, name } = this.getFileInfo(path);
+ const repoPath = this.getNormalizedPath(vaultPath);
+ const remoteSha = this.status.get(vaultPath)?.remoteSha;
+ if (!remoteSha) throw new Error('Cannot accept remote version because the reviewed remote revision is unavailable.');
+ const blob = await this.gitService.getBlob(remoteSha, repoPath);
+ const fileRep = this.app.vault.getFileByPath(vaultPath) ?? { path: vaultPath, name };
+ await this.performPull(fileRep, blob.content, blob.sha, true, blob.isSymlink ? blob.symlinkTarget ?? '' : undefined);
+ }
+
private async saveSettings() {
if (this.onSaveSettings) {
await this.onSaveSettings();
@@ -271,7 +306,7 @@ export class SyncManager {
files: (TFile | string)[],
onProgress?: (current: number, total: number, fileName: string) => void,
remoteTree?: GitTreeEntry[]
- ): Promise<{ success: number; failed: number; conflicts: number; errors: Array<{ file: string; error: string }> }> {
+ ): Promise {
return this.pullCoordinator.pullAllFiles(files, onProgress, remoteTree);
}
@@ -280,6 +315,30 @@ export class SyncManager {
return this.pullCoordinator.planPullBatch(files, remoteTree);
}
+ /** Applies an already-confirmed pull batch without showing its own confirm modal. */
+ async applyPullBatch(
+ files: (TFile | string)[],
+ onProgress?: (current: number, total: number, fileName: string) => void,
+ remoteTree?: GitTreeEntry[],
+ options?: PullExecutionOptions,
+ ): Promise {
+ return this.pullCoordinator.applyPullBatch(files, onProgress, remoteTree, options);
+ }
+
+ /** Classifies and conflict-resolves a push batch without confirming or committing, for a unified Sync Plan orchestrator. */
+ planSyncBatch(
+ files: (TFile | string)[],
+ onProgress?: (current: number, total: number, fileName: string) => void,
+ remoteTree?: GitTreeEntry[],
+ ): ReturnType {
+ return this.pushCoordinator.planSyncBatch(files, onProgress, remoteTree);
+ }
+
+ /** Commits already-planned pushes/moves/deletions as one provider mutation set. */
+ commitResolvedBatch(...args: Parameters): ReturnType {
+ return this.pushCoordinator.commitResolvedBatch(...args);
+ }
+
/** Migrates a legacy GitLab last_commit_id baseline only when the current
* file endpoint proves it still describes this tree blob. */
private async migrateGitLabLegacyBaseline(path: string, repoPath: string, entry: GitTreeEntry | undefined): Promise {
diff --git a/src/logic/sync/SyncScanner.ts b/src/logic/sync/SyncScanner.ts
index 60133c7..0ef73fc 100644
--- a/src/logic/sync/SyncScanner.ts
+++ b/src/logic/sync/SyncScanner.ts
@@ -2,6 +2,7 @@ import { TFile, type App } from 'obsidian';
import type { GitLabFilesPushSettings } from '../../settings';
import { logger } from '../../utils/logger';
import { isBinaryPath } from '../../utils/path';
+import { getNormalizedVaultPath } from './vault-folder-scope';
export interface ScannedFileInfo {
path: string;
@@ -24,10 +25,7 @@ export class SyncScanner {
}
toRepoPath(path: string): string {
- if (!this.settings.vaultFolder) return path;
- const folderPath = `${this.settings.vaultFolder}/`;
- if (path.startsWith(folderPath)) return path.substring(folderPath.length);
- return path === this.settings.vaultFolder ? '' : path;
+ return getNormalizedVaultPath(path, this.settings.vaultFolder);
}
toTreePath(repoPath: string): string {
diff --git a/src/logic/sync/SyncStatusRefreshService.ts b/src/logic/sync/SyncStatusRefreshService.ts
index 59c4a2b..e6b5236 100644
--- a/src/logic/sync/SyncStatusRefreshService.ts
+++ b/src/logic/sync/SyncStatusRefreshService.ts
@@ -50,6 +50,9 @@ interface DiscoveredFiles {
export class SyncStatusRefreshService {
private static readonly STATUS_CHECK_CONCURRENCY = 8;
+ /** Per-path monotonic revision ordering async content writes (create read vs a raced modify). */
+ private readonly contentRevisions = new Map();
+
constructor(
private readonly dependencies: SyncStatusRefreshDependencies,
private readonly statuses: SyncStatusService,
@@ -177,15 +180,54 @@ export class SyncStatusRefreshService {
if (localFile) extra.push(localFile);
else if (await this.isLocalFile(vaultPath)) extra.push(vaultPath);
else {
+ // No local file at all. A tracked file that's since been
+ // removed locally (sync metadata still present for the path,
+ // and not a pending move source) is a *local deletion* — a
+ // potential remote deletion — distinct from a never-tracked
+ // remote-only file, which is simply available to download.
this.statuses.set(vaultPath, {
path: vaultPath,
- status: this.statuses.classify({ localExists: false, remoteExists: true }),
+ status: this.statuses.classify({
+ localExists: false,
+ remoteExists: true,
+ wasTracked: this.wasTrackedBeforeDelete(vaultPath),
+ }),
});
}
}
return extra;
}
+ /**
+ * Whether `vaultPath` was previously tracked locally and has since been
+ * removed (sync metadata present for the path, and not a pending move
+ * source). Used to distinguish a `local-deleted` row from a
+ * never-tracked `remote-only` download candidate.
+ */
+ private wasTrackedBeforeDelete(vaultPath: string): boolean {
+ const metadata = this.dependencies.settings().syncMetadata;
+ const pathMetadata = metadata ? metadata[vaultPath] : undefined;
+ return isSyncMetadataAtPath(pathMetadata, vaultPath) && !pathMetadata.renamedFrom;
+ }
+
+ /** The last-synced blob sha on record for `path`, or undefined if never tracked there. */
+ private baseShaFor(path: string): string | undefined {
+ const metadata = this.dependencies.settings().syncMetadata;
+ const pathMetadata = metadata ? metadata[path] : undefined;
+ return isSyncMetadataAtPath(pathMetadata, path) ? pathMetadata.lastSyncedSha : undefined;
+ }
+
+ /**
+ * Direction facts for a two-sided diff, relative to the last-synced
+ * baseline: undefined for both when there is no baseline on record (the
+ * two-sided diff then falls back to the direction-blind `modified`).
+ */
+ private diffDirection(path: string, localSha: string, remoteSha: string): { localChanged?: boolean; remoteChanged?: boolean } {
+ const baseSha = this.baseShaFor(path);
+ if (baseSha === undefined) return {};
+ return { localChanged: localSha !== baseSha, remoteChanged: remoteSha !== baseSha };
+ }
+
async reconcileOutOfBandMoves(remoteMap: Map): Promise {
const orphansBySha = this.orphanedMoveSourcesBySha(remoteMap);
if (orphansBySha.size === 0) return;
@@ -229,26 +271,95 @@ export class SyncStatusRefreshService {
await Promise.all(Array.from({ length: workerCount }, () => worker()));
}
+ /**
+ * Handles an out-of-band local create so a brand-new file appears in the
+ * Source Control view immediately rather than waiting for the next full
+ * refresh. The file is published in two resilient steps:
+ *
+ * 1. The `unsynced` (local-only) row is published *immediately* without
+ * content, so the row is visible even if the subsequent read fails
+ * (its stat stays pending — never cached — until content lands).
+ * 2. The file content is read asynchronously; on success the row is
+ * republished with `localContent` so its `+N` stat can compute, on
+ * failure only a warning is logged (a later modify/full refresh
+ * retries the read).
+ *
+ * A per-path async revision guards the republish against racing
+ * create → modify/delete/rename events: the read's result is applied
+ * only if the path still exists in the map under the same file object
+ * (a delete/rename re-keys or removes it) and is still the newest
+ * pending read for that path, so an old read cannot clobber a newer
+ * one's content.
+ *
+ * No-op when the path is already tracked (a `modify`/`rename` event will
+ * have handled it) or falls outside the configured vault folder. Returns
+ * whether the status map changed, so a caller can skip a republish.
+ */
+ async handleFileCreated(file: TFile): Promise {
+ if (this.statuses.has(file.path) || !this.dependencies.filterPathByVaultFolder(file.path)) return false;
+ const revision = this.bumpContentRevision(file.path);
+ this.statuses.set(file.path, {
+ file,
+ path: file.path,
+ status: this.statuses.classify({ localExists: true, remoteExists: false }),
+ });
+ void this.readFileContent(file, isBinaryPath(file.path), false).then(localContent => {
+ const current = this.statuses.get(file.path);
+ if (!current
+ || current.file !== file
+ || this.contentRevisions.get(file.path) !== revision) return;
+ this.statuses.set(file.path, { ...current, localContent });
+ }).catch(error => {
+ logger.warn(`Failed to read created file ${file.path}; its row stays pending until the next refresh`, error);
+ });
+ return true;
+ }
+
+ /** Monotonic per-path counter ordering async content reads so only the newest one may write. */
+ private bumpContentRevision(path: string): number {
+ const next = (this.contentRevisions.get(path) ?? 0) + 1;
+ this.contentRevisions.set(path, next);
+ return next;
+ }
+
async handleFileModified(file: TFile): Promise {
const existing = this.statuses.get(file.path);
if (!existing || !['synced', 'modified', 'unsynced', 'moved'].includes(existing.status)) return false;
+ const revision = this.bumpContentRevision(file.path);
const localContent = await this.readFileContent(file, isBinaryPath(file.path), false);
- let status: FileStatus['status'] = existing.status;
- if (existing.status !== 'moved') {
- status = existing.remoteSha === undefined
- ? this.statuses.classify({ localExists: true, remoteExists: false })
- : this.statuses.classify({
+ // A create's slow async read may still be in flight behind this
+ // modify; only the newest read may write.
+ if (this.contentRevisions.get(file.path) !== revision) return true;
+ // Re-read AFTER the await: a full refresh may have completed while
+ // the read was pending and replaced the row's remoteSha/
+ // remoteContent/isSymlink/movedFrom. Classifying from the stale
+ // pre-await snapshot would write that old state back over the fresh
+ // refresh result; the row may even no longer exist (deleted/renamed
+ // away while pending) — in both cases the snapshot must be abandoned.
+ const current = this.statuses.get(file.path);
+ if (!current || current.file !== file) return true;
+ let status: FileStatus['status'] = current.status;
+ if (current.status !== 'moved') {
+ const remoteSha = current.remoteSha;
+ if (remoteSha === undefined) {
+ status = this.statuses.classify({ localExists: true, remoteExists: false });
+ } else {
+ const localSha = await gitBlobSha(localContent);
+ status = this.statuses.classify({
localExists: true,
remoteExists: true,
- contentsEqual: await gitBlobSha(localContent) === existing.remoteSha,
+ contentsEqual: localSha === remoteSha,
+ ...this.diffDirection(file.path, localSha, remoteSha),
});
+ }
}
- this.statuses.set(file.path, { ...existing, status, localContent });
+ this.statuses.set(file.path, { ...current, status, localContent });
return true;
}
handleFileRenamed(file: TFile, oldPath: string): boolean {
const existing = this.statuses.get(oldPath);
+ this.contentRevisions.delete(oldPath);
if (!existing || existing.status === 'checking') return false;
this.statuses.delete(oldPath);
if (!this.dependencies.filterPathByVaultFolder(file.path)) return true;
@@ -270,6 +381,43 @@ export class SyncStatusRefreshService {
return true;
}
+ /**
+ * Handles an out-of-band local delete of a previously known file so the
+ * Source Control view reflects it immediately rather than waiting for the
+ * next full refresh.
+ *
+ * - A *tracked* file removed locally (`synced`/`modified`) is marked
+ * `local-deleted`: the remote still holds it, so this is a potential
+ * remote deletion, distinct from a never-tracked `remote-only` file.
+ * - A *local-only* (`unsynced`) file simply drops out of the status map —
+ * nothing on the remote to delete or restore, so there's no change to
+ * surface.
+ * - A tracked *move* (`moved`) abandons its move: the row is dropped and a
+ * later refresh reconciles the old remote path (still on the remote,
+ * metadata relocated by `trackRename`) as `remote-only`/`local-deleted`.
+ * - A `remote-only`/`local-deleted`/`checking` row is left untouched
+ * (nothing local existed to delete, or it's still resolving).
+ *
+ * Returns whether the status map changed, so a caller can skip a
+ * republish when nothing moved.
+ */
+ handleFileDeleted(path: string): boolean {
+ const existing = this.statuses.get(path);
+ this.contentRevisions.delete(path);
+ if (!existing || existing.status === 'checking' || existing.status === 'remote-only' || existing.status === 'local-deleted') return false;
+ if (existing.status === 'moved') {
+ this.statuses.delete(path);
+ return true;
+ }
+ if (existing.status === 'unsynced') {
+ this.statuses.delete(path);
+ return true;
+ }
+ // synced / modified: tracked, remote still holds this path -> local-deleted.
+ this.statuses.set(path, { ...existing, status: 'local-deleted', localContent: undefined });
+ return true;
+ }
+
async refreshFileStatus(
fileOrPath: TFile | string,
remoteEntry: GitTreeEntry | undefined,
@@ -305,10 +453,13 @@ export class SyncStatusRefreshService {
const binary = isBinaryPath(path);
const symlinkMode = getEffectiveSymlinkHandling(this.dependencies.settings());
const localContent = await this.readLocalContentForSha(fileOrPath, isStringPath, binary, remoteEntry.symlink, symlinkMode);
+ const localSha = await gitBlobSha(localContent);
+ const remoteSha = remoteEntry.sha;
const status = this.statuses.classify({
localExists: true,
remoteExists: true,
- contentsEqual: await gitBlobSha(localContent) === remoteEntry.sha,
+ contentsEqual: localSha === remoteSha,
+ ...(remoteSha !== undefined ? this.diffDirection(path, localSha, remoteSha) : {}),
});
if (status === 'synced' && remoteEntry.sha) {
await this.dependencies.syncManager().updateMetadata(path, remoteEntry.sha);
@@ -332,13 +483,18 @@ export class SyncStatusRefreshService {
this.dependencies.getNormalizedPath(path),
this.dependencies.settings().branch,
);
- const status = remote.sha
- ? this.statuses.classify({
+ let status: FileStatus['status'];
+ if (!remote.sha) {
+ status = this.statuses.classify({ localExists: true, remoteExists: false });
+ } else {
+ const equal = contentsEqual(localContent, remote.content);
+ status = this.statuses.classify({
localExists: true,
remoteExists: true,
- contentsEqual: contentsEqual(localContent, remote.content),
- })
- : this.statuses.classify({ localExists: true, remoteExists: false });
+ contentsEqual: equal,
+ ...(equal ? {} : this.diffDirection(path, await gitBlobSha(localContent), remote.sha)),
+ });
+ }
if (status === 'synced' && remote.sha) {
await this.dependencies.syncManager().updateMetadata(path, remote.sha);
}
@@ -377,7 +533,11 @@ export class SyncStatusRefreshService {
const metadata = this.dependencies.settings().syncMetadata ?? {};
const orphansBySha = new Map();
for (const [path, status] of this.statuses) {
- if (status.status !== 'remote-only') continue;
+ // A tracked-then-deleted file is now classified `local-deleted`
+ // (not `remote-only`), so both qualify as an orphaned move
+ // source: the remote entry still exists, sync metadata is
+ // present for the path, and it isn't itself a pending move.
+ if (status.status !== 'remote-only' && status.status !== 'local-deleted') continue;
const pathMetadata = metadata[path];
if (!isSyncMetadataAtPath(pathMetadata, path) || pathMetadata.renamedFrom) continue;
const entry = remoteMap.get(path);
diff --git a/src/logic/sync/SyncWorkspace.ts b/src/logic/sync/SyncWorkspace.ts
index c653717..c1c0107 100644
--- a/src/logic/sync/SyncWorkspace.ts
+++ b/src/logic/sync/SyncWorkspace.ts
@@ -3,16 +3,18 @@ import type { GitServiceInterface, GitTreeEntry } from '../../services/git-servi
import { getServiceName, type GitLabFilesPushSettings } from '../../settings';
import type { GitignoreManager } from '../gitignore-manager';
import type { FileStatus, SyncStatusService } from '../sync-status-service';
+import type { PlannedPushBatch } from './PushCoordinator';
import { RemoteDeleteExecutor, type RemoteDeleteResult } from './RemoteDeleteExecutor';
import { SyncDiffService } from './SyncDiffService';
import type { SyncManager } from './SyncManager';
+import type { SyncPlanDirection } from './SyncInteractionPort';
import {
SyncStatusRefreshService,
type SyncStatusRefreshDependencies,
SyncStatusRefreshProgress,
SyncStatusRefreshResult,
} from './SyncStatusRefreshService';
-import type { FileDiff, PushResults, SyncResult } from './types';
+import type { BatchPushConflict, DeleteQueueEntry, FileDiff, MoveQueueEntry, PullExecutionOptions, PushQueueEntry, PushResults, SyncPlan, SyncResult } from './types';
import { ensureParentDirs } from '../../utils/vault-path';
import { buildRemoteFileUrl } from '../../utils/remote-url';
@@ -33,12 +35,33 @@ export interface SyncWorkspace {
push(paths: readonly string[], onProgress?: SyncProgress): Promise;
pull(paths: readonly string[], onProgress?: SyncProgress): Promise;
pullOne(path: string): Promise;
+ /** Applies the reviewed remote version of a conflicted path without re-running the planner (no second conflict modal). */
+ acceptRemoteConflict(path: string): Promise;
deleteRemote(paths: readonly string[], onProgress?: RemoteDeleteProgress): Promise;
deleteLocal(path: string): Promise;
moveLocal(path: string, target: string): Promise;
clearMetadata(path: string): Promise;
trackRename(newPath: string, oldPath: string): Promise;
getDiff(path: string): Promise;
+ /** Repo-relative path a provider mutation needs for a given vault path. */
+ toRepoPath(path: string): string;
+ /** Classifies and conflict-resolves a push batch without confirming or committing — for a unified Sync Plan. */
+ planPush(paths: readonly string[]): Promise;
+ /** Computes what a pull batch would do, without writing anything — for a unified Sync Plan. */
+ planPull(paths: readonly string[]): Promise;
+ /** Applies an already-confirmed pull batch without showing its own confirm modal. */
+ applyPull(paths: readonly string[], options?: PullExecutionOptions): Promise;
+ /** Commits already-planned pushes/moves/deletions as one provider mutation set. */
+ commitResolvedBatch(
+ pushes: PushQueueEntry[],
+ moves: MoveQueueEntry[],
+ deletions: DeleteQueueEntry[],
+ keepRemote: BatchPushConflict[],
+ keepLocal: BatchPushConflict[],
+ results: PushResults,
+ ): Promise;
+ /** Shows one review/confirm modal for a merged Sync Plan. */
+ confirmPlan(plan: SyncPlan, direction: SyncPlanDirection): Promise;
}
export interface SyncWorkspaceRuntimeDependencies {
@@ -102,15 +125,29 @@ export class SyncManagerWorkspace implements SyncWorkspace {
await this.dependencies.manager().pullFile(path);
}
+ acceptRemoteConflict(path: string): Promise {
+ return this.dependencies.manager().acceptRemoteConflict(path);
+ }
+
async deleteRemote(paths: readonly string[], onProgress?: RemoteDeleteProgress): Promise {
const executor = new RemoteDeleteExecutor(
this.dependencies.gitService(),
this.dependencies.settings().branch,
);
- return executor.execute(
+ const result = await executor.execute(
paths.map(path => ({ path, repoPath: this.dependencies.normalizePath(path) })),
(current, target) => onProgress?.(current, target.path),
);
+ // Both sides are now gone for these paths: drop tracked metadata so a
+ // future remote file at the same path isn't mistaken for a rename
+ // source / misclassified as `local-deleted`, and drop the row from
+ // the live status map instead of leaving a stale `local-deleted`
+ // entry until the next full refresh.
+ for (const path of result.deletedPaths) {
+ await this.clearMetadata(path);
+ this.dependencies.manager().status.delete(path);
+ }
+ return result;
}
async deleteLocal(path: string): Promise {
@@ -142,6 +179,40 @@ export class SyncManagerWorkspace implements SyncWorkspace {
return this.dependencies.diffService.getDiff(path);
}
+ toRepoPath(path: string): string {
+ return this.dependencies.normalizePath(path);
+ }
+
+ async planPush(paths: readonly string[]): Promise {
+ const remoteTree = await this.reusableRemoteTree();
+ return this.dependencies.manager().planSyncBatch([...paths], undefined, remoteTree);
+ }
+
+ async planPull(paths: readonly string[]): Promise {
+ const remoteTree = await this.reusableRemoteTree();
+ return this.dependencies.manager().planPullBatch([...paths], remoteTree);
+ }
+
+ async applyPull(paths: readonly string[], options?: PullExecutionOptions): Promise {
+ const remoteTree = await this.reusableRemoteTree();
+ return this.dependencies.manager().applyPullBatch([...paths], undefined, remoteTree, options);
+ }
+
+ commitResolvedBatch(
+ pushes: PushQueueEntry[],
+ moves: MoveQueueEntry[],
+ deletions: DeleteQueueEntry[],
+ keepRemote: BatchPushConflict[],
+ keepLocal: BatchPushConflict[],
+ results: PushResults,
+ ): Promise {
+ return this.dependencies.manager().commitResolvedBatch(pushes, moves, deletions, keepRemote, keepLocal, results);
+ }
+
+ confirmPlan(plan: SyncPlan, direction: SyncPlanDirection): Promise {
+ return this.dependencies.manager().confirmPlan(plan, direction);
+ }
+
private async reusableRemoteTree(): Promise {
const snapshot = this.remoteTreeSnapshot;
const settings = this.dependencies.settings();
@@ -175,12 +246,28 @@ export class BoundarySyncWorkspace implements SyncWorkspace {
push(paths: readonly string[]): Promise { return this.getManager().pushFiles([...paths]); }
pull(paths: readonly string[]): Promise { return this.getManager().pullAllFiles([...paths]); }
pullOne(path: string): Promise { return this.getManager().pullFile(path); }
+ acceptRemoteConflict(path: string): Promise { return this.getManager().acceptRemoteConflict(path); }
deleteRemote(paths: readonly string[]): Promise { return this.boundaries.deleteRemote(paths); }
async deleteLocal(path: string): Promise { await this.getManager().clearMetadata(path); }
moveLocal(path: string, target: string): Promise { return this.getManager().trackRename(target, path); }
clearMetadata(path: string): Promise { return this.getManager().clearMetadata(path); }
trackRename(newPath: string, oldPath: string): Promise { return this.getManager().trackRename(newPath, oldPath); }
getDiff(path: string): Promise { return this.boundaries.getDiff(path); }
+ toRepoPath(path: string): string { return path; }
+ planPush(paths: readonly string[]): Promise { return this.getManager().planSyncBatch([...paths]); }
+ planPull(paths: readonly string[]): Promise { return this.getManager().planPullBatch([...paths]); }
+ applyPull(paths: readonly string[], options?: PullExecutionOptions): Promise { return this.getManager().applyPullBatch([...paths], undefined, undefined, options); }
+ commitResolvedBatch(
+ pushes: PushQueueEntry[],
+ moves: MoveQueueEntry[],
+ deletions: DeleteQueueEntry[],
+ keepRemote: BatchPushConflict[],
+ keepLocal: BatchPushConflict[],
+ results: PushResults,
+ ): Promise {
+ return this.getManager().commitResolvedBatch(pushes, moves, deletions, keepRemote, keepLocal, results);
+ }
+ confirmPlan(plan: SyncPlan, direction: SyncPlanDirection): Promise