diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa4d015..f677335 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,14 +25,33 @@ on: # Weekly API-drift check: same real-provider suites, no code change required to trigger them. - cron: '0 6 * * 1' +# ───────────────────────────────────────────────────────────────────────────── +# CI DAG overview +# +# changes ──► provider-e2e ──┐ +# │ +# lint ──────────────────────┤ +# unit-test ─────────────────┤──► required-checks ──► package +# build ─────────────────────┤ └────► publish (main only) +# +# All four validation jobs (lint, unit-test, build, provider-e2e) start in +# parallel right after the push; only `provider-e2e` waits on `changes` for its +# 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. +# ───────────────────────────────────────────────────────────────────────────── + 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 }} @@ -57,17 +76,49 @@ jobs: - 'package-lock.json' - '.github/workflows/ci.yml' - # Fast local gate: run cheap deterministic checks in parallel before any - # real-provider E2E spends remote API time. The release-critical reusable CI - # still runs after E2E below; this is only an early failure gate. - preflight: - name: Preflight / ${{ matrix.check }} + # ── Fast checks (parallel, no E2E dependency) ────────────────────────────── + + 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: true - max-parallel: 3 + fail-fast: false matrix: - check: [lint, test, build] + 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 @@ -75,15 +126,39 @@ jobs: node-version: '22' cache: npm - run: npm ci --ignore-scripts - - name: Run ${{ matrix.check }} - run: bash scripts/run-preflight.sh "${{ matrix.check }}" + - 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 ──────────────────────────────────── # Real-provider E2E: one matrix job covering GitHub, GitLab, and Gitea (see - # docs/testing/real-provider-e2e.md). It starts only after the fast local - # preflight passes, then provider legs run in parallel. + # 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: E2E / ${{ matrix.provider }} - needs: [changes, preflight] + 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 @@ -179,7 +254,8 @@ jobs: if [ "${{ matrix.provider }}" = "gitea" ]; then run=false # Make the disabled state explicit in the run log + summary so a - # green "E2E / gitea" job is never mistaken for "Gitea E2E passed". + # green "CI / Provider E2E / gitea" job is never mistaken for + # "Gitea E2E passed". echo "::notice::Gitea E2E is disabled in CI (runner Docker networking — see TODO below). Suite/harness code passes locally; re-enable by removing this block." { echo "### Gitea E2E: disabled" @@ -258,78 +334,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, 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.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 \ No newline at end of file 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/docs/testing/real-provider-e2e.md b/docs/testing/real-provider-e2e.md index 4e7ff78..fa78b76 100644 --- a/docs/testing/real-provider-e2e.md +++ b/docs/testing/real-provider-e2e.md @@ -122,9 +122,10 @@ sandbox and starved each other (observed as real GitLab API timeouts under that 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 `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 @@ -221,15 +222,26 @@ 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/**`, +`.github/workflows/ci.yml` runs four 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 / ` — the real-provider matrix below + +`CI / Provider E2E / ` is the matrix job (`github`, `gitlab`, `gitea`): provision, seed, +the real vitest run, independent verify, cleanup (`if: always()` so cleanup runs even if an +earlier step failed). Only this job depends on the `changes` job's path gate (`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. +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`. 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. Two more workflows round out the isolation model's other cleanup layers — see "Isolation model" above for what each does and why: @@ -262,20 +274,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 ──► provider-e2e [github | gitlab | gitea, parallel] ──┐ +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 four 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. GitHub/GitLab (`CI / Provider E2E / github`, `CI / Provider E2E / gitlab`) are +deliberately **not** required at the branch-protection level, and Gitea is currently disabled in +CI (see the "Determine whether this provider leg should run" step), so a fork PR (which runs no +real-provider leg) is never wedged by checks it structurally cannot produce. ## Cleanup / troubleshooting @@ -304,10 +325,11 @@ structurally cannot produce. 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. + actual execution on that fleet, and the parallel-validation DAG → `required-checks` → + `publish` 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 (`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. diff --git a/e2e/suites/source-control-flows.e2e.test.ts b/e2e/suites/source-control-flows.e2e.test.ts index ded8171..ec50d22 100644 --- a/e2e/suites/source-control-flows.e2e.test.ts +++ b/e2e/suites/source-control-flows.e2e.test.ts @@ -548,6 +548,55 @@ describe('Source Control Flows E2E', () => { }); }); + // ------------------------------------------------------------------ + // 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'); + }); + }); + // ------------------------------------------------------------------ // Phase 7 — Remote divergence + idempotency // ------------------------------------------------------------------ diff --git a/progress.md b/progress.md index bb8a526..46680d4 100644 --- a/progress.md +++ b/progress.md @@ -4,19 +4,37 @@ 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. +**Last Updated:** 2026-08-23 +**Active Feature:** `feat/sync-status-auto-refresh` (uncommitted, stacked on `feat/sync-status-workflow-ui`): auto-refresh the Source Control view on local vault create/modify/delete without a manual refresh, and distinguish a user-deleted tracked file from a never-tracked remote-only download candidate. Domain + view work. (1) New `local-deleted` `SyncStatus`/`SyncChangeKind`: a previously-tracked file removed locally classifies `local-deleted` (badge `D`, "Deleted locally", red border) instead of collapsing into `remote-only` — `SyncStatusFacts.wasTracked` drives `SyncStatusService.classify`; `FileStatusAdapter`/`SourceControlFilter`/`SourceControlSummary`/`ChangePresentation` (badge `D` + tooltip + `changeOperation: download`) updated; `identifyExtraFiles` + `orphanedMoveSourcesBySha` treat `local-deleted` like `remote-only` for move reconciliation. (2) New `RefreshReason` type threaded through `RefreshState.start(reason)` → `SourceControlViewModel.refresh(reason)` (default `'manual'`; startup uses `'startup'`); `RefreshState` now records `reason` + `lastCheckedAt`. (3) New immediate optimistic handlers `handleFileCreated` (→`unsynced`) and `handleFileDeleted` (synced/modified→`local-deleted`, unsynced/moved→drop), wired to `app.vault.on('create'/'delete')` in `main.ts` alongside the existing `modify`/`rename` handlers — all update the shared `SyncStatusService` which republishes → `ChangeRepository` → ViewModel → UI (no direct filesystem→UI path; no `syncMetadata` cleared on delete so out-of-band move reconciliation still works). (4) "Last checked: just now /