diff --git a/.specify/feature.json b/.specify/feature.json
index 153a932ce8..0c4259625a 100644
--- a/.specify/feature.json
+++ b/.specify/feature.json
@@ -1,3 +1,3 @@
{
- "feature_directory": "docs/specs/002-proxy-media"
+ "feature_directory": "docs/specs/005-project-git-versioning"
}
diff --git a/CLAUDE.md b/CLAUDE.md
index 65d221d73c..0d04df99f4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -26,5 +26,6 @@ For large, parallelizable work that splits into independent units — dead-code/
- **001 — Agent Editing Toolkit** (ACTIVE): plan at [`docs/specs/001-agent-editing-toolkit/plan.md`](docs/specs/001-agent-editing-toolkit/plan.md) (spec/research/data-model/contracts in the same dir). An **MCP server + Skills + Subagents** so external AI agents author/edit Beutl projects **headlessly** (no live-GUI automation). **Declarative-first**: the agent reads an identity-anchored JSON document and submits a desired end-state — a full document or a **JSON Merge Patch (RFC 7396)** — which the toolkit reconciles by **`CoreObject.Id` diff** into Beutl's **undoable `HistoryManager` operations** via the `CoreObjectOperationObserver` recording pipeline (**never raw `PopulateFromJsonObject`/`SetValue` on the live root** — that mints new children, destroying `Id` identity and bypassing undo), behind a **plan/apply** dry-run. Schema/capability discovery comes from `PropertyRegistry` **plus `EngineObject.Properties`** (the modern per-object `IProperty` surface holds most editable params; `PropertyRegistry` alone misses them); `$type` via `JsonHelper.WriteDiscriminator` (`TypeFormat` is internal). Two new **MIT `net10.0`** projects — `Beutl.AgentToolkit` (core lib) + `Beutl.AgentToolkit.Mcp` (stdio console exe, SDK `ModelContextProtocol` 1.4.0, logs→STDERR). Headless still render via `SceneRenderer`/`Renderer.Snapshot` (SKSL runs on CPU; GLSL/3D run on the Vulkan backend — hardware, MoltenVK, or the bundled SwiftShader software fallback — so shader/3D effects are always available, just slower on the software path); **video export in v1** reaches the GPL worker **only via `Beutl.FFmpegIpc`** (no `Beutl.FFmpegWorker` `ProjectReference`). Writes confined to a configured **workspace root** (read anywhere); audio is first-class. **Real-time UI reflection** is delivered by **in-app hosting**: the running editor hosts a loopback HTTP/SSE MCP endpoint (`ModelContextProtocol.AspNetCore`) bound to the active `EditViewModel`'s live `Scene`+`HistoryManager` (**one writer; no new IPC** — Beutl has none into the running app), so agent edits update the preview/timeline/undo stack live; the headless stdio exe handles the no-GUI case via a file-opened session (the `Sessions/` *source* seam swaps file↔live). **Do NOT** add a live-GUI-*automation* (simulated-input) path, a JSON-patch dependency, a universal byte-identity rule, or a GPL `ProjectReference`; in-app live *observation* of the shared model IS in scope and is **not** GUI automation. When building this toolkit, consult that plan + contracts.
- **002 — Proxy Media Workflow** (ACTIVE): plan at [`docs/specs/002-proxy-media/plan.md`](docs/specs/002-proxy-media/plan.md). Spec, research, data-model, contracts, and quickstart live in the same directory.
+- **005 — Git Version Control for Editing Projects** (ACTIVE): plan at [`docs/specs/005-project-git-versioning/plan.md`](docs/specs/005-project-git-versioning/plan.md) (spec/research/data-model/contracts/quickstart in the same dir). In-app Git versioning uses the **user's installed `git` CLI only** (≥ 2.23; **no LibGit2Sharp**, no bundled git; graceful degradation when absent). Snapshots fire on **explicit Save/Save All/close only** (never per autosave tick or timer), plus manual commits; automatic messages are stable English with a `Beutl-Snapshot:` trailer. The Avalonia-free public `IProjectVersionControlService` in `src/Beutl.Editor/VersionControl/` is **read/query only**; `VersionControlCoordinator` owns the internal Active→Retiring→Retired backend, all user-level version-control mutations, and one exclusive **durably preserve → close → operation-specific transaction → reopen** cycle. Consented stale-lock removal remains the sole narrow mutation exposed separately through `IRepositoryLockRecoveryService`. `IProjectVersionControlInitializer.InitializeCurrentProjectAsync(Project expectedProject, ...)` requires the exact currently open project as a concurrency guard, forwards the exact operation token to the identity callback, and rejects initialization if another project becomes current; the identity flyout observes that token and closes itself on the UI thread when cancellation is requested. This is a breaking migration with no parameterless shim, so use `feat!:`/`refactor!:` plus a `BREAKING CHANGE:` footer naming the callback change. Dirty restore/switch makes a Safety commit; dirty pull writes `refs/beutl/safety/*` without moving the branch, builds the merged tree and Safety commit off-ref, then applies that exact state. Restore is **restore-as-new-commit**; a post-commit failure appends a compensating Recovery commit. Tree transitions hold the worktree-private `HEAD.lock`, validate scoped worktree/index fingerprints, and compare-and-swap the same attached branch from its exact expected tip as the final durable step; external ref movement yields `OwnershipLost`, unverified recovery yields `RecoveryFailed`, and neither may be overwritten or reopened automatically. `RepositoryDirty` is reserved for actual cleanliness-precondition failures; the coordinator renders either unsafe transition state as exactly the localized uncertain-transition failure without composing inner result text. Enclosing repositories are detected and project-file mutations are **pathspec-scoped to the project directory**; branch/push/pull, whole-repository cleanliness checks, and guarded branch CAS retain disclosed repository-wide semantics. Four serialization prerequisites land first: `{Id:N}.belm` naming (`ElementFileNaming`), appVersion migration semantics (`feat!:`), Include/Exclude `/` normalization, and `JsonHelper` `NewLine="\n"` pinning. **Do NOT** expose merge (beyond fast-forward pull), rebase, destructive reset, force-push, other history rewriting, detached HEAD on the user's project worktree, partial staging, timer checkpoints, or LibGit2Sharp; never parse human-facing Git output (porcelain v2 `-z` only), and always set `GIT_TERMINAL_PROMPT=0` + `GIT_OPTIONAL_LOCKS=0` + `GIT_LITERAL_PATHSPECS=1`, except the validated NUL-delimited `check-ignore --stdin -z` probe sets literal pathspecs to `0` so ignore patterns are evaluated. When building this feature, consult that plan and contracts.
- **003 — Resolution-Independent Rendering Pipeline** (delivered; guardrails still apply): plan at [`docs/specs/003-resolution-independent-pipeline/plan.md`](docs/specs/003-resolution-independent-pipeline/plan.md) (spec + research + data-model + contracts in the same dir). **Supply-driven** scale model (logical properties; output scale `RenderNodeContext.OutputScale` = final target only; per-op `EffectiveScale`, vector = `Unbounded`; computed working scale `w` via `ResolveWorkingScale` — **no per-effect policy** (the `ResolutionPolicy` type was removed; an effect needing a non-supply `w` overrides `Process` in a custom `FilterEffectRenderNode` from `FilterEffect.Resource.CreateRenderNode()`); root surface `ceil(FrameSize × s_out)`); at `s_out = 1.0` the golden content set (vector / text / Skia-filter / unscaled bitmap) stays byte-identical, but byte-identity is **no longer a universal guarantee** — a scaled bitmap feeding an effect renders at its coherent supply density (FR-019; the universal constraint was abolished in `32634977c`). **Do NOT revert to top-down single-scale or output-capped intermediates** — `s_out` never clamps an intermediate (FR-016/FR-036). **Do NOT reintroduce a universal byte-identity-at-`s_out=1` rule, a `ResolutionPolicy` enum, or a `PreserveSource` policy** (all removed); the only **global** working-scale bound is `MaxWorkingScale` (FR-037; preview `2×s_out`, export `+∞` — no quality ceiling; per-buffer allocatability is bounded by `ClampWorkingScaleToBufferBudget`) — additionally the per-buffer dimension clamp (FR-037(b), `ClampWorkingScaleToBufferBudget`, 16384 px per axis) may further reduce `w` at effect boundaries to keep buffers allocatable (two distinct bounds, do not conflate them). Breaking public surface (`refactor!`/`feat!` + `BREAKING CHANGE:`). When touching `Beutl.Engine` graphics rendering / filter effects, consult that plan and the contracts.
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 33ab5e991f..7c928c42a2 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -64,6 +64,7 @@
+
diff --git a/README.md b/README.md
index a0ce5867b4..65ee5af608 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,9 @@ Create a Beutl account to acquire extensions or publish your own developed exten
### 🧰 Rich Effects
From basic effects like color filters, blurs, shadows, and LUTs, to minor effects like outlines, inner shadows, and long shadows, Beutl offers a wide range of effects. These can be further expanded through extensions.
+### 🕘 Version Control
+Track a project's editing history with Git, restore earlier versions, create experimental branches, and back up work to a remote repository from within Beutl.
+
## 📥 Installation
Refer to the [documentation](https://docs.beutl.beditor.net/get-started/install) here.
diff --git a/docs/specs/005-project-git-versioning/checklists/requirements.md b/docs/specs/005-project-git-versioning/checklists/requirements.md
new file mode 100644
index 0000000000..2030147d5d
--- /dev/null
+++ b/docs/specs/005-project-git-versioning/checklists/requirements.md
@@ -0,0 +1,35 @@
+# Specification Quality Checklist: Git Version Control for Editing Projects
+
+**Purpose**: Validate specification completeness and quality before proceeding to planning
+**Created**: 2026-07-28
+**Feature**: [spec.md](../spec.md)
+
+## Content Quality
+
+- [x] No implementation details (languages, frameworks, APIs)
+- [x] Focused on user value and business needs
+- [x] Written for non-technical stakeholders
+- [x] All mandatory sections completed
+
+## Requirement Completeness
+
+- [x] No [NEEDS CLARIFICATION] markers remain
+- [x] Requirements are testable and unambiguous
+- [x] Success criteria are measurable
+- [x] Success criteria are technology-agnostic (no implementation details)
+- [x] All acceptance scenarios are defined
+- [x] Edge cases are identified
+- [x] Scope is clearly bounded
+- [x] Dependencies and assumptions identified
+
+## Feature Readiness
+
+- [x] All functional requirements have clear acceptance criteria
+- [x] User scenarios cover primary flows
+- [x] Feature meets measurable outcomes defined in Success Criteria
+- [x] No implementation details leak into specification
+
+## Notes
+
+- "Git" appears throughout as a product-level domain concept (the user-approved scope is Git-based versioning with remotes), not as an implementation choice; engine selection (CLI vs library) is deliberately absent and deferred to plan/research.
+- Four assumptions are marked *(to be confirmed in clarification)* — creation-default, timer checkpoints, Save As history, LFS default. They carry informed defaults, so no [NEEDS CLARIFICATION] markers were needed; `/speckit-clarify` will confirm or adjust them.
diff --git a/docs/specs/005-project-git-versioning/contracts/coordinator-lifecycle.md b/docs/specs/005-project-git-versioning/contracts/coordinator-lifecycle.md
new file mode 100644
index 0000000000..f7052b11a4
--- /dev/null
+++ b/docs/specs/005-project-git-versioning/contracts/coordinator-lifecycle.md
@@ -0,0 +1,81 @@
+# Contract: VersionControlCoordinator lifecycle & UI orchestration
+
+**Scope**: `src/Beutl/Services/VersionControlCoordinator.cs` — the app-level owner of per-project services and the only component allowed to run the close→operate→reopen cycle.
+
+## Ownership
+
+- Constructed once in `MainViewModel` next to `ProjectService`.
+- Subscribes `ProjectService.ProjectObservable`: on project open → resolve project root from `Project.Uri`, run repo discovery (`git rev-parse --show-toplevel`), construct `GitCliVersionControlService` + `RepositoryWatcher`; on close → retire both after any in-flight activation completes.
+- Ordinary close captures the current activation revision and project root, waits for that activation to finish, then retires the final owned backend for the same activation lineage exactly once with the `Close` snapshot intent. A project change while waiting aborts that handoff, so an old project's close snapshot can never reach a newly opened project's backend. The snapshot intent is passed even while an owned backend is transitioning from untracked to tracked; backend retirement rechecks `Repository` after the current exclusive initialization finishes and no-ops only when it is still genuinely untracked.
+- Maintains separate owned and visible service state. A temporary close keeps ownership for recovery but publishes `null` to editor consumers; reopen republishes the same service only when the project root still matches.
+- Publishes `(service, IsTracked, IsGitAvailable)` snapshots through one revisioned FIFO on the UI thread. Stale discovery completions and older queued publications cannot overwrite a newer project state. Within each revision, availability and tracked flags are written before the service, so every service-publication subscriber observes the matching flags; individual reactive callbacks are not an atomic multi-property transaction.
+
+## Commit trigger wiring (FR-012/013/014/015)
+
+| Trigger | Hook point | Kind |
+|---|---|---|
+| Explicit Save / Save All | end of `MenuBarViewModel.OnSave` / `OnSaveAll`, still holding the project-file write reservation → `NotifySavedAsync(completedWrite)` | `Save` |
+| Project close | start of the close flow, after final save, before `ProjectService.CloseProject()` | `Close` |
+| Before restore / branch switch | inside the cycle, when status is dirty | `Safety` |
+| Dirty pull | durable private checkpoint before pull; promoted after fast-forward | `Safety` |
+| After restore | inside the cycle | `Restore` |
+| Restore recovery after a post-commit failure | inside the recovery path | `Recovery` |
+| Manual commit | tool tab / command palette, after saving the open project inside the exclusive lease so the version records what the user sees | `Manual` |
+
+Autosave ticks never reach the coordinator (FR-015). All triggers no-op silently on a clean tree.
+
+## The close→operate→reopen cycle (FR-022)
+
+```text
+1. Read-only backend preflight while the project stays open
+ └─ return immediately when pull is already up to date or cannot proceed
+2. Release the backend gate, then show the operation confirmation
+3. Acquire ProjectService's transition gate and the work-tree lease
+4. Reacquire the backend gate and revalidate status, branch tip, upstream, and operation need
+5. If dirty:
+ - restore / branch switch: CommitAllAsync(safety message, Safety)
+ - pull: create a durable refs/beutl/safety/* checkpoint without moving the branch
+6. ProjectService.CloseProject()
+7. Git operation inside the mutation-phase `ExecuteExclusiveAsync` transaction
+ └─ on failure: recover the operation-specific original state, then surface the error
+ (original branch/work tree for switch; compensating Recovery commit for restore;
+ exact branch-tip CAS plus checkpoint restore for pull)
+8. For restore: apply the selected tree and append a Restore commit atomically
+ For dirty pull: apply the checkpoint tree and append a Safety commit atomically
+9. ProjectService.OpenProject(bepPath)
+```
+
+The two backend phases never invert the normal-close lock order. Read-only preflight releases the backend gate before requesting the project transition; the mutation phase always holds the project transition before reacquiring the backend gate. Confirmation dialogs hold neither gate. A concurrent normal close can therefore retire the backend and complete without deadlocking against pull or pending-recovery confirmation.
+
+Pull recovery captures the checked-out local branch ref and commit before closing. Immediately before the first guarded tree/ref transition, a second durable descriptor ref under `refs/beutl/recovery//` records the checkpoint ref, exact branch/base/target commits, project file, and creation time. It only rolls that same ref back when its current commit still equals the operation's expected commit; a concurrent external branch movement is never overwritten. `RepositoryDirty` is reserved for a real whole-repository cleanliness precondition failure, such as an unrelated dirty path outside an enclosing-project pathspec. `OwnershipLost` and `RecoveryFailed` remain internal transition states; at the coordinator boundary either becomes exactly `RemoteOpResult.Failed(Strings.VersionControl_PullTransitionUncertain)`, without composing potentially misleading inner remote-result text. Checkpoint and descriptor ref publication are re-observed after a lost `update-ref` response, so a ref that Git durably created is still returned to the coordinator instead of becoming an unreachable orphan. The descriptor and private checkpoint are deleted together by one compare-and-swap ref transaction only after successful reopen or a fully verified recovery, and both are retained when completion cannot be proved.
+
+Repository activation enumerates pending descriptors even when the Version Control tab has never been opened. A continuously present descriptor ID is offered at most once during the active service session; IDs that complete or disappear are removed from the deduplication set, while an explicit Recent Projects open may offer the same durable descriptor again. The offer is canceled when its project/service generation is replaced or normally closed, so stale prompts never overlap a later activation. Enumeration and confirmation hold no backend gate. Direct pull and manual-recovery confirmation capture the project/service epoch before preflight or lookup and use that same cancellation token through confirmation, so close, branch transition, and backend replacement cancel a stale prompt without holding a lifecycle, project, or backend gate. If accepted, the normal recovery cycle reacquires the project transition first, then the backend gate, re-enumerates the exact ID and descriptor object, verifies the open project path again, closes, rolls an already-applied target back to its exact base when necessary, restores the saved checkpoint, reopens, and atomically completes both refs only after successful project publication. Explicit opens use a per-attempt preparation: descriptor discovery and confirmation run before `ProjectService` acquires its transition, then the immutable ticket is applied inside that transition only after acquiring the work-tree lease, rediscovering the same repository, and matching the exact descriptor object and project path. Enclosing-repository consent obtained by that preflight is carried only by the same open-attempt and transition IDs, recorded only after recovery revalidation or mutation succeeds, and consumed once by the matching published project path. Activation rediscovers the exact repository before honoring that decision, so a superseding attempt, changed repository, or failed recovery cannot reuse stale consent or cause a second prompt for the matching open. An already-applied ticket also captures the exact live opening-marker object; apply skips recovery only while that same marker instance still names the same repository and recovery. Missing or replaced markers abort rather than falling back to another recovery, required-ID misses never clear an unrelated marker, and ordinary stale-marker cleanup uses reference-identity compare-and-remove. A superseded attempt, changed descriptor, unavailable preparation, busy work tree, or accepted recovery failure aborts before the current project is closed. A physically escaping project alias vetoes open only when it belongs to a matching pending recovery or an internal version-control reopen; unrelated explicit project symlink opens preserve their prior behavior. `ProjectRecoveryResult` reports the exact disposition, and only its two success cases remove the non-overlay recovery banner. Declined, unavailable, changed, verified-preserved failure, or uncertain failure results keep the action visible, including while conflict guidance is visible.
+
+After rollback/checkpoint restoration, the coordinator re-reads the attached branch tip immediately before reopening and requires the exact captured original tip. This check is the recovery cycle's ownership linearization point: a mismatch leaves the project closed and the checkpoint reachable. A later external Git write is a new operation outside Beutl's transaction and is observed through the repository watcher; Beutl never rewrites that external result.
+
+If a restore commit succeeds but reopening fails, recovery restores the captured pre-operation tree and records a `Recovery` commit on top. The attempted restore remains in history and the original project state becomes the visible tip again without rewriting history.
+
+Push runs outside the cycle (no work-tree mutation): progress dialog + cancel only.
+
+Guard: standard render exports and project-package exports acquire a shared output lease before reading or writing project files. Restore and branch-switch acquire the corresponding exclusive work-tree lease before confirmation and hold it through close, mutation, recovery, and reopen. Pull and pending-recovery confirmation first release the backend gate, then acquire the exclusive work-tree lease with the project transition before their revalidation/mutation phase. Explicit saves acquire a project-file write reservation before writing and hand that same reservation to the save snapshot, so the workspace is never unreserved between the write and the commit. Automatic save and close snapshots also hold the exclusive lease through staging and commit: an already-active output skips only the snapshot (save/close continues and a later save captures the accumulated changes), while an in-progress snapshot refuses a new output. Either side fails immediately when the other is active, so Git never stages a partially written output.
+
+Application-window shutdown uses the same asynchronous close contract. The first window-closing event is canceled, one shared pipeline awaits the close snapshot, project close, and proxy drain under a single 15-second deadline, and then issues one final `Close()`. Repeated closing events join the same pipeline; timeout or failure is logged before the final close proceeds, and any cleanup that finishes after the deadline remains observed.
+
+## Enablement flows (FR-001/FR-002/FR-003)
+
+- **Create dialog**: `CreateNewProjectViewModel` requires `IProjectVersionControlInitializer` and the identity callback; there is no degraded constructor that silently omits version control. The initializer exposes availability and project initialization without coupling the dialog to the app coordinator. `InitializeCurrentProjectAsync` accepts `Func>` and forwards its exact operation token to the identity prompt, so cancellation is not lost at the UI callback boundary. The identity flyout registers that token, cancels its pending result, and closes itself on the UI thread rather than waiting for user dismissal. "Track history with Git" remains false and hidden until `GetAvailabilityAsync` reports `Installed`; only then is the configured default applied and shown. Creation snapshots that visible checked state before writing the project, so a detection completion during creation can never opt the user in silently. A checked visible option calls `InitializeCurrentProjectAsync` after creation.
+- **Existing project**: "Enable Version Control…" button in the version control tab, which raises the shell `EnableVersionControl` context command (also reachable from the command palette, gated on `ProjectService.IsOpened`) and awaits it through `ContextCommandExecution.Completion` so the tab can show the operation running.
+- **Command lifecycle**: `MenuBarViewModel` depends on `IProjectVersionControlSession` only for read-only availability/tracking state and save notification. Project close remains the responsibility of the existing `ProjectService`; external hosts can substitute the version-control session without duplicating the general project-lifecycle surface.
+- **Nested repo detected**: consent dialog with "use enclosing repository" (pathspec scoping, project-local `.gitignore`) / "leave unmanaged". Never `git init` inside a foreign work tree.
+- **Save As**: never copies `.git`; the copy is offered fresh enablement per the creation default (clarification #3).
+
+## UI surface map
+
+| Surface | Location | Content |
+|---|---|---|
+| Tool tab | `src/Beutl.Editor.Components/VersionControlTab/` + `VersionControlTabExtension` (`[PrimitiveImpl]`, registered in `LoadPrimitiveExtensionTask`) | branch + ahead/behind + dirty summary; commit box; paged history list (kind badges); changed files; unified diff view (monospace, +/- coloring, 1 MB cap) |
+| Commands | `MenuBarViewModel.Files.cs` + `MainViewExtension` context commands + command palette (no menu-bar entries: the tool tab is the only menu-level surface) | Enable Version Control…, Commit… |
+| Settings | `VersionControlConfig` page | per data-model.md table |
+| Degradation | tool tab + the version control commands collapse to one informational state | per-OS install guidance (FR-037) |
+
+All new XAML declares `x:CompileBindings="True"` + `x:DataType` (constitution IV). All user-facing strings go through `Beutl.Language` resources; repository content stays English (R-5).
diff --git a/docs/specs/005-project-git-versioning/contracts/git-cli-invocation.md b/docs/specs/005-project-git-versioning/contracts/git-cli-invocation.md
new file mode 100644
index 0000000000..7fe6235a6c
--- /dev/null
+++ b/docs/specs/005-project-git-versioning/contracts/git-cli-invocation.md
@@ -0,0 +1,53 @@
+# Contract: Git CLI invocation (`GitCliRunner`)
+
+**Scope**: the single choke point through which every git child process is spawned. No other type starts a git process.
+
+## Process rules
+
+1. **No shell.** `ProcessStartInfo` with an argument list; never string-concatenated command lines.
+2. **Working directory** = `RepositoryInfo.RepoRoot` (repo discovery itself runs from the project directory).
+3. **Executable** = the path resolved by `GitInstallationLocator` (R-3), re-validated on config change.
+
+## Environment (every invocation)
+
+| Variable | Value | Why |
+|---|---|---|
+| `GIT_TERMINAL_PROMPT` | `0` | Never hang a GUI process on a credential/passphrase prompt; fail fast into the guidance dialog |
+| `GIT_OPTIONAL_LOCKS` | `0` | `git status` must not write `.git/index` — breaks the watcher feedback loop (R-8) |
+| `GIT_LITERAL_PATHSPECS` | `1` | Treat every generated project path as data, even when a directory name begins with Git pathspec magic such as `:(top)` |
+| `LC_ALL` | `C` | Stable, locale-independent parseable output |
+| `GIT_SSH_COMMAND` / `GIT_SSH` / `GIT_SSH_VARIANT` | Preserve inherited selection; otherwise set `GIT_SSH_COMMAND=ssh -oBatchMode=yes` for the default SSH transport | Network ops only; OpenSSH fails fast instead of prompting without replacing a user-selected SSH command or variant |
+
+The runner must **not** set `GIT_CONFIG_GLOBAL`/`GIT_CONFIG_NOSYSTEM` in production (the user's config is the credential story); tests set them for isolation (R-14).
+
+The sole `GIT_LITERAL_PATHSPECS` exception is the ignored-collision probe: `git check-ignore --stdin -z` receives already validated, NUL-delimited repository-relative paths on standard input and runs with the variable set to `0` so Git can apply ignore patterns. All command-line path arguments retain literal mode.
+
+For network operations, the runner preserves inherited `GIT_SSH_COMMAND`, `GIT_SSH`, and `GIT_SSH_VARIANT` values. If none is present, it queries the effective repository/global `core.sshCommand` and `ssh.variant`. Only absent command and variant settings select the default OpenSSH transport and add `-oBatchMode=yes`; configured commands, explicit variants, and indeterminate configuration results are left untouched. Standard input is redirected and closed immediately after process start, so neither Git nor an SSH child can wait for input from the GUI process.
+
+## Output rules
+
+- Machine-readable formats only, NUL-separated where supported:
+ - status: `git status --porcelain=v2 --branch -z`
+ - history: `git log --format=%H%x00%h%x00%an%x00%aI%x00%s%x00%(trailers:key=Beutl-Snapshot,valueonly)%x00 -z --skip= -n -- `
+ - commit files: `git show --name-status --format= -z -- `
+ - refs: `git for-each-ref --format=...` / `git rev-parse`
+- Human-facing output is never parsed. `stderr` is captured and preserved on `GitOperationException` for error dialogs after credentials embedded in URLs are redacted.
+- stdout/stderr are read concurrently with process execution (no deadlock on full pipes). Diff stdout is capped at 1 MiB while the pipe is read: excess bytes are discarded while the pipe continues to drain, the retained prefix ends on a complete UTF-8 sequence, and the service appends one truncation marker.
+
+## Lifecycle
+
+- `WaitForExitAsync` with the caller's `CancellationToken`; cancellation kills the process tree.
+- Timeouts: local operations 30 s (a wedged local git indicates a broken repo → surface, don't spin); network operations unbounded but cancelable with progress (`--progress` on push, parsed from stderr).
+- Exit code ≠ 0 ⇒ typed failure. The runner never retries; retry policy is the caller's.
+
+## Guarded tree-transition ref updates
+
+A close/reopen tree transition resolves the original worktree's private `HEAD` and `index` through `git rev-parse --git-path`, acquires its `HEAD.lock`, and verifies the exact `ref: refs/heads/...` contents before mutating files. It validates the expected attached tip and scoped worktree/index fingerprints, then applies the target through Git's branch-mode checkout collision gate. The checkout runs from the temporary detached context with `GIT_WORK_TREE` pointing to the original repository worktree and `GIT_INDEX_FILE` pointing to that worktree's private index: `git -c core.hooksPath=/dev/null checkout --detach --no-overwrite-ignore `. This moves only the temporary HEAD while Git refuses late tracked, untracked, and ignored collisions in the original worktree. Hooks are disabled only for this internal forward/reverse checkout so a `post-checkout` hook cannot mutate or reverse the protected transaction outcome; ordinary user-facing Git commands retain the user's hooks. `git update-ref ` remains the final durable step.
+
+Git refuses to update a branch checked out in a worktree while that same worktree's `HEAD.lock` is held. Before acquiring the lock, Beutl therefore creates a uniquely named temporary worktree at the captured current tree with `git worktree add --detach --no-checkout`. That context owns the protected checkout's temporary HEAD and the final expected-old `update-ref`; the user's project HEAD never becomes detached. Creation failure is pre-mutation; removal is best-effort after the transition and cannot reverse a durable success. The same in-process exclusive transaction prevents two Beutl transitions from creating competing writers.
+
+Checkout failure can occur after Git has updated the selected worktree/index but before it updates the temporary HEAD. Recovery therefore observes all three independently. If the exact target tree/index is present, a temporary HEAD still at the captured current tree is aligned to the target with `git update-ref --no-deref HEAD ` before the protected reverse checkout; an already-target temporary HEAD proceeds directly, and any other value yields `OwnershipLost`. A response failure after that temporary-HEAD CAS is resolved by observing the ref. Unknown or partially written worktree content is never overwritten; only an index fingerprint proven to belong to Beutl may be restored before returning the uncertain outcome.
+
+## Stale lock recovery (edge case: interrupted repository mutation)
+
+On repository-lock failures (`index.lock`, the worktree-private `HEAD.lock`, or `another git process seems to be running`), resolve lock paths through the repository's Git directories. If no live Git child of this Beutl process exists and a lock file's mtime is older than 10 minutes, offer one-click removal of that specific stale lock (explicit user consent, logged); otherwise surface guidance. Never auto-delete silently.
diff --git a/docs/specs/005-project-git-versioning/contracts/version-control-service.md b/docs/specs/005-project-git-versioning/contracts/version-control-service.md
new file mode 100644
index 0000000000..88afa78ef5
--- /dev/null
+++ b/docs/specs/005-project-git-versioning/contracts/version-control-service.md
@@ -0,0 +1,63 @@
+# Contract: IProjectVersionControlService
+
+**Scope**: the read/query seam consumed by the tool tab and exposed to plugin authors through `IEditorContext.GetService`. Lives in `Beutl.Editor.VersionControl` (Avalonia-free). `VersionControlCoordinator` owns one internal backend per open project and is the public surface for user-level version-control mutations; the separate narrow `IRepositoryLockRecoveryService` capability remains responsible only for consented stale-lock removal.
+
+```csharp
+public interface IProjectVersionControlService
+{
+ RepositoryInfo? Repository { get; }
+
+ Task GetAvailabilityAsync(CancellationToken ct);
+ Task GetStatusAsync(CancellationToken ct);
+ Task> GetHistoryAsync(int skip, int take, CancellationToken ct);
+ Task> GetCommitFilesAsync(string sha, CancellationToken ct);
+ Task GetDiffAsync(string sha, string? path, CancellationToken ct);
+ Task> GetBranchesAsync(CancellationToken ct);
+ Task> GetRemotesAsync(CancellationToken ct);
+ Task GetIdentityAsync(CancellationToken ct);
+
+ event EventHandler? StatusChanged;
+}
+```
+
+The public initialization seam uses the same cancellation contract:
+
+```csharp
+public interface IProjectVersionControlInitializer
+{
+ Task GetAvailabilityAsync(CancellationToken cancellationToken);
+
+ Task InitializeCurrentProjectAsync(
+ Func> requestIdentityAsync,
+ CancellationToken cancellationToken);
+}
+```
+
+The coordinator passes the exact `InitializeCurrentProjectAsync` operation token to `requestIdentityAsync`. The previous parameterless callback is not retained as an overload or compatibility shim.
+
+Mutation is split into two internal surfaces:
+
+- `IProjectVersionControlBackend` owns discovery, initialization, snapshots, remote and identity updates, retirement, and `ExecuteExclusiveAsync`.
+- `IProjectVersionControlTransaction` is available only inside `ExecuteExclusiveAsync`. It owns branch/tree transitions, pull checkpoints, guarded branch-tip rollback, and checkpoint deletion. Callers cannot retain it or interleave another mutation halfway through a lifecycle cycle.
+
+The public `IProjectVersionControlCoordinator` exposes user-level mutations such as commit, restore, branch operations, identity/remote changes, push, and pull. It also exposes pending-pull recovery enumeration/action plus a change signal using the intentionally opaque `ProjectRecoveryInfo` (`Id`, display file name, creation time); backend refs, commits, and mutation primitives remain internal. Recovery returns `ProjectRecoveryResult`: `RestoredOriginal` and `ReappliedCheckpoint(recoveryBranchName)` are successful dispositions, while `Declined`, `NotFoundOrChanged`, `Unavailable`, `FailedPreserved(recoveryReference)`, and `FailedUncertain` keep the pending action visible. `FailedPreserved` is returned only after the named Git reference is re-observed at the expected checkpoint commit; a generic close, recovery, reopen, completion, or reference-verification failure returns `FailedUncertain` and makes no durability claim. It couples those mutations to dialogs, output leases, project close/reopen, and recovery instead of exposing backend primitives to plugins. The narrower `IProjectVersionControlSession` supplies menu state and save integration without forcing non-editor consumers to implement the full mutation surface; general project close remains on `ProjectService`.
+
+## Behavioral guarantees
+
+1. **Serialization, lock order, and lifetime**: backend work serializes on one internal gate. Coordinator operations may use a short read-only preflight phase, release the backend gate for confirmation, then acquire the project transition before reacquiring the backend gate for the complete close/mutate/recover/reopen phase. Normal close uses the same project-transition→backend order, so no path waits for the project transition while holding the backend gate. Retirement changes the backend from active to retiring, waits for the exclusive owner, optionally records the final snapshot, then enters a terminal retired state; no queued mutation can start afterward.
+2. **Pathspec scoping**: ordinary project-content commands (`add`, `status`, `log`, `show`, and scoped index restore) append `-- {Repository.Pathspec}`. In the nested-repository case no file outside the project directory is staged or restored by project operations. `Branch*`/`Push`/`Pull` and branch-tip compare-and-swap act on the whole repository (disclosed by the UI). Checkpointed pull requires unrelated repository state to be clean and returns `RepositoryDirty` only when that cleanliness precondition fails. `OwnershipLost` and `RecoveryFailed` are transition states, not dirty-repository diagnoses. Standalone checkpoint/restore transitions remain pathspec-scoped and preserve unrelated outside staging.
+3. **`CommitAllAsync`**: checks status first; returns `NoChanges` without creating a commit when clean (FR-014). Automatic kinds with unset identity return `SkippedNoIdentity`; the coordinator resolves identity before a manual commit. Staging uses `git add -A -- ` and non-`Manual` kinds write the `Beutl-Snapshot` trailer. Each commit has a unique reflog action and enables the branch reflog for that command, including the first commit on an unborn branch. If Git reports failure after recording that action, the operation returns the observed durable commit without restoring the index. With no matching action, the exact pre-snapshot index is restored only when the branch tip is unchanged; an observation failure or unexpected tip movement leaves the index untouched and surfaces an uncertain result together with the original failure. Once `git commit` succeeds, post-commit revision lookup and status publication are best-effort and cannot reverse the durable result; a failed revision lookup returns `Committed(CommitRevision.Unavailable)` so callers do not retry the commit. `CommitRevision.Known` always contains a non-empty SHA; nullable or empty-string sentinels are forbidden.
+4. **Project checkpoints and restart recovery**: `CreateProjectCheckpointAsync` uses a temporary index to write the complete project pathspec to a commit referenced by `refs/beutl/safety//`; it does not move the checked-out branch or alter the user's index/worktree. Before a dirty pull's first guarded transition, the backend writes a strict JSON blob and publishes `refs/beutl/recovery//` to that blob. The descriptor records its version/ID, exact checkpoint ref+commit, attached branch ref, base+target commit, project-relative `.bep` path, and timestamp. Enumeration treats every descriptor as untrusted: object IDs must be full validated OIDs; ref suffixes must be one exact `Guid` component; checkpoint path hashes must match the current project; branch refs reject every Git-forbidden character (including space); and project paths must be lexically rooted beneath the project root. Physical symlink containment is deliberately checked at persistence and again after a prepared Git tree but before its ref update/reopen, so a target-created escape remains recoverable by descriptor yet is never opened. Invalid descriptors are logged and skipped without becoming Git revision arguments. Pull revalidates the checkpoint ref, its first parent, the recorded base branch tip, and the project-state fingerprint, builds a merged tree and Safety commit without moving the branch, then applies that exact tree through the guarded transition. Callers never observe an intermediate mutation primitive. Completion uses one `update-ref --stdin` transaction with expected-old values for both descriptor and checkpoint; any CAS change retains both refs and reports a changed recovery instead of partially deleting evidence. If Git commits both deletions but the process response is lost, re-observing both refs as absent accepts the durable completion instead of reporting a nonexistent preserved reference.
+5. **Restore transaction**: the coordinator records a Safety snapshot only when the project pathspec is dirty, closes the project, applies the selected tree, and appends a Restore commit. If a later step fails after that commit, the backend applies the captured original tree and appends a Recovery commit; it does not erase the attempted restore or rewrite history.
+6. **Branch and pull transactions**: the project must be closed before a tree transition. Pull accepts only a fast-forward. A transition holds the worktree-private `HEAD.lock`, validates the attached ref and scoped worktree/index fingerprints, updates the captured worktree/private index through a protected branch-mode checkout, and compare-and-swaps the same branch from the exact expected commit to the target as its final durable step. The checkout and CAS run from a temporary detached/no-checkout worktree because Git's checked-out-branch update path otherwise contends with the original `HEAD.lock`; environment overrides point the checkout at the original worktree/index while only the temporary HEAD moves. Git therefore rejects late tracked, untracked, and ignored collisions without detaching the user's project HEAD. Recovery observes and, when necessary, expected-old-aligns that temporary HEAD before reversing an exact target state; unknown worktree content is not overwritten, while a proven Beutl-owned prepared index is restored. The temporary context is cleaned up best-effort. An external ref movement yields `OwnershipLost` and is never overwritten. If the backend cannot prove either the target or restored original state, it yields `RecoveryFailed`; the coordinator keeps the project closed, retains its checkpoint, and maps either internal state to exactly `Failed(VersionControl_PullTransitionUncertain)` without composing the backend's inner result text.
+7. **Local destructive phases**: after their cancellable preflight, guarded tree transitions, checkpoint restore/delete, and branch-tip rollback run to a verified boundary without accepting cancellation. This keeps the private ref reachable and prevents cancellation from exposing a half-applied local transaction.
+8. **Conflict lockout**: when `WorkspaceStatus.HasConflicts`, every mutation is refused with conflict guidance while read members (`GetStatusAsync`, `GetHistoryAsync`, etc.) keep working (FR-033).
+9. **`StatusChanged`**: publication is best-effort after durable mutations and debounced watcher refreshes. Each subscriber is isolated so one callback cannot fail the operation or suppress later subscribers; consumers marshal to the UI thread themselves.
+10. **Cancellation**: cancellable operations kill the underlying git process; the repository is left in a state git itself considers consistent. A killed network operation is followed by coordinator recovery from the captured branch tip and optional durable checkpoint. Identity callbacks receive the exact operation token so cancellation also reaches an in-progress initialization prompt; the prompt cancels its pending result and closes its flyout on the UI thread.
+11. **Errors**: git non-zero exits surface as `GitOperationException { ExitCode, Stderr }` with stderr preserved for the error dialog after credentials embedded in URLs are redacted; remote operations map expected outcomes, including an unrelated-dirty-repository refusal, to `RemoteOpResult` instead of throwing.
+
+## Exposure
+
+- `EditViewModel.GetService(typeof(IProjectVersionControlService))` returns the coordinator's visible read/query service for the open project; temporary close publishes `null` without surrendering backend ownership.
+- The tool tab observes `IReadOnlyReactiveProperty` for queries and resolves `IProjectVersionControlCoordinator` for mutations.
+- Plugin callers cannot cast the public service to the internal backend or transaction interfaces.
diff --git a/docs/specs/005-project-git-versioning/data-model.md b/docs/specs/005-project-git-versioning/data-model.md
new file mode 100644
index 0000000000..2bb8a23d54
--- /dev/null
+++ b/docs/specs/005-project-git-versioning/data-model.md
@@ -0,0 +1,142 @@
+# Data Model: Git Version Control for Editing Projects
+
+**Feature**: 005-project-git-versioning | **Date**: 2026-07-28
+
+All types live in `Beutl.Editor.VersionControl` (project `src/Beutl.Editor/`, Avalonia-free) unless noted. Types are immutable records unless stated otherwise.
+
+## GitAvailability
+
+Result of probing the machine for git tooling.
+
+| Field | Type | Notes |
+|---|---|---|
+| `State` | `GitAvailabilityState` | `Installed` / `NotInstalled` / `VersionTooOld` |
+| `GitPath` | `string?` | Resolved executable path when installed |
+| `Version` | `Version?` | Parsed from `git --version`; floor is 2.23 (needs `git switch`, worktree, and current plumbing behavior) |
+| `LfsInstalled` | `bool` | `git lfs version` succeeded |
+
+## RepositoryInfo
+
+Identity of the repository serving one open project. `null` on the service ⇒ project not under version control (or git unavailable).
+
+| Field | Type | Notes |
+|---|---|---|
+| `RepoRoot` | `string` | Absolute path of the repository work-tree root |
+| `ProjectRoot` | `string` | Absolute path of the directory containing the `.bep` |
+| `IsNestedInForeignRepo` | `bool` | `RepoRoot` ≠ `ProjectRoot` (enclosing repo the user opted into) |
+| `Pathspec` | `string` | `"."` for a dedicated repo; project directory relative to `RepoRoot` when nested. Project status/history/snapshot/tree operations use it |
+
+**Invariant**: `ProjectRoot` is always equal to or below `RepoRoot`. Project-content operations never stage or restore paths outside `Pathspec`; disclosed repository-level branch, push, pull, cleanliness checks, and expected-old ref updates apply to the enclosing repository (FR-003).
+
+## SnapshotKind
+
+`enum`: `Manual` | `Save` | `Close` | `Safety` | `Restore` | `Recovery` | `Init`.
+
+Persisted in the repository as the commit trailer `Beutl-Snapshot: save|close|safety|restore|recovery|init` (absent ⇒ `Manual`, including commits made by external tools). `Recovery` records a compensating commit after an attempted restore committed successfully but its project reopen failed. UI badges/localization derive from this — never from the subject text (FR-016).
+
+## CommitInfo
+
+One entry in the history list.
+
+| Field | Type | Notes |
+|---|---|---|
+| `Sha` / `ShortSha` | `string` | |
+| `Subject` | `string` | Raw subject; shown verbatim for `Manual`, localized display for auto kinds |
+| `AuthorName` | `string` | |
+| `AuthorDate` | `DateTimeOffset` | |
+| `Kind` | `SnapshotKind` | Parsed from trailer |
+
+## FileChange
+
+| Field | Type | Notes |
+|---|---|---|
+| `Path` | `string` | Repo-relative |
+| `Status` | `FileChangeStatus` | `Added` / `Modified` / `Deleted` / `Renamed` |
+| `OldPath` | `string?` | For renames |
+
+## WorkspaceStatus
+
+Snapshot of the current repo state, produced by one `git status --porcelain=v2 -z` (+ branch/ahead-behind headers).
+
+| Field | Type | Notes |
+|---|---|---|
+| `Branch` | `string?` | `null` only in the rejected detached case (defensive; UI shows a warning) |
+| `Ahead` / `Behind` | `int` | vs upstream; 0 when no upstream |
+| `Changes` | `IReadOnlyList` | Scoped to `Pathspec` |
+| `HasConflicts` | `bool` | Unmerged paths present ⇒ service enters `Conflicted` (FR-033) |
+| `IsClean` | `bool` | Derived: no changes |
+
+**Repository-state transitions**: `NotARepo → Ready` (initialization / opening a tracked project); `Ready → Conflicted` (unmerged paths detected); `Conflicted → Ready` (external resolution observed on refresh). There is no in-app transition into `Conflicted` — only external tools can create it.
+
+**Backend lifetime transitions**: `Active → Retiring → Retired`. Starting retirement immediately rejects new mutations, waits for the current exclusive transaction, optionally records the final close snapshot, then disposes the watcher and other resources. `Retired` is terminal. A temporary close during a coordinator cycle hides the public service without retiring the owned backend.
+
+## CommitResult / RemoteOpResult
+
+- `CommitResult`: `NoChanges` | `Committed(CommitRevision Revision)` | `SkippedNoIdentity` (auto-triggers only; one-time warning surfaced). `CommitRevision` is `Known(string Sha)` or `Unavailable`; the latter means `git commit` succeeded but the best-effort post-commit revision lookup failed, so callers must not retry the commit.
+- `RemoteOpResult`: `Success` | `AuthFailed(string Guidance)` | `Diverged` | `Offline` | `RepositoryDirty` | `Failed(string Stderr)` — each maps to a distinct actionable message (FR-031/FR-032, edge cases). `RepositoryDirty` is reserved for a failed whole-repository cleanliness precondition; it never represents ownership loss or an unverified recovery.
+
+## CheckedOutBranchTip / ProjectCheckpoint
+
+- `CheckedOutBranchTip(RefName, Commit)` identifies one attached local branch and its exact commit. Detached HEAD is not a valid input to a close/reopen mutation cycle.
+- `ProjectCheckpoint(RefName, Commit, BaseTip)` identifies a commit reachable through `refs/beutl/safety/*`. It captures the project pathspec with a temporary index while leaving the checked-out branch, working tree, and user's index unchanged.
+- A checkpoint is valid only while its ref resolves to the recorded commit, that commit's first parent equals `BaseTip.Commit`, and the same local branch remains checked out. Branch rollback uses the recorded ref plus expected-old commit as one compare-and-swap.
+
+## PullTransitionState
+
+Internal result state returned with a fast-forward pull:
+
+- `Unchanged`: no durable branch/tree transition remains; normal recovery/reopen is safe.
+- `Applied`: the exact target tree/index was prepared and the expected-old branch CAS reached the target.
+- `OwnershipLost`: an external ref, worktree, or index update invalidated Beutl's captured ownership; Beutl does not overwrite it.
+- `RecoveryFailed`: mutation started and the backend could not verify either the target or restored original state.
+
+`OwnershipLost` and `RecoveryFailed` leave the project closed and retain any private checkpoint. They remain distinct internally because the coordinator must not attempt a second rollback against uncertain ownership; only at the public coordinator boundary are both rendered as the exact localized uncertain-transition `Failed` result, without inner result text.
+
+## BranchInfo / RemoteInfo / GitIdentity
+
+- `BranchInfo`: `Name`, `IsCurrent`, `UpstreamName?`.
+- `RemoteInfo`: `Name` (always `origin` in v1), `Url`.
+- `GitIdentity`: `Name`, `Email`; `null` from `GetIdentityAsync` ⇒ unset (triggers the one-time prompt, stored repo-local — FR-004).
+
+## VersionControlConfig (`src/Beutl.Configuration/`, mutable `ConfigurationBase`)
+
+| Property | Type | Default | Maps to |
+|---|---|---|---|
+| `EnableForNewProjects` | `bool` | `true` | Creation-dialog checkbox default (clarification #1) |
+| `AutoCommitOnSave` | `bool` | `true` | FR-012 |
+| `AutoCommitOnClose` | `bool` | `true` | FR-013 |
+| `GitExecutablePath` | `string?` | `null` | Discovery override (R-3) |
+| `UseLfsWhenAvailable` | `bool` | `true` | FR-035 (clarification #4) |
+| `LargeMediaWarningThresholdMb` | `int` | `50` | FR-035 warning without LFS |
+
+All six values are editable from the existing Editor Settings page; blank executable input restores automatic Git discovery, and the media threshold is clamped to at least 1 MB.
+
+## Repository content contracts (on-disk)
+
+- **Generated `.gitignore`** (project root; also written inside the project dir in the nested case): `**/.beutl/`, `*.tmp`.
+- **Generated `.gitattributes`**: `*.bep` / `*.scene` / `*.belm` / `.gitignore` / `.gitattributes` → `text eol=lf`; when LFS active: `resources/**` media patterns → `filter=lfs diff=lfs merge=lfs -text`.
+- **Commit message**: subject per R-5; trailer `Beutl-Snapshot: ` for auto commits.
+
+## Relationships
+
+```text
+VersionControlCoordinator (src/Beutl/, app-level, 1 per open project)
+ ├─ owns → IProjectVersionControlBackend (GitCliVersionControlService)
+ │ ├─ RepositoryInfo (identity, pathspec scoping)
+ │ ├─ GitCliRunner (process contract, R-2)
+ │ ├─ RepositoryWatcher (debounced status refresh, R-8)
+ │ └─ emits WorkspaceStatus via StatusChanged (background thread)
+ ├─ exposes → IProjectVersionControlService (read/query only)
+ ├─ mutates → IProjectVersionControlTransaction (exclusive, non-retainable)
+ ├─ subscribes → ProjectService.ProjectObservable (create/dispose per project)
+ └─ orchestrates → close → git op → reopen cycles (restore / switch / pull)
+
+VersionControlTabViewModel (src/Beutl.Editor.Components/)
+ ├─ observes IProjectVersionControlService for status/history/diff queries
+ └─ resolves IProjectVersionControlCoordinator for mutations
+ (EditViewModel switchboard; all scene tabs of one project share the instances)
+```
+
+## Element file naming (prerequisite fix, `Beutl.Editor`)
+
+`ElementFileNaming.GetUri(sceneUri, elementId)` → `{Id:N}.belm`; on collision append `-{index}` (matches `DeclarativeDocumentApplier`). Replaces `RandomFileNameGenerator` at the six GUI call sites (R-10.1). Existing files are never renamed.
diff --git a/docs/specs/005-project-git-versioning/plan.md b/docs/specs/005-project-git-versioning/plan.md
new file mode 100644
index 0000000000..359ec65eb0
--- /dev/null
+++ b/docs/specs/005-project-git-versioning/plan.md
@@ -0,0 +1,146 @@
+# Implementation Plan: Git Version Control for Editing Projects
+
+**Branch**: `speckit/005-project-git-versioning` | **Date**: 2026-07-28 | **Spec**: [spec.md](./spec.md)
+
+**Input**: Feature specification from `docs/specs/005-project-git-versioning/spec.md`
+
+## Summary
+
+Turn a Beutl project directory into a Git repository the app manages for the user: automatic snapshots on explicit save/close, manual commits, a history tool tab with restore, branches, and a single remote (push / ff-only pull) — implemented by invoking the user's installed `git` CLI (research R-1), with graceful degradation when git is absent. Four serialization prerequisites (Id-based element file names, appVersion churn, path-separator normalization, JSON newline pinning — R-10) land first so commits are minimal and cross-platform from day one. Every operation that changes files under the editor runs a durable-preserve → close → operate → reopen cycle (R-6/R-7); dirty pull uses a private-ref checkpoint so fast-forward remains possible, and operation-specific recovery prevents history or project-state loss.
+
+## Technical Context
+
+**Language/Version**: C# (`LangVersion: preview`), .NET `net10.0` + `net10.0-windows`
+
+**Primary Dependencies**: none new — the user's installed `git` (≥ 2.23) as a child process; optional `git-lfs`. No LibGit2Sharp (R-1). Avalonia for the tool tab UI.
+
+**Storage**: the project directory itself becomes the repository work tree; generated `.gitignore`/`.gitattributes`; commit trailers (`Beutl-Snapshot:`) as version metadata (data-model.md)
+
+**Testing**: NUnit + Moq in `tests/Beutl.UnitTests/Editor/VersionControl/` against **real git** in temp dirs (`Assert.Ignore` when absent; env-isolated — R-14); two shell E2E scenarios in `tests/Beutl.HeadlessUITests/`
+
+**Target Platform**: Windows / macOS / Linux desktop (GUI-launch PATH discovery per R-3)
+
+**Project Type**: desktop application feature — Avalonia-free core service (`Beutl.Editor`) + shell orchestration (`Beutl`) + tool tab (`Beutl.Editor.Components`) + settings (`Beutl.Configuration`)
+
+**Performance Goals**: snapshot of a 500-element project ≤ 2 s off the UI thread; history view opens ≤ 1 s for 200 versions (SC-003); bounded `git status` calls under autosave bursts (R-8 stress test)
+
+**Constraints**: never block the UI thread; never stage or restore project content outside the project pathspec in a shared repo (FR-003); no history-rewriting operation exposed (FR-028); repository content language-independent (R-5); one in-process writer with external ownership changes detected and never overwritten
+
+**Scale/Scope**: hundreds of small JSON files per project; histories in the hundreds of versions; media up to multi-GB via LFS
+
+## Constitution Check
+
+*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
+
+| Principle | Gate | Status |
+|---|---|---|
+| I. License Firewall | No `ProjectReference` to `Beutl.FFmpegWorker`; no GPL linkage | **PASS** — feature spawns the user's `git` as a separate process (mere process invocation, no linking); no LibGit2Sharp/libgit2 dependency at all (R-1) |
+| II. Dual TFM | `net10.0` + `net10.0-windows` keep building | **PASS** — no new TFM; no platform-specific APIs beyond existing per-OS process patterns; no new NuGet packages |
+| III. Test-First NUnit | New logic ships with tests | **PASS** — real-git unit suite + serialization regression additions (`NoMigrationRegressionTests`) + 2 headless E2E scenarios (R-14); coverage gate unchanged |
+| IV. Avalonia + Compiled Bindings | New XAML declares `x:CompileBindings` + `x:DataType` | **PASS** — `VersionControlTab` views follow the rule (coordinator-lifecycle.md); core service is Avalonia-free by placement |
+| V. Style Belongs to the Linter | No stylistic-only edits | **PASS** — `dotnet format` owns style |
+| VI. Source Generators | No generator changes | **PASS** — feature does not touch `Beutl.Engine.SourceGenerators` |
+
+**Post-review re-check**: PASS — the design adds no project, package, or cross-boundary reference. The plugin-facing `IProjectVersionControlService` is intentionally query-only; user mutations live on `IProjectVersionControlCoordinator`, while backend, transaction, and policy-notice primitives remain internal. The service/initializer signature changes, `CommitRevision`, `SnapshotKind.Recovery`, `RemoteOpResult.RepositoryDirty`, and the appVersion serialization change are breaking and require a `feat!:`/`refactor!:` commit with a `BREAKING CHANGE:` footer and migrated call sites. The footer must state all of the following, with no compatibility overloads retained: `IProjectVersionControlInitializer.InitializeCurrentProjectAsync` now requires `Func>`; `IProjectVersionControlCoordinator` adds the pending-recovery event/list/action surface and returns the typed `ProjectRecoveryResult`; `CreateNewProjectViewModel` removes its one-argument constructor and requires all three dependencies; `MenuBarViewModel` now accepts `IProjectVersionControlSession` (availability/tracking state and save notification only) instead of the concrete coordinator while project close remains on `ProjectService`; and implementations must provide the new session contract.
+
+## Project Structure
+
+### Documentation (this feature)
+
+```text
+docs/specs/005-project-git-versioning/
+├── spec.md # Feature specification (+ Clarifications 2026-07-28)
+├── plan.md # This file
+├── research.md # Phase 0 — decisions R-1 … R-14
+├── data-model.md # Phase 1 — service/config/repo-content model
+├── quickstart.md # Phase 1 — user walkthrough + manual verification matrix
+├── contracts/
+│ ├── version-control-service.md # IProjectVersionControlService seam
+│ ├── git-cli-invocation.md # GitCliRunner process contract
+│ └── coordinator-lifecycle.md # trigger wiring + close/reopen cycle + UI map
+└── tasks.md # Phase 2 (/speckit-tasks — not created by /speckit-plan)
+```
+
+### Source Code (repository root)
+
+```text
+src/Beutl.Core/
+├── Project.cs # touched: appVersion churn fix (R-10.2, feat!)
+└── JsonHelper.cs # touched: NewLine = "\n" pinning (R-10.4)
+
+src/Beutl.ProjectSystem/ProjectSystem/
+└── Scene.cs # touched: Include/Exclude separator normalization (R-10.3)
+
+src/Beutl.Editor/
+├── VersionControl/ # NEW — Avalonia-free core
+│ ├── IProjectVersionControlService.cs
+│ ├── GitCliVersionControlService.cs
+│ ├── GitCliRunner.cs
+│ ├── GitInstallationLocator.cs
+│ ├── RepositoryWatcher.cs
+│ └── (records: RepositoryInfo, CommitInfo, WorkspaceStatus, … per data-model.md)
+└── Services/
+ ├── ElementFileNaming.cs # NEW — {Id:N}.belm convention (R-10.1)
+ ├── ElementStructureService.cs # touched: use ElementFileNaming
+ ├── DuplicateHelper.cs # touched: use ElementFileNaming
+ └── ElementClipboardService.cs # touched: use ElementFileNaming
+
+src/Beutl.Configuration/
+├── VersionControlConfig.cs # NEW — ConfigurationBase subclass
+└── GlobalConfiguration.cs # touched: wire the new config
+
+src/Beutl.Editor.Components/
+└── VersionControlTab/ # NEW — tool tab (views + viewmodels)
+
+src/Beutl/
+├── Services/VersionControlCoordinator.cs # NEW — lifecycle + close/reopen cycles
+├── Services/PrimitiveImpls/VersionControlTabExtension.cs # NEW
+├── Services/StartupTasks/LoadPrimitiveExtensionTask.cs # touched: register extension
+├── ViewModels/EditViewModel.cs # touched: GetService branch
+├── ViewModels/EditContext/ElementAdderImpl.cs # touched: use ElementFileNaming
+├── ViewModels/MenuBarViewModel.Files.cs # touched: save hooks + new commands
+├── ViewModels/Dialogs/CreateNewProjectViewModel.cs # touched: tracking checkbox
+└── Views/MainView.axaml (+ InitializeMenuBar.cs, MacWindow) # touched: context command handlers
+
+src/Beutl.Language/
+└── Strings.resx (+ locales) # touched: new strings
+
+tests/Beutl.UnitTests/Editor/VersionControl/ # NEW — real-git suite (R-14)
+tests/Beutl.UnitTests/ProjectSystem/NoMigrationRegressionTests.cs # touched (R-10.2/10.4)
+tests/Beutl.HeadlessUITests/ # touched: 2 E2E scenarios
+```
+
+**Structure Decision**: no new csproj — the core service goes into `src/Beutl.Editor/VersionControl/` (the placement rule for Avalonia-free, unit-testable editor services; precedent `ProjectPackageService`), UI into the existing tool-tab host `Beutl.Editor.Components`, shell wiring into `src/Beutl`. This mirrors how FileBrowserTab/TerminalTab are split today and keeps the plugin-facing seam (`IProjectVersionControlService` via `IEditorContext.GetService`) in a library project.
+
+## Phase 0: Research
+
+Complete — [research.md](./research.md), decisions R-1 … R-14. Headline choices: user's `git` CLI over LibGit2Sharp (R-1), snapshot-on-explicit-save-only (R-4), restore-as-new-commit (R-6), close→operate→reopen cycle (R-7), watcher/status anti-feedback design (R-8), four serialization prerequisites (R-10), pathspec scoping for enclosing repos (R-11).
+
+## Phase 1: Design & Contracts
+
+Complete — [data-model.md](./data-model.md), [contracts/](./contracts/), [quickstart.md](./quickstart.md). The service seam, process contract, and coordinator orchestration (trigger table, cycle steps, UI map) are pinned; repository content contracts (`.gitignore`, `.gitattributes`, message trailers) are in data-model.md.
+
+## Phase 1 testing
+
+- **Serialization prerequisites**: `NoMigrationRegressionTests` additions (appVersion preserved on plain resave; newline byte-stability on all OSes), separator normalization round-trip (Windows-written exclude entries load on POSIX), `ElementFileNaming` collision suffixes, per-call-site tests that new elements get `{Id:N}.belm`.
+- **Runner**: arg passing, NUL parsing, typed non-zero-exit errors with stderr, env injection (`GIT_TERMINAL_PROMPT`, `GIT_OPTIONAL_LOCKS`), cancellation kills the process.
+- **Service**: init artifacts (+ initial commit), status parsing incl. unmerged→`Conflicted` lockout, clean-tree commit skip, trailer round-trip through history, log paging, nested-repo pathspec scoping (fixture with a repo root above the project; asserts foreign files never staged/cleaned), restore reproduces the exact tree of the target commit incl. deleting later-added elements while `.beutl/` survives, durable private-ref checkpoint create/apply/restore/delete, checked-out-branch-tip compare-and-swap rollback, branch create/switch, dirty+remote-ahead ff-pull success + divergence via a local bare remote, repo-local identity get/set.
+- **Watcher**: debounce and `.git`/`.beutl`/`*.tmp` exclusion (TimeProvider-based); 1000-edit burst asserts bounded status calls (R-8).
+- **Shell E2E**: save → snapshot appears; restore close/reopen cycle completes and clears undo; post-restore reopen failure appends a recovery commit; dirty+remote-ahead pull preserves both states; temporary service publication remains coherent.
+- **Manual matrix**: quickstart.md table (network/credential/LFS/notarization paths that cannot be automated honestly).
+
+## Risks & mitigations
+
+| Risk | Likelihood | Impact | Mitigation |
+|---|---|---|---|
+| appVersion serialization change ripples into migration semantics / fixtures | Medium | High | Land first as an isolated `feat!:` task with explicit "when does appVersion advance" rules + regression fixtures (R-10.2) |
+| autosave → watcher → `git status` feedback loop | Medium | Medium | `GIT_OPTIONAL_LOCKS=0` + `.git`/`.beutl` exclusion + 500 ms debounce, verified by the 1000-edit burst test (R-8) |
+| A tree transition overwrites an external worktree/ref update | Low | High | Hold the worktree-private `HEAD.lock`, compare scoped worktree/index fingerprints, make branch CAS the last durable step, and refuse `OwnershipLost`; real-Git tests cover linked/enclosing worktrees and late tracked/untracked/ignored changes (R-6/R-12, service contract) |
+| Newline pinning causes a one-time full diff for existing Windows projects | Certain (once) | Low | Pair with `.gitattributes eol=lf` so it happens once per project, not per machine; release-notes callout (R-10.4) |
+| Close/reopen cycle meets in-memory state not flushed by the close path | Low | Medium | Reuses the proven `ProjectPackageService.ImportAsync` lifecycle; E2E restore scenario verifies; cycle refuses to run during export (coordinator contract) |
+| macOS CLT git stub triggers an OS install dialog | Medium | Low | `xcode-select -p` check before trusting `/usr/bin/git` (R-3) |
+| GUI-launch PATH misses the user's git | Medium | Low | Ordered probe list + `GitExecutablePath` override (R-3) |
+
+## Complexity Tracking
+
+No constitution violations to justify — no new projects, no new packages, no boundary crossings.
diff --git a/docs/specs/005-project-git-versioning/quickstart.md b/docs/specs/005-project-git-versioning/quickstart.md
new file mode 100644
index 0000000000..d3e2d103df
--- /dev/null
+++ b/docs/specs/005-project-git-versioning/quickstart.md
@@ -0,0 +1,50 @@
+# Quickstart: Git Version Control for Editing Projects
+
+**Feature**: 005-project-git-versioning
+
+This walkthrough doubles as the SC-005 discoverability check (enable → history → restore within 2 minutes, in-app UI only) and the manual-verification script.
+
+## 1. Enable tracking
+
+**New project**: File → New Project → the "Track history with Git" checkbox is visible (Git detected) and pre-checked → Create. The project directory is now a repository with an initial version; `.beutl/` state and `*.tmp` are excluded automatically.
+
+**Existing project**: Project → Enable Version Control…. If the project already sits inside one of your own repositories, Beutl asks whether to use that repository or leave the project unmanaged — it never creates a nested repository on its own. Snapshots, status, history, and restore stay scoped to the project folder; branch, push, and pull actions apply to the whole enclosing repository and the UI shows its root.
+
+**No Git installed?** The Version Control tab shows a single guidance panel with per-OS install instructions; everything else in Beutl works as usual.
+
+## 2. Save = version
+
+Edit something, press Ctrl+S / Cmd+S. Open View → Version Control: a "Saved" snapshot appears at the top of the history. Save again without changes — no new version (no empty snapshots). Closing the project with unsaved-to-history changes records a "Closed" snapshot.
+
+## 3. Name a milestone
+
+In the Version Control tab, type a message ("rough cut v1") and press Commit. Your commit appears with a distinct badge next to the automatic snapshots.
+
+## 4. Inspect and restore
+
+Select any version → the changed files list appears; select a file → a line diff. Click Restore on an older version → Beutl explains the project will close and reopen (undo history clears), snapshots your current state for safety, restores, and reopens. The history keeps everything: the old versions, your pre-restore state, and a new "Restored" entry. Nothing is ever deleted.
+
+Prefer to keep the restored line separate? Right-click the version → Restore to new branch.
+
+## 5. Branch an experiment
+
+Version Control tab → branch dropdown → New branch ("alt-ending"). Edit and save freely; switch back via the dropdown (Beutl runs the same safe close/reopen cycle, snapshotting first if needed). Each branch reopens with exactly its own state. Beutl never merges branches beyond fast-forward — divergent lines stay intact as separate versions.
+
+## 6. Back up to a remote
+
+Version Control tab → Remote → paste your repository URL (GitHub/GitLab/self-hosted) → Push. Authentication uses whatever Git already uses on your machine (credential manager, SSH agent); Beutl never asks for or stores passwords. If large media is tracked with LFS, a one-time notice explains hosting quotas.
+
+On another machine: clone the repository with any Git tool, open the `.bep` in Beutl, and continue. Pull fetches new versions (fast-forward only); if histories diverged, Beutl tells you and leaves both sides untouched for resolution in an external Git client.
+
+## Manual verification matrix (release gate)
+
+| Check | Platforms |
+|---|---|
+| HTTPS push/pull via credential helper (GitHub) | Windows / macOS / Linux |
+| SSH push/pull via agent; repeat with a custom `core.sshCommand` or `GIT_SSH*` wrapper/proxy and verify Beutl preserves it | Windows / macOS / Linux |
+| GUI-launch git discovery (Homebrew git, CLT git, no git) | macOS |
+| LFS round-trip with a >100 MB video in `resources/`, clone on 2nd machine, verify playback | any two |
+| Git-absent degradation (full editor pass, zero errors) | one per OS |
+| Windows-committed project cloned and opened on macOS/Linux (SC-006) | Windows → macOS/Linux |
+| Auth-failure dialog wording (revoked token / no agent) | any |
+| Notarized-bundle smoke test: process spawn works from the .app | macOS |
diff --git a/docs/specs/005-project-git-versioning/research.md b/docs/specs/005-project-git-versioning/research.md
new file mode 100644
index 0000000000..34ce9bc593
--- /dev/null
+++ b/docs/specs/005-project-git-versioning/research.md
@@ -0,0 +1,139 @@
+# Research: Git Version Control for Editing Projects
+
+**Feature**: 005-project-git-versioning | **Date**: 2026-07-28
+
+Each entry records a decision that resolves an unknown from the Technical Context, with rationale and the alternatives that were evaluated.
+
+## R-1. Git engine: the user's installed `git` CLI
+
+**Decision**: Invoke the user's installed `git` binary as a child process. Do not take a `LibGit2Sharp` dependency. No hybrid.
+
+**Rationale** (in order of weight):
+
+1. **Credentials.** Push/pull must work with SSH keys + agents, HTTPS credential helpers (Git Credential Manager, osxkeychain, libsecret), and host-specific configuration the user already has. The CLI inherits all of it for free. LibGit2Sharp requires hand-written credential callbacks per transport, and the stock `LibGit2Sharp.NativeBinaries` libgit2 build has no usable SSH transport — "SSH remotes don't work" is unacceptable for the approved remote scope (FR-032).
+2. **git-lfs.** libgit2 does not run smudge/clean filters, so LFS-tracked media would check out as pointer files. The CLI delegates to `git-lfs` transparently (FR-035).
+3. **Native binary / codesigning.** Bundling `libgit2` dylibs inside the notarized macOS .app means signing third-party natives for x64+arm64 in the release pipeline — pure added risk. The CLI needs zero native payload.
+4. **Maintenance.** LibGit2Sharp releases are sporadic and historically lag new .NET versions; the CLI is evergreen and the on-disk repo format is the compatibility contract.
+5. **Performance is irrelevant here.** All operations run at human-interaction rate over hundreds of small JSON files; ~10 ms process-spawn overhead is noise.
+
+**Alternatives considered**:
+- *LibGit2Sharp*: rejected on credentials/SSH, LFS, native bundling, and maintenance grounds above. License note: LibGit2Sharp is MIT but links libgit2 (GPLv2 **with linking exception** — permissible, but moot given rejection).
+- *Hybrid (library for read, CLI for network)*: rejected — two failure domains, two behavior models, no measurable win.
+
+**Consequence**: graceful degradation when git is absent is a first-class requirement (FR-037), following the `FFmpegInstallService` probe precedent (`src/Beutl.Extensions.FFmpeg/FFmpegInstallService.cs` — `which` probe, stdout capture, `WaitForExitAsync`).
+
+## R-2. CLI invocation contract
+
+**Decision**: A single `GitCliRunner` owns all process invocation, with these rules:
+
+- Never through a shell; argument arrays only. Working directory = repository root.
+- Environment on every call: `GIT_TERMINAL_PROMPT=0` (fail fast instead of hanging on credential prompts), `GIT_OPTIONAL_LOCKS=0` (`git status` must not write the index — prevents a feedback loop with the work-tree watcher), `GIT_LITERAL_PATHSPECS=1` (treat generated project paths as literal data), and `LC_ALL=C` (stable parseable output). The sole literal-path exception is `git check-ignore --stdin -z`, which receives validated NUL-delimited repository-relative paths and sets `GIT_LITERAL_PATHSPECS=0` so Git can apply ignore patterns. Network operations preserve inherited `GIT_SSH_COMMAND`/`GIT_SSH`/`GIT_SSH_VARIANT` and effective repository/global `core.sshCommand`/`ssh.variant`; only the unconfigured default transport adds `GIT_SSH_COMMAND=ssh -oBatchMode=yes`.
+- Machine-readable output only: `status --porcelain=v2 -z`, `log --format=…%x00 -z`, `show --name-status -z`, `rev-parse`, `for-each-ref`. Human-facing output is never parsed.
+- Cancellation kills the child process.
+
+**Rationale**: prompts hanging a GUI process, locale-dependent output, and index-writing status calls are the three classic failure modes of GUI-embedded git; each rule closes one. Preserving the effective SSH command keeps user-selected wrappers and non-OpenSSH clients functional, while closing the redirected standard-input stream and adding BatchMode only to default OpenSSH keeps the default path noninteractive. Detailed in `contracts/git-cli-invocation.md`.
+
+**Alternatives considered**: parsing default (`--porcelain` v1 / human) output — rejected, v2 -z is the documented stable machine interface.
+
+## R-3. Git discovery on GUI launch
+
+**Decision**: Probe an ordered candidate list, overridable via `VersionControlConfig.GitExecutablePath`:
+
+- macOS: `git` on PATH → `/usr/bin/git` only if Xcode CLT is actually installed (`xcode-select -p` succeeds; the bare stub otherwise triggers Apple's CLT install dialog) → `/opt/homebrew/bin/git` → `/usr/local/bin/git`.
+- Windows: `where.exe git` → `%ProgramFiles%\Git\cmd\git.exe`.
+- Linux: `git` on PATH.
+- Validate with `git --version` and enforce a minimum version floor (2.23+, for `git switch`, worktree, and the required plumbing behavior). Repository initialization uses `git init` followed by `git symbolic-ref HEAD refs/heads/main`, because `git init -b` is only available from Git 2.28.
+- Bound each subprocess probe to 5 seconds and the complete ordered discovery pass to a shared 10-second budget, while preserving caller cancellation. If the shared budget expires after Git validation but during the LFS probe, report Git as installed with LFS unavailable.
+
+**Rationale**: macOS GUI apps launch with a minimal PATH; the CLT stub is a well-known trap that would pop an OS dialog from inside Beutl. A shared deadline prevents several missing or stalled candidates from multiplying the per-process timeout into an unbounded GUI wait.
+
+**Alternatives considered**: requiring PATH only — breaks the majority macOS GUI-launch case.
+
+## R-4. Commit model: snapshot on explicit save/close only
+
+**Decision**: The work tree is continuously current (autosave writes every undoable edit); commits mark user-meaningful points only — explicit Save / Save All, project close, safety snapshots around destructive-ish operations, recovery snapshots after compensating a failed restore, and manual commits. A dirty pull first writes the project state to a durable private ref without advancing the branch, then promotes it to a normal safety commit on the fast-forwarded tip. Clean-tree triggers skip silently. Always `git add -A -- ` (whole project); no partial staging. *(Pinned by clarification 2026-07-28 and review resolution 2026-07-31.)*
+
+**Rationale**: autosave fires per edit (`EditViewModel.OnChangeOperations` → `AutoSaveService`); mapping commits 1:1 onto it would produce a commit per drag. `HistoryManager` is per-scene, in-memory, with no save-point concept, so the only honest definition of "version" is "the on-disk state at a moment the user called done".
+
+**Alternatives considered**: timer-based checkpoints (rejected in clarification — history noise); commit-per-undo-transaction (rejected — explodes history and couples undo to VC).
+
+## R-5. Snapshot message format
+
+**Decision**: Stable English subjects (`beutl: snapshot on save`, `beutl: snapshot on close`, `beutl: safety snapshot before `, `beutl: restore project state from `, `beutl: recover original project state after failed restore`, `beutl: initialize version control`) plus a machine-readable trailer `Beutl-Snapshot: save|close|safety|restore|recovery|init`. Manual commits use the user's message verbatim, no trailer. The history UI localizes the *display* by parsing the trailer.
+
+**Rationale**: repository content must be language-independent (survives locale switches and external tools); trailers are git's sanctioned metadata channel (FR-016).
+
+**Alternatives considered**: localized subjects written into the repo — rejected (locale-coupled history); git notes — rejected (don't survive push by default).
+
+## R-6. Restore semantics: "restore as a new commit"
+
+**Decision**: Default restore = close project → validate the attached branch plus scoped worktree/index → apply the selected tree through the guarded tree-transition transaction → append a commit with the `restore` trailer → reopen. The transition removes project files absent from the selected tree without running a broad clean and protects untracked or ignored collisions. If failure occurs after the Restore commit, apply the captured original tree and append a compensating `recovery` commit before reopening; never erase the attempted restore. A secondary "Restore to new branch" is offered in the commit context menu. Exposing detached HEAD and destructive reset is rejected outright.
+
+**Rationale**: history stays linear and complete (the pre-restore state is one commit back), nothing is ever lost, `push` keeps working, and the mental model — "make the project look like it did then" — needs zero git literacy. Detached HEAD orphans subsequent auto-commits (GC-able = data loss); reset rewrites history (violates FR-021/FR-028).
+
+**Alternatives considered**: checkout-detached with a rescue branch on edit — rejected as the *default* (silent branch proliferation, confusing state), retained as the explicit secondary action.
+
+## R-7. Live-editor constraint: close → operate → reopen
+
+**Decision**: Every operation that changes files under the editor (restore, branch switch, pull) runs a read-only preflight → release backend gate → confirm → acquire project transition/work-tree lease → reacquire backend gate and revalidate → preserve dirty project state → `ProjectService.CloseProject()` → git operation → `ProjectService.OpenProject()` cycle. This project-transition→backend mutation order matches normal close and prevents lock inversion. Restore and branch switch preserve dirty state as an ordinary safety commit. Pull preserves it as a durable private-ref checkpoint so the checked-out branch can still fast-forward, then publishes a separate durable recovery descriptor immediately before transition, reapplies and commits that state on the new tip, and removes descriptor+checkpoint atomically only after verified reopen/recovery. Restart activation enumerates descriptors and offers recovery without requiring the tool tab; declined entries remain actionable in the tab. Push does not touch the work tree and needs no cycle.
+
+**Rationale**: the in-memory `Scene` is live-bound and `HistoryManager` is per-open-scene; rewriting files under them is undefined behavior. `ProjectPackageService.ImportAsync` already uses exactly this shape, so the lifecycle seam is proven.
+
+**Alternatives considered**: in-place model reload — a much larger feature (object-graph diffing against the live scene) with no v1 payoff; explicitly rejected for v1. Committing dirty state before pull — rejected because it creates local divergence exactly when the remote is ahead. A process-only temporary stash — rejected because cancellation or a process crash can make the saved state undiscoverable to the app; the private ref is a durable recovery marker.
+
+## R-8. Status pipeline and the autosave feedback loop
+
+**Decision**: A `RepositoryWatcher` (recursive FileSystemWatcher on `ProjectRoot`, non-recursive `.gitignore`/`.gitattributes` watchers in each ancestor directory through `RepoRoot`, dedicated Git metadata watchers resolved from `RepoRoot` through `.git`/gitdir/commondir and refs, 500 ms debounce, background-thread events) triggers a single `git status --porcelain=v2 -z` per burst. The ancestor watchers ignore unrelated files and sibling subtrees; `.git/`, `**/.beutl/`, and `*.tmp` are excluded from worktree watch events. `GIT_OPTIONAL_LOCKS=0` guarantees status never writes the Git index, so status cannot retrigger the watcher (double protection). Mutating service calls refresh status on completion. All git operations serialize on one `SemaphoreSlim(1,1)` per project.
+
+**Rationale**: autosave writes the tree on every edit, so the watcher fires constantly; the debounce+exclusion+no-lock triple keeps status calls bounded. Modeled on `DirectoryWatcherService` (`src/Beutl.Editor.Components/FileBrowserTab/Services/DirectoryWatcherService.cs`) but Avalonia-free.
+
+**Verification**: a scripted 1000-edit burst test asserts a bounded number of status invocations.
+
+## R-9. Repository hygiene: generated `.gitignore` / `.gitattributes`
+
+**Decision**: On init, write at the project root:
+
+- `.gitignore`: `**/.beutl/` (per-user view state **and** `output-profile.json`, which lives under `/.beutl/` — so its absolute paths never enter history), `*.tmp` (atomic-write leftovers).
+- `.gitattributes`: `*.bep`/`*.scene`/`*.belm` (+ the dotfiles themselves) `text eol=lf`; LFS patterns for `resources/**` media extensions when LFS is active.
+- `resources/` is **committed** (media traveling with the project is a core value of remotes).
+
+**Rationale**: Beutl's own exporter already excludes `.beutl` (`ProjectPackageService`); ignoring it also covers the absolute-path output-profile issue without a serializer change.
+
+**Alternatives considered**: ignoring `resources/` — rejected (a cloned project would silently lose its relocated media).
+
+## R-10. Serialization prerequisites (in-scope fixes)
+
+**Decision**: Four fixes land first, each as an independent PR-sized task with tests:
+
+1. **Id-based element file names** — extract the AgentToolkit convention (`{Id:N}.belm`, `-{index}` collision suffix; `DeclarativeDocumentApplier.cs:788`) into an `ElementFileNaming` helper in `Beutl.Editor` and replace the six GUI call sites of `RandomFileNameGenerator` (`ElementAdderImpl.cs:50,287`, `ElementStructureService.cs:74`, `ElementClipboardService.cs:205,294`, `DuplicateHelper.cs:162`). No bulk rename of existing files (scene loading is glob-based; names are cosmetic).
+2. **appVersion churn** — `Project.Serialize` writes `BeutlApplication.Version` unconditionally (`src/Beutl.Core/Project.cs:96`); change to persist the loaded `AppVersion` and advance it only when a migration actually rewrites content. Project-item deserialization reports real persisted-content migrations back to `Project`, including legacy formats that normalize to an empty current collection; plain old-version resaves remain unchanged. `feat!:` + positive and negative migration regressions.
+3. **Exclude-list separator normalization** — `Scene` stores `Path.GetRelativePath` output (native `\` on Windows; `Scene.cs` include/exclude update paths); normalize to `/` on write, accept both on read.
+4. **JSON newline pinning** — `JsonHelper.WriterOptions` (`src/Beutl.Core/JsonHelper.cs:41`) leaves `JsonWriterOptions.NewLine` at its .NET default (`Environment.NewLine` ⇒ CRLF on Windows); pin `NewLine = "\n"`, paired with the `.gitattributes` `eol=lf`. One-time diff for existing Windows projects, called out in release notes.
+
+**Rationale**: without these, SC-002 (minimal diffs) and SC-006 (cross-platform portability) are unfalsifiable; each is a spurious-diff or correctness defect independent of this feature's UI.
+
+**Explicitly not fixed** (recorded in spec Out of Scope): ObjectRegenerator GUID regeneration (semantically required for duplicates), percent-encoded URIs (stable, cosmetic), output-profile absolute paths (never committed).
+
+## R-11. Nested / pre-existing repository handling
+
+**Decision**: Before init, `git rev-parse --show-toplevel` from the project directory. If an enclosing repo exists: never nested-init without consent; offer "use enclosing repository" (all path-touching and project-history calls are scoped with pathspec `-- `; a project-local `.gitignore` is written inside the project directory) or "leave unmanaged". Repository-level branch, push, and pull operations act on the whole enclosing repository, disclosed in the UI ("repository root: …").
+
+**Rationale**: users keep projects in their own monorepos; sweeping unrelated files into a Beutl snapshot (or nesting repos silently) is corruption of *their* repository (FR-003). Pathspec scoping also defuses the pathological "home directory is a repo" case.
+
+## R-12. Remote scope and conflict policy
+
+**Decision**: One remote (`origin`), URL-configurable. Push = `git push -u origin HEAD` with progress + cancel. Pull fetches, resolves the configured upstream commit, proves the update is fast-forward, and performs the close/reopen tree transition without invoking merge or rebase. A dirty pull captures the attached branch tip and a durable project checkpoint, builds the merged project tree and Safety commit off-ref, then applies that exact state and compare-and-swaps the branch as the last durable step. Any failure restores the captured tree/index only while ownership fingerprints still match; an unexpected external ref movement or unrelated dirty repository state is refused, never overwritten. Divergence and unmerged states are detected and surfaced with "resolve outside Beutl" guidance; all VC operations block in the `Conflicted` state; the editor itself stays usable; opening files containing conflict markers warns first (they fail JSON parse).
+
+**Rationale**: fast-forward-only means git itself refuses anything destructive; the element-per-file layout keeps realistic conflicts confined to `.scene`/`.bep`, which external tools handle. A semantic merge UI is a standalone future feature (spec Out of Scope).
+
+## R-13. Identity handling
+
+**Decision**: Use `git config user.name/user.email`. If unset at first commit: prompt once (prefilled from the OS username), write **repo-local** config only. The initialization seam accepts `Func>` and passes the exact operation token into that prompt. The Avalonia identity flyout observes the token, cancels its pending result, and closes itself on the UI thread. Unattended auto-commit with missing identity is skipped with a one-time warning instead of fabricating an identity.
+
+**Rationale**: mutating `--global` config from an app is hostile; silent fabricated identities poison shared repos (FR-004).
+
+## R-14. Test strategy against real git
+
+**Decision**: Unit tests run real `git` in per-test temp directories: fixture-level `git --version` probe with `Assert.Ignore` when absent; determinism via `GIT_CONFIG_GLOBAL=/dev/null`, `GIT_CONFIG_NOSYSTEM=1`, fixed `GIT_AUTHOR_DATE`/`GIT_COMMITTER_DATE`, repo-local identity. Remote tests use a local bare repository (no network). Two headless-shell E2E scenarios (save→commit appears; restore cycle) live in `tests/Beutl.HeadlessUITests/`; everything else stays in `tests/Beutl.UnitTests/Editor/VersionControl/` per the csharp.md placement rule.
+
+**Rationale**: mocking git verifies nothing about the porcelain formats this feature depends on; CI runners always ship git. Network/credential paths are the manual-verification matrix (they cannot be automated honestly).
diff --git a/docs/specs/005-project-git-versioning/spec.md b/docs/specs/005-project-git-versioning/spec.md
new file mode 100644
index 0000000000..2483b6431f
--- /dev/null
+++ b/docs/specs/005-project-git-versioning/spec.md
@@ -0,0 +1,285 @@
+# Feature Specification: Git Version Control for Editing Projects
+
+**Feature Branch**: `speckit/005-project-git-versioning`
+
+**Created**: 2026-07-28
+
+**Status**: Draft
+
+**Input**: User description: "プロジェクトをGitで履歴管理できるようにしたい。 — Git version control for user editing projects: full in-app Git integration (commit, history browsing, restore of past versions, branching, remote push/pull) for Beutl editing projects, with automatic snapshots on explicit save/close plus manual user commits with messages, with graceful degradation when Git is absent, including prerequisite git-friendly project-storage fixes and generated ignore/attribute rules with optional large-media handling."
+
+## Overview
+
+A Beutl editing project is already a self-contained directory of small, human-readable text files (one project file, one file per scene, one file per timeline element). This feature turns that directory into a Git repository that the app manages for the user: every explicit save becomes a restorable version, the user can browse the project's history and restore any past version from inside the editor, create branches to try alternative edits, and push/pull the project to a remote for backup and multi-machine work — all without requiring any Git knowledge for the core flows.
+
+Version history is powered by the Git tooling installed on the user's machine. When Git is not installed, the feature quietly steps aside: the editor remains fully functional and the versioning surface shows installation guidance instead of errors.
+
+## Clarifications
+
+### Session 2026-07-28
+
+- Q: Default state of the "track history with Git" option on project creation (shown only when Git is detected)? → A: Enabled by default; the default is adjustable in application settings.
+- Q: Automatic snapshot triggers — explicit save/close only, or additionally timer-based checkpoints? → A: Explicit save / save-all / project close only; no timer-based checkpoints.
+- Q: Does a Save As copy carry the original's history or start fresh? → A: The copy starts a fresh, independent history; the original keeps its history. Copying the repository would silently duplicate history size and remote configuration.
+- Q: Default for the large-file extension (Git LFS) on in-project media? → A: Used automatically when detected (configurable off); a one-time quota notice is shown when a remote is first connected with LFS active.
+
+## Scope
+
+### In scope (this feature)
+
+- Opt-in, per-project version tracking with app-managed repository setup (ignore rules, attribute rules, initial version).
+- Automatic snapshots on explicit save / save-all / project close, plus manual commits with user messages.
+- A version-history view: version list, per-version change summary, and content diff display.
+- Whole-project restore of any past version, always non-destructive (history is preserved; a safety snapshot protects unsaved work).
+- Branch list / create / switch for exploring alternative edits.
+- A single remote per project: push and pull (fast-forward only), with authentication delegated to the user's existing Git credential setup.
+- Project-storage hygiene fixes required for meaningful versioning: minimal diffs per save, stable element file names, cross-platform path separators and line endings, and exclusion of per-user editor state from history.
+- Graceful degradation when Git (or the optional large-file extension) is unavailable.
+
+### Out of scope (deliberately excluded)
+
+- **In-app merge conflict resolution.** Divergent histories are detected and the user is directed to resolve them with external Git tooling; both sides are always preserved, so no data is lost by this exclusion. A semantic merge tool for scene content is a standalone future feature.
+- **Semantic / visual timeline diff.** The history view shows changed items and line-based content diffs; a visual "what changed on the timeline" comparison is a separate feature with its own design surface, and no correctness requirement in this feature depends on it.
+- **Partial staging / per-file commits.** Versions always capture the whole project; element-level cherry-picking of changes contradicts the "each save is a version" model.
+- **Bundling a Git runtime with the app.** The feature uses the user's installed Git and offers installation guidance when absent; shipping a private Git increases installer size and update surface for little gain in v1.
+- **Multiple remotes, tags, rebase, force-push, or history rewriting of any kind.** The in-app surface is intentionally limited to operations that cannot lose committed work.
+- **Making element duplication/splitting preserve identifiers.** Duplicated objects must receive new identifiers for correctness; the resulting "new file" diffs are semantically accurate.
+- **URI readability cosmetics.** Percent-encoded non-ASCII names in project files are stable across saves and never churn diffs; changing the encoding is a cosmetic, repo-wide-diff-causing change with round-trip risk.
+
+## User Scenarios & Testing *(mandatory)*
+
+### User Story 1 - Every save is a restorable version (Priority: P1)
+
+A user enables version tracking for a project (at creation time or later from the version control tab). From then on, every explicit save quietly records a snapshot of the whole project. The user never has to think about Git: saving is versioning.
+
+**Why this priority**: This is the core value — passive, zero-knowledge history. Without it, nothing else in the feature matters.
+
+**Independent Test**: Create a project with tracking enabled, make three edits with an explicit save after each, and verify three distinct versions exist, each reflecting the project state at that save.
+
+**Acceptance Scenarios**:
+
+1. **Given** a new project and Git installed, **When** the user enables version tracking, **Then** the project directory becomes a repository with an initial version, and per-user editor state (view state, output profiles, temp files) is excluded from tracking.
+2. **Given** a tracked project with unsaved changes, **When** the user explicitly saves, **Then** a snapshot version is recorded automatically, labeled as a save snapshot.
+3. **Given** a tracked project with no changes since the last snapshot, **When** the user explicitly saves again, **Then** no new version is created (no empty versions).
+4. **Given** a tracked project with changes, **When** the user closes the project, **Then** a close snapshot is recorded so nothing is left unversioned.
+5. **Given** a tracked project, **When** the user performs many rapid edits without an explicit save, **Then** no versions are created for individual edits (autosave keeps files current, but versions mark user-meaningful points only).
+
+---
+
+### User Story 2 - Browse history and restore a past version (Priority: P1)
+
+The user opens the version-history view, sees a chronological list of versions (save snapshots, close snapshots, manual commits), inspects what changed in each, and restores the project to any past version. Restore never destroys anything: the current state is snapshotted first, and the restore itself is recorded as a new version.
+
+**Why this priority**: History is only useful if you can get back to it. Restore is the second half of the core value and the feature's biggest safety promise.
+
+**Independent Test**: Build a 10-version history, restore version 4, verify the project reopens exactly in its version-4 state, and verify all 10 prior versions plus the pre-restore state remain reachable in history.
+
+**Acceptance Scenarios**:
+
+1. **Given** a tracked project with history, **When** the user opens the history view, **Then** versions are listed with time, kind (automatic/manual), message, and author, and the list stays responsive for long histories.
+2. **Given** a selected version, **When** the user inspects it, **Then** a summary of changed items and a readable content diff are shown.
+3. **Given** a selected past version, **When** the user chooses Restore, **Then** the app explains that the project will close and reopen and that undo history will be cleared, snapshots any unsaved changes, restores the project files to the selected version, records the restore as a new version, and reopens the project.
+4. **Given** a completed restore, **When** the user inspects history, **Then** the pre-restore state is still present and restorable (no version was deleted or rewritten).
+5. **Given** elements that were added after the restored version, **When** the restore completes, **Then** those elements are absent from the reopened project (the project matches the restored version exactly).
+
+---
+
+### User Story 3 - Safe coexistence and graceful degradation (Priority: P1)
+
+A user without Git installed keeps using Beutl exactly as before; the versioning surface shows what to install and why. A user whose projects already live inside an existing repository (e.g. their own monorepo) gets versioning that cooperates with that repository instead of fighting it.
+
+**Why this priority**: The feature must never make the editor worse for users who don't use it, and must never corrupt a user's existing repository. Both are launch-blocking safety properties.
+
+**Independent Test**: On a machine without Git, exercise the full editor surface and verify zero versioning errors; then place a project inside an existing repository and verify snapshots and restore touch only the project's own directory while disclosed branch, push, and pull operations affect the whole enclosing repository.
+
+**Acceptance Scenarios**:
+
+1. **Given** Git is not installed (or is older than the supported floor), **When** the user opens any project, **Then** the editor is fully functional, the versioning surface shows installation guidance, and no error dialogs appear.
+2. **Given** a project directory already inside an existing repository, **When** the user enables version tracking, **Then** the app detects the enclosing repository, never creates a nested repository without explicit consent, and offers to use it while disclosing that branch and remote operations affect the whole enclosing repository.
+3. **Given** a project in a shared (enclosing) repository, **When** a snapshot is recorded, **Then** only files under the project directory are ever included in the version.
+4. **Given** a previous app crash left a stale repository lock, **When** the project is next opened, **Then** versioning recovers automatically or offers a one-click recovery, and never wedges permanently.
+
+---
+
+### User Story 4 - Manual commits with messages (Priority: P2)
+
+At meaningful milestones ("rough cut done", "client feedback round 1"), the user records a named version with their own message, visually distinguished from automatic snapshots in the history view.
+
+**Why this priority**: Named milestones make long histories navigable, but automatic snapshots already provide the safety net, so this is additive.
+
+**Independent Test**: Record a manual commit between automatic snapshots and verify it appears in history with the user's message and a distinct visual treatment.
+
+**Acceptance Scenarios**:
+
+1. **Given** a tracked project, **When** the user invokes Commit with a message, **Then** a version with that message is recorded, capturing the whole current project state.
+2. **Given** mixed history, **When** the user browses it, **Then** manual commits are visually distinguishable from automatic snapshots at a glance.
+3. **Given** no changes since the last version, **When** the user tries to commit, **Then** the app says there is nothing to record (and does not create an empty version).
+
+---
+
+### User Story 5 - Branches for experiments (Priority: P2)
+
+The user creates a branch to try a different edit of the same project ("alt-ending"), switches between branches, and keeps both lines of work intact.
+
+**Why this priority**: Valuable for creative iteration, but builds entirely on the P1 snapshot/restore machinery.
+
+**Independent Test**: Create a branch, make divergent edits on both branches, switch back and forth, and verify each branch reopens with exactly its own state.
+
+**Acceptance Scenarios**:
+
+1. **Given** a tracked project, **When** the user creates a branch, **Then** the new branch starts from the current version and becomes the active branch.
+2. **Given** unsaved changes, **When** the user switches branches, **Then** the app prompts, snapshots the current state, closes the project, switches, and reopens — never silently discarding work.
+3. **Given** two diverged branches, **When** the user switches between them, **Then** each branch's project state is fully restored, and no in-app operation offers a merge beyond fast-forward.
+
+---
+
+### User Story 6 - Remote backup and multi-machine work (Priority: P3)
+
+The user connects the project to a remote repository, pushes their history for backup, and pulls it on another machine (or after edits elsewhere), using the credentials they already have configured for Git.
+
+**Why this priority**: High value but depends on everything else working, adds network/auth complexity, and is the first story where the outside world can push back (divergence, auth failures).
+
+**Independent Test**: Push a tracked project to a remote, clone it on a second machine (different OS), open it in Beutl, and verify it loads and renders identically; then verify pull brings new versions across.
+
+**Acceptance Scenarios**:
+
+1. **Given** a tracked project and a remote URL, **When** the user connects the remote and pushes, **Then** the full history transfers using the user's existing Git authentication, with visible progress and the ability to cancel.
+2. **Given** a remote with new versions, **When** the user pulls and the local history has not diverged, **Then** the project updates to the remote state via the same safe close/reopen cycle, after a safety snapshot.
+3. **Given** local and remote histories have diverged, **When** the user pulls or pushes, **Then** the app clearly explains the situation, preserves both sides untouched, and directs the user to external Git tooling — it never merges, overwrites, or discards either side.
+4. **Given** authentication fails, **When** the user pushes or pulls, **Then** the failure surfaces immediately with actionable guidance (credential helper / SSH agent setup), and the app never prompts for or stores passwords itself.
+5. **Given** a project committed on Windows and cloned on macOS or Linux, **When** it is opened, **Then** it loads with zero path or line-ending errors.
+
+---
+
+### Edge Cases
+
+- **Project inside the user's own existing repository**: detected before enabling; no nested repository is created without explicit consent; snapshots, status, history, and restore are scoped to the project directory, while branch, push, and pull operations affect the whole enclosing repository and are disclosed as such.
+- **Git missing, broken, or below the version floor**: versioning UI degrades to guidance; every other editor feature is unaffected; the probe never blocks startup.
+- **Snapshot concurrent with export/render/proxy generation**: output operations hold a shared workspace lease, while snapshots hold the exclusive lease through staging and commit. An already-running output makes save/close skip only the Git snapshot, and a snapshot in progress refuses a new output. The save/close action itself continues; the next explicit save after output completes records the accumulated changes, so no snapshot captures a partially written file.
+- **Restore or branch switch with unsaved in-memory state**: the user is prompted; dirty on-disk project state is recorded in a safety snapshot first, while a clean project creates no empty snapshot; the close/reopen cycle is the only path that changes files under the editor.
+- **Editing after restoring an old version**: the restore itself is a new version on the current branch, so subsequent saves continue linearly — no detached or orphaned states are ever created.
+- **Huge media files committed into the project**: when the large-file extension is unavailable or a candidate path is not effectively covered by an LFS filter, a one-time warning explains that history size is permanent before large media is first committed; the operation is never blocked.
+- **Cross-platform round-trip**: path separators are normalized in stored file lists, line endings are pinned identically on all platforms, and case-only filename differences are avoided by the app's own file naming; a project committed on one OS opens cleanly on the others.
+- **Interrupted version operation (crash mid-commit)**: a stale repository lock is detected and recovered on next open; the project files themselves are always intact thanks to atomic saves.
+- **Remote failures (offline, rejected auth, non-fast-forward push)**: each failure mode surfaces an actionable, distinct message; saving and editing are never blocked by remote problems.
+- **Stale per-user view state after restore**: reopening tolerates view state that references elements that no longer exist (view state is untracked and may lag the restored content).
+- **Second writer (e.g. a headless agent or external Git session) on the same project**: Beutl serializes its own mutations; tree transitions lock the worktree HEAD, validate scoped fingerprints, and use expected-old branch updates. A detected external change aborts without being overwritten and may leave the project closed with its checkpoint retained. Ordinary snapshots still capture only completed atomic file writes.
+- **Project Save As / rename**: saving a copy to a new location starts a fresh, independent history for the copy (the original keeps its history); an in-place rename of project items relies on rename detection and does not lose history.
+
+## Requirements *(mandatory)*
+
+### Functional Requirements
+
+**Versioning lifecycle & repository hygiene**
+
+- **FR-001**: Version tracking MUST be opt-in per project: offered as a pre-selected option when creating a project (only when Git is available) and as an explicit "enable version tracking" action for existing projects. The system MUST NOT initialize a repository without user consent.
+- **FR-002**: Enabling tracking MUST set up the repository at the project root with generated ignore rules (per-user editor state, temporary files) and attribute rules (consistent line endings; large-media handling when available), and record an initial version of the current project state.
+- **FR-003**: Before initializing, the system MUST detect an enclosing existing repository. If found, it MUST NOT create a nested repository without explicit consent, MUST offer using the enclosing repository, and MUST scope every versioning operation (status, snapshot, history, restore) to the project's own directory so unrelated files are never touched.
+- **FR-004**: Version authorship MUST use the user's existing Git identity. When unset, the system MUST ask once and store the identity for that repository only — it MUST NOT modify the user's global Git configuration, MUST NOT silently fabricate an identity, and MUST propagate the initiating operation's cancellation token through the identity request.
+- **FR-005**: The system MUST recover from interrupted version operations (e.g. a stale lock left by a crash) on the next project open, without data loss and without permanently disabling versioning.
+
+**Git-friendly project storage**
+
+- **FR-006**: Changing a single property of a single element and saving MUST produce a version whose changes touch only that element's file (plus the scene file for structural changes) — no unrelated file churn.
+- **FR-007**: The project file's application-version metadata MUST NOT be rewritten on save unless the project content was actually migrated; opening and saving with a newer app MUST NOT by itself dirty the project.
+- **FR-008**: Newly created element files MUST be named from the element's stable identity rather than randomly, so file names are meaningful and reproducible. Existing files MUST NOT be mass-renamed.
+- **FR-009**: Stored file lists (element include/exclude patterns) MUST use `/` separators on write and accept both separators on read, so a project saved on one OS loads on the others.
+- **FR-010**: Project files MUST serialize with identical line endings on every platform, and repository attribute rules MUST pin the same policy, so cross-platform collaboration produces no line-ending diffs.
+- **FR-011**: Per-user editor state (view state, output profiles) and temporary save artifacts MUST never be recorded in versions.
+
+**Automatic snapshots**
+
+- **FR-012**: When tracking is enabled, an explicit save or save-all MUST record an automatic snapshot if anything changed since the last version, except while an output operation owns the shared workspace lease. In that case the save succeeds without a snapshot, and the next explicit save after output completes records the accumulated changes.
+- **FR-013**: Closing a tracked project with changes since the last version MUST record a close snapshot, except while an output operation owns the shared workspace lease. In that case closing continues without asking Git to snapshot files that may still be changing.
+- **FR-014**: When nothing changed, save/close/commit MUST NOT create a version (no empty versions), and repeated saves MUST NOT spam history.
+- **FR-015**: The system MUST NOT record a version per editing action or autosave tick; continuous autosave keeps files current, while versions mark explicit user save points only.
+- **FR-016**: Automatic snapshot messages MUST be stable and machine-readable in the repository, with the kind (save / close / safety / restore / recovery) distinguishable, while the history view localizes what the user sees.
+- **FR-017**: Version operations MUST run off the UI thread, MUST be serialized against each other, and MUST NOT capture partially written files. Automatic snapshots MUST hold the exclusive workspace lease for their entire staging/commit interval; an existing output lease skips the snapshot, while an existing snapshot lease refuses a new output.
+
+**History browsing**
+
+- **FR-018**: Users MUST be able to view the version list with time, kind (automatic/manual), message, and author, loaded incrementally so long histories stay responsive.
+- **FR-019**: Selecting a version MUST show which files changed, and selecting a changed file MUST show a readable line-based content diff.
+- **FR-020**: The history view MUST reflect the current repository state shortly after any change (new snapshots, external commits), without requiring a manual refresh.
+
+**Restore**
+
+- **FR-021**: Users MUST be able to restore the whole project to any past version. Restore MUST be recorded as a new version on the current line of history — the system MUST NOT rewrite, delete, or orphan any existing version to perform a restore.
+- **FR-022**: Before any operation that changes files under the editor (restore, branch switch, pull), the system MUST durably preserve the current state when there are changes, then close the project, apply the operation, and reopen it. Restore and branch switch use an ordinary safety commit; pull uses a reachable private checkpoint that does not move the branch and promotes it to a safety commit after fast-forward.
+- **FR-023**: The restore confirmation MUST disclose that the project will close and reopen and that the in-session undo history will be cleared.
+- **FR-024**: A restored project MUST match the selected version exactly, including the removal of elements that were added after that version.
+- **FR-025**: A secondary "restore to a new branch" action MUST be available for users who want to keep the restored line separate.
+
+**Manual commits**
+
+- **FR-026**: Users MUST be able to record a manual version with their own message at any time while a tracked project is open; manual versions MUST be visually distinct from automatic snapshots in the history view.
+
+**Branching**
+
+- **FR-027**: Users MUST be able to list branches, create a branch from the current version, and switch branches; switching follows the same safety-snapshot + close/reopen cycle as restore.
+- **FR-028**: The system MUST NOT perform or offer any merge beyond fast-forward, and MUST NOT expose history-rewriting operations (rebase, force operations, resets that discard versions).
+
+**Remotes**
+
+- **FR-029**: Users MUST be able to associate one remote with the project and change its URL.
+- **FR-030**: Push MUST transfer the current branch with visible progress and cancellation; push MUST NOT require closing the project.
+- **FR-031**: Pull MUST apply only fast-forward updates via the durable-checkpoint + close/reopen cycle. On success, dirty local project state MUST be reapplied and committed on the fast-forwarded tip. On divergence or failure, the system MUST restore the exact captured local branch tip and project state without overwriting a concurrent external ref movement, preserve both sides, and direct the user to external Git tooling when automatic recovery is unsafe. `RepositoryDirty` MUST describe only a failed cleanliness precondition; ownership loss or unverified recovery MUST surface as one localized uncertain-transition failure without composing an inner remote-result message.
+- **FR-032**: Authentication MUST be fully delegated to the user's existing Git credential mechanisms; the app MUST NOT collect, store, or transmit credentials itself, and auth failures MUST surface immediately with actionable guidance.
+- **FR-033**: When the repository is in a conflicted state (e.g. after an external merge attempt), versioning operations MUST be blocked with clear guidance while the editor itself remains usable; the app MUST warn before opening project files that contain conflict markers.
+
+**Media policy**
+
+- **FR-034**: Media files located inside the project directory MUST be included in versions by default; media referenced from outside the project stays untracked by nature.
+- **FR-035**: When the large-file extension is available, it MUST be used automatically for media in the project (configurable); when unavailable, committing media past a size threshold MUST trigger a one-time warning that history growth is permanent — and MUST NOT block. When a remote is first connected while the large-file extension is active, a one-time notice MUST explain that remote hosting quotas may apply to large-file storage and bandwidth.
+
+**Settings & degradation**
+
+- **FR-036**: Application settings MUST cover: default state of the tracking option for new projects, automatic snapshot toggles (save / close), and an override path for the Git executable.
+- **FR-037**: When Git is unavailable or below the supported version floor, the entire versioning surface MUST degrade to a single informative state with per-OS installation guidance; every other editor capability MUST remain fully functional with zero versioning errors.
+
+**Non-goals (explicit, to bound scope)**
+
+- **FR-038**: The system is NOT required to provide in-app merge conflict resolution; divergence handling is detection + preservation + guidance.
+- **FR-039**: The system is NOT required to provide a semantic or visual timeline diff; line-based content diffs satisfy this feature.
+- **FR-040**: The system is NOT required to support partial staging, multiple remotes, tags, or any history-rewriting operation.
+- **FR-041**: The system is NOT required to bundle a Git runtime; installation guidance is the v1 answer to Git absence.
+
+### Key Entities
+
+- **Project repository**: the version store rooted at the project directory (or an enclosing repository the user opted into, with operations scoped to the project directory).
+- **Version (snapshot/commit)**: a whole-project state with time, author, message, and kind; immutable once recorded.
+- **Snapshot kind**: save, close, safety, restore, recovery, or manual — machine-readable in the repository, localized in the UI.
+- **Branch**: a named line of history; exactly one is active per project.
+- **Remote**: a single associated backup/collaboration endpoint per project.
+- **Ignore/attribute rules**: generated repository configuration that excludes per-user state and pins cross-platform text policies.
+- **Safety snapshot**: the reachable preservation point taken when project state is dirty, making restore/switch/pull non-destructive without creating empty commits for clean state. For pull it begins as a private checkpoint and becomes an ordinary commit on the fast-forwarded branch tip.
+
+## Success Criteria *(mandatory)*
+
+### Measurable Outcomes
+
+- **SC-001** (integrity): Restoring any version from a 50-version history reopens the project with zero load errors, and the reopened project renders frame-identically to the state that was saved at that version.
+- **SC-002** (diff minimality): Changing one property of one element and saving produces a version that touches exactly that element's file (plus the scene file for structural edits) — never the project file, per-user state, or unrelated files.
+- **SC-003** (performance): Recording a snapshot of a 500-element project completes within 2 seconds without blocking the UI; the history view opens within 1 second for a 200-version history.
+- **SC-004** (safety): 100% of restore, branch-switch, and pull flows with dirty project state create a durable reachable preservation point before mutating files; successful dirty pulls promote that checkpoint to a safety commit, clean flows create no empty safety version, and no sequence of in-app versioning operations can lose committed work or the currently saved project state.
+- **SC-005** (discoverability): A user new to the feature can enable tracking, find the history view, and restore a prior version within 2 minutes using only in-app UI.
+- **SC-006** (portability): A project committed on Windows, pushed, and cloned on macOS or Linux opens with zero path or line-ending errors and renders identically.
+- **SC-007** (degradation): With Git absent, a full pass over the editor's feature surface produces zero versioning-related errors or dialogs beyond the single guidance state.
+
+## Assumptions
+
+- **Git tooling is the user's responsibility in v1.** The feature relies on an installed Git (with a minimum supported version); the app guides installation but does not bundle it.
+- **The tracking option on project creation defaults to enabled when Git is detected**, so most users accumulate history passively; the default is adjustable in settings.
+- **Automatic snapshots fire on explicit save/save-all/close only** — not on autosave ticks and not on a timer. Continuous autosave already keeps files current; versions mark user-intent points.
+- **Repository content is language-independent**: automatic messages are stored in stable English with a machine-readable kind and localized only for display, so repositories survive locale changes and external tools.
+- **Save As starts a fresh history for the copy** rather than duplicating the original's repository; the original project keeps its history.
+- **Media inside the project (`resources/`) is committed by default**; the large-file extension is used automatically when available, and a size-threshold warning covers its absence.
+- **Restore, branch switch, and pull operate on a closed project.** The editor's in-memory state and undo history are per-session; the close/reopen cycle is the only correct way to change files underneath the editor, and undo history loss on reopen is accepted and disclosed.
+- **Beutl is the single in-process writer per project.** Concurrent external Git or file writers are not coordinated by Beutl's internal gate, so every close/reopen transition validates ownership and refuses a mismatch. External writes after the final verified ownership point are new operations observed by the repository watcher; snapshot atomicity remains the boundary for arbitrary file writers.
+
+## Dependencies
+
+- An installed Git meeting the minimum supported version, discoverable on the user's system (with a settings override for nonstandard locations).
+- Optionally, the Git large-file extension for media-heavy projects.
+- The existing project storage model (directory-rooted project, one file per scene/element, autosave-on-edit, atomic file writes) and the existing project open/close lifecycle, which the restore/switch/pull cycle reuses.
+- The existing localization pipeline for all user-facing strings.
diff --git a/docs/specs/005-project-git-versioning/tasks.md b/docs/specs/005-project-git-versioning/tasks.md
new file mode 100644
index 0000000000..c3d1e5b6f5
--- /dev/null
+++ b/docs/specs/005-project-git-versioning/tasks.md
@@ -0,0 +1,162 @@
+# Tasks: Git Version Control for Editing Projects
+
+**Input**: Design documents from `docs/specs/005-project-git-versioning/`
+
+**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/, quickstart.md
+
+**Tests**: included — constitution principle III ("new logic in `src/` is incomplete without an accompanying test") makes them mandatory, not optional. Unit suites run real `git` in temp directories with env isolation (research R-14).
+
+**Organization**: grouped by user story (US1–US6 from spec.md) so each story is an independently testable increment.
+
+## Format: `[ID] [P?] [Story] Description`
+
+- **[P]**: parallelizable (different files, no dependency on an incomplete task)
+- **[Story]**: US1–US6 (user-story phases only)
+
+## Phase 1: Setup
+
+**Purpose**: shared configuration and strings every story consumes
+
+- [X] T001 Add `VersionControlConfig` (`ConfigurationBase`; properties per data-model.md) in src/Beutl.Configuration/VersionControlConfig.cs and wire it into `GlobalConfiguration` (`Save`/`Restore`/`AddHandlers`/`RemoveHandlers`) in src/Beutl.Configuration/GlobalConfiguration.cs; NUnit round-trip tests in tests/Beutl.UnitTests/Configuration/VersionControlConfigTests.cs
+- [X] T002 [P] Add the new user-facing strings (menu entries, dialogs, snapshot badges, degradation guidance, error dialogs) to src/Beutl.Language/Strings.resx and the ja locale, following the existing resource conventions
+
+---
+
+## Phase 2: Foundational (Blocking Prerequisites)
+
+**Purpose**: the four serialization fixes (research R-10 — land first so early adopters' commits are clean) and the Avalonia-free git core every story builds on
+
+**⚠️ CRITICAL**: user-story phases must not start before this phase completes
+
+- [X] T003 [P] appVersion churn fix (`feat!:`): persist the loaded `AppVersion`, aggregate real project-item migrations (including an empty legacy `Operation.Children` rewrite), and advance it only when a migration rewrites content, in src/Beutl.Core/Project.cs + src/Beutl.ProjectSystem/ProjectSystem/{Scene,Element,ElementMigration}.cs; cover both the byte-stable plain resave and migrated project/element save paths, and document the `BREAKING CHANGE:` migration rule
+- [X] T004 [P] Pin `NewLine = "\n"` in `JsonHelper.WriterOptions`/`SerializerOptions` in src/Beutl.Core/JsonHelper.cs; add a newline byte-stability regression test (all platforms produce LF) in tests/Beutl.UnitTests/ProjectSystem/NoMigrationRegressionTests.cs
+- [X] T005 [P] Normalize Scene `Elements` Include/Exclude entries to `/` separators on write and accept both on read in src/Beutl.ProjectSystem/ProjectSystem/Scene.cs; round-trip test proving a Windows-written (`\`) exclude entry still matches on POSIX in tests/Beutl.UnitTests/ProjectSystem/SceneTests.cs
+- [X] T006 [P] Add `ElementFileNaming` (`{Id:N}.belm`, `-{index}` collision suffix, matching `DeclarativeDocumentApplier`) in src/Beutl.Editor/Services/ElementFileNaming.cs; replace `RandomFileNameGenerator` at the six GUI call sites (src/Beutl/ViewModels/EditContext/ElementAdderImpl.cs:50,287; src/Beutl.Editor/Services/ElementStructureService.cs:74; src/Beutl.Editor/Services/ElementClipboardService.cs:205,294; src/Beutl.Editor/Services/DuplicateHelper.cs:162); tests for the convention + collisions in tests/Beutl.UnitTests/Editor/ElementFileNamingTests.cs
+- [X] T007 [P] Create the VersionControl model types per data-model.md (`GitAvailability`, `RepositoryInfo`, `SnapshotKind`, `CommitInfo`, `FileChange`, `WorkspaceStatus`, `CommitResult`, `RemoteOpResult`, `BranchInfo`, `RemoteInfo`, `GitIdentity`, exceptions) under src/Beutl.Editor/VersionControl/
+- [X] T008 Implement `GitInstallationLocator` (ordered probe incl. the macOS CLT-stub check, version floor 2.23, `VersionControlConfig.GitExecutablePath` override, LFS probe, 5-second per-process timeout, shared 10-second end-to-end discovery budget, and caller-cancellation preservation) in src/Beutl.Editor/VersionControl/GitInstallationLocator.cs; tests in tests/Beutl.UnitTests/Editor/VersionControl/GitInstallationLocatorTests.cs
+- [X] T009 Implement `GitCliRunner` per contracts/git-cli-invocation.md (no shell, env injection, NUL-separated parsing helpers, stderr capture, timeout, cancellation kills the process, stale-lock detection hook) in src/Beutl.Editor/VersionControl/GitCliRunner.cs; tests (args, parsing, typed errors, env, cancellation) in tests/Beutl.UnitTests/Editor/VersionControl/GitCliRunnerTests.cs
+- [X] T010 [P] Implement `RepositoryWatcher` (recursive worktree watch on `ProjectRoot`; targeted, non-recursive `.gitignore`/`.gitattributes` watches in ancestor directories through `RepoRoot`; dedicated Git metadata watches resolved through `.git`/gitdir/commondir and refs; 500 ms debounce; unrelated sibling, `.git/`, `**/.beutl/`, and `*.tmp` worktree exclusion; background-thread events; modeled on `DirectoryWatcherService` but Avalonia-free) in src/Beutl.Editor/VersionControl/RepositoryWatcher.cs; TimeProvider-based debounce/exclusion tests in tests/Beutl.UnitTests/Editor/VersionControl/RepositoryWatcherTests.cs
+- [X] T011 Implement the query-only `IProjectVersionControlService`, internal `IProjectVersionControlBackend`/exclusive transaction, and `GitCliVersionControlService` core per contracts/version-control-service.md (serialized Active→Retiring→Retired lifetime, `GetAvailabilityAsync`, porcelain-v2 status including conflicts, best-effort observer-isolated `StatusChanged`) in src/Beutl.Editor/VersionControl/; real-Git fixtures and lifecycle/status tests in tests/Beutl.UnitTests/Editor/VersionControl/GitCliVersionControlServiceTests.cs
+
+**Checkpoint**: foundation ready — user stories can begin
+
+---
+
+## Phase 3: User Story 1 - Every save is a restorable version (Priority: P1) 🎯 MVP
+
+**Goal**: opt-in per-project tracking; every explicit save/close records a snapshot; zero git knowledge needed
+
+**Independent Test**: create a tracked project, save after three edits → three versions, each matching the saved state; repeated clean saves add nothing (spec US1 scenarios)
+
+- [X] T012 [US1] Implement `InitializeAsync`: before any `git init`, discover an enclosing repository and require the caller's exact consented `RepositoryInfo` selection (with a regression proving refusal creates no nested `.git` directory); after that prerequisite, run `git init` + `git symbolic-ref HEAD refs/heads/main` for the Git 2.23 floor when needed, generate `.gitignore` `**/.beutl/` + `*.tmp` and `.gitattributes` `eol=lf` + LFS patterns, run `git lfs install --local` when active, and create the initial `Beutl-Snapshot: init` commit in src/Beutl.Editor/VersionControl/GitCliVersionControlService.cs; artifact, initial-commit, and nested-repository refusal tests in tests/Beutl.UnitTests/Editor/VersionControl/{GitCliVersionControlServiceTests,NestedRepositoryTests}.cs
+- [X] T013 [US1] Implement `CommitAllAsync` (clean-tree skip → `NoChanges`, `git add -A -- `, `Beutl-Snapshot` trailer for auto kinds, `SkippedNoIdentity` for unattended auto commits) and `GetIdentityAsync`/`SetLocalIdentityAsync` (repo-local only) in src/Beutl.Editor/VersionControl/GitCliVersionControlService.cs; commit/trailer/identity tests in the same suite
+- [X] T014 [US1] Implement `VersionControlCoordinator` (subscribe `ProjectService.ProjectObservable`, per-project service + watcher lifecycle, `NotifySavedAsync`, close-snapshot hook, config gating) in src/Beutl/Services/VersionControlCoordinator.cs, constructed in src/Beutl/ViewModels/MainViewModel.cs
+- [X] T015 [US1] Wire triggers and exposure: call the coordinator at the end of `OnSave`/`OnSaveAll`, route final close through backend retirement, and expose the read/query `IProjectVersionControlService` plus mutation `IProjectVersionControlCoordinator` through `EditViewModel.GetService`; temporary lifecycle closes publish no service while retaining backend ownership
+- [X] T016 [P] [US1] Add the "Track history with Git" checkbox (visible when git detected, default `VersionControlConfig.EnableForNewProjects`) to src/Beutl/ViewModels/Dialogs/CreateNewProjectViewModel.cs and its dialog XAML; initialize after creation when checked
+- [X] T017 [P] [US1] Add the "Enable Version Control…" command (gated on `ProjectService.IsOpened`) to src/Beutl/ViewModels/MenuBarViewModel.Files.cs, src/Beutl/Views/MainView.axaml, src/Beutl/Views/MainView.axaml.InitializeMenuBar.cs, and the command palette in src/Beutl/ViewModels/MenuBarViewModel.Palette.cs
+- [X] T018 [P] [US1] Identity prompt dialog (first commit with unset `user.name`/`user.email`; prefill OS username; writes repo-local via `SetLocalIdentityAsync`) under src/Beutl/Views/Dialogs/ + ViewModel with compiled bindings
+- [X] T019 [US1] Shell E2E scenario: explicit save on a tracked project produces exactly one snapshot commit (and none when clean) in tests/Beutl.HeadlessUITests/
+
+**Checkpoint**: US1 fully functional — the MVP ("save = version") works end to end
+
+---
+
+## Phase 4: User Story 2 - Browse history and restore (Priority: P1)
+
+**Goal**: history view (list / changed files / diff) and non-destructive whole-project restore
+
+**Independent Test**: 10-version history → restore version 4 → project reopens in version-4 state; all prior versions plus the pre-restore state remain reachable (spec US2 scenarios)
+
+- [X] T020 [US2] Implement history queries: `GetHistoryAsync` (paged `git log … -z` with trailer parse), `GetCommitFilesAsync` (`git show --name-status -z`), `GetDiffAsync` (unified diff, 1 MB cap with truncation marker) in src/Beutl.Editor/VersionControl/GitCliVersionControlService.cs; paging/trailer/diff tests in the service suite
+- [X] T021 [US2] Implement the exclusive restore-tree transaction in src/Beutl.Editor/VersionControl/GitCliVersionControlService.cs; it validates the attached branch and scoped worktree/index, applies the target tree, appends the Restore commit, and can compensate with a Recovery commit. Tests cover target byte identity, removal of later-added elements, ignored-file protection, and failure recovery.
+- [X] T022 [US2] Implement the coordinator restore cycle per contracts/coordinator-lifecycle.md (confirm dialog disclosing close/reopen + undo loss, safety snapshot when dirty, close → restore → `Beutl-Snapshot: restore` commit → reopen, post-commit failure appends a compensating `Beutl-Snapshot: recovery` commit before reopening the original state, refusal while an export is running) in src/Beutl/Services/VersionControlCoordinator.cs
+- [X] T023 [US2] Build the Version Control tool tab: `VersionControlTabExtension` in src/Beutl/Services/PrimitiveImpls/VersionControlTabExtension.cs (registered in src/Beutl/Services/StartupTasks/LoadPrimitiveExtensionTask.cs) + views/viewmodels under src/Beutl.Editor.Components/VersionControlTab/ (status header with branch/ahead-behind/dirty, incrementally loaded history list with kind badges, changed-files pane, monospace +/- diff view; `x:CompileBindings` + `x:DataType` everywhere; `StatusChanged` marshaled to the UI thread); ViewModel tests in tests/Beutl.UnitTests/Editor/VersionControl/
+- [X] T024 [P] [US2] "Restore to new branch" context action (`git switch -c ` through the same cycle) in the tab ViewModel + service; test for the created branch state
+- [X] T025 [US2] Shell E2E scenario: restore an older version → close/reopen completes, project state matches, undo history cleared, in tests/Beutl.HeadlessUITests/
+
+**Checkpoint**: US1+US2 = the complete safety story (save = version, any version restorable, nothing ever lost)
+
+---
+
+## Phase 5: User Story 3 - Safe coexistence and degradation (Priority: P1)
+
+**Goal**: never corrupt a user's existing repository; fully functional editor without git
+
+**Independent Test**: (a) no git → full editor pass with zero versioning errors + guidance panel; (b) project inside an existing repo → snapshots and restore touch only the project directory, while branch, push, and pull are verified to act on the whole enclosing repository after explicit disclosure (spec US3 scenarios)
+
+- [X] T026 [US3] Complete the enclosing-repository flow whose pre-init guard is required by T012: consent UI ("use enclosing repository" with pathspec scoping + project-local `.gitignore` / "leave unmanaged"), `RepositoryInfo.IsNestedInForeignRepo`/`Pathspec` plumbing through every path-touching call, and explicit disclosure that branch/push/pull act on the whole enclosing repository, in src/Beutl.Editor/VersionControl/ + coordinator consent dialog; nested fixtures (repo root above project) asserting foreign files are never staged, restored, or cleaned by project-scoped operations and that branch/push/pull retain whole-repository semantics, in tests/Beutl.UnitTests/Editor/VersionControl/NestedRepositoryTests.cs
+- [X] T027 [P] [US3] Degradation surface: availability drives the tab to a single per-OS guidance state and disables the menu commands (no error dialogs anywhere) in src/Beutl.Editor.Components/VersionControlTab/ + src/Beutl/ViewModels/MenuBarViewModel.Files.cs; availability-state ViewModel tests
+- [X] T028 [P] [US3] Stale-lock recovery per contracts/git-cli-invocation.md (detect repository-lock failures including `index.lock` and the worktree-private `HEAD.lock`, age + liveness check, consent-gated removal, logged) in src/Beutl.Editor/VersionControl/GitCliRunner.cs; tests with fabricated stale locks in tests/Beutl.UnitTests/Editor/VersionControl/GitCliRunnerTests.cs
+- [X] T029 [US3] Conflicted-state lockout: `HasConflicts` ⇒ mutating members throw `VersionControlConflictedException` with guidance while reads keep working; coordinator surfaces the guidance and warns before opening files containing conflict markers; unmerged-path fixture tests in the service suite
+
+**Checkpoint**: all three P1 stories done — safe to ship as the MVP release
+
+---
+
+## Phase 6: User Story 4 - Manual commits with messages (Priority: P2)
+
+**Goal**: named milestones, visually distinct from automatic snapshots
+
+**Independent Test**: commit with a message between auto snapshots → appears with the message and a distinct badge; clean-tree commit reports "nothing to record" (spec US4 scenarios)
+
+- [X] T030 [US4] Manual commit UI: message box + Commit button in src/Beutl.Editor.Components/VersionControlTab/ (routes through `IProjectVersionControlCoordinator.CommitManualAsync`, handles `NoChanges`, and requests a returned `GitIdentity` when unset), a "Commit Version…" palette/menu command, and Manual-vs-auto badge distinction in the history list; ViewModel tests
+
+**Checkpoint**: US4 done — history becomes navigable by milestones
+
+---
+
+## Phase 7: User Story 5 - Branches for experiments (Priority: P2)
+
+**Goal**: create/list/switch branches with the same safety cycle; both lines always intact
+
+**Independent Test**: create a branch, diverge both branches, switch back and forth → each reopens with exactly its own state (spec US5 scenarios)
+
+- [X] T031 [US5] Implement `GetBranchesAsync` (`for-each-ref`), `CreateBranchAsync`, `SwitchBranchAsync` in src/Beutl.Editor/VersionControl/GitCliVersionControlService.cs; branch create/switch/divergence tests in the service suite
+- [X] T032 [US5] Branch UI + cycle: branch dropdown/list + "New branch" dialog in src/Beutl.Editor.Components/VersionControlTab/, coordinator switch cycle (dirty prompt → safety snapshot → close → `git switch` → reopen; failure surfaces stderr and reopens the original branch) in src/Beutl/Services/VersionControlCoordinator.cs; ViewModel tests
+
+**Checkpoint**: US5 done — no merge surface exists beyond fast-forward (FR-028 guardrail holds)
+
+---
+
+## Phase 8: User Story 6 - Remote backup and multi-machine (Priority: P3)
+
+**Goal**: one remote; push with progress; ff-only pull; auth fully delegated
+
+**Independent Test**: push to a local bare "remote", clone elsewhere, open, pull new versions; divergence and auth failures produce the specified guidance (spec US6 scenarios)
+
+- [X] T033 [US6] Implement `GetRemotesAsync`/`SetRemoteAsync`/`PushAsync` (progress from stderr, cancelable)/`PullFastForwardAsync` with `RemoteOpResult` mapping (`Success`/`AuthFailed`/`Diverged`/`Offline`/`RepositoryDirty`/`Failed`) plus durable dirty-project checkpoints, restart-enumerable recovery descriptors, atomic descriptor+checkpoint completion, and checked-out-branch-tip compare-and-swap recovery in src/Beutl.Editor/VersionControl/GitCliVersionControlService.cs; local-bare-remote tests (push, dirty+remote-ahead ff pull, divergence, detached refusal, checkpoint/restart recovery, malformed descriptor rejection, descriptor CAS retention) in tests/Beutl.UnitTests/Editor/VersionControl/RemoteOperationsTests.cs
+- [X] T034 [US6] Remote UI: URL field, Push/Pull commands with progress + cancel, divergence/auth/offline guidance dialogs, pull via the coordinator cycle, in src/Beutl.Editor.Components/VersionControlTab/ + src/Beutl/Services/VersionControlCoordinator.cs; ViewModel tests
+- [X] T035 [P] [US6] LFS + large-media policy: auto-track `resources/**` patterns when LFS active (`UseLfsWhenAvailable`), one-time quota notice on first remote connect with LFS, one-time `LargeMediaWarningThresholdMb` warning when LFS is unavailable or a candidate path lacks an effective LFS filter — never blocking; batch effective-filter queries across candidates; expose all six `VersionControlConfig` values in Editor Settings; test attribute generation, warning triggers, and settings round-trips
+
+**Checkpoint**: all six stories functional
+
+---
+
+## Phase 9: Polish & Cross-Cutting Concerns
+
+- [X] T036 [P] R-8 stress test: scripted 1000-edit burst against a tracked temp project asserts a bounded number of `git status` invocations (watcher debounce + `GIT_OPTIONAL_LOCKS=0` hold) in tests/Beutl.UnitTests/Editor/VersionControl/RepositoryWatcherStressTests.cs
+- [X] T037 [P] macOS native menu mirror for the new commands in src/Beutl/Views/MacWindow.axaml.cs and shortcut/palette completeness via `ContextCommandDefinition` in src/Beutl/Services/PrimitiveImpls/MainViewExtension.cs
+- [X] T038 Verify SC-002/SC-003 measurably: one-property edit + save touches exactly one `.belm` (assert in a service test); snapshot timing on a 500-element fixture ≤ 2 s; history load ≤ 1 s for 200 commits (timed tests, generous CI margins)
+- [X] T039 `dotnet format Beutl.slnx` + `dotnet build Beutl.slnx` + `dotnet test Beutl.slnx -f net10.0 --settings coverlet.runsettings` all green; fix fallout (2026-07-28: format 0 violations after encoding/import fixes; build 0 errors; per-project runs — UnitTests 5,010 pass/7 skip, HeadlessUI 198/198, E2E 80/80, AgentToolkit 527/527, FFmpegIpc 56/56, SourceGenerator 11/11, Graphics3D 5/5, FFmpegWorker 1/1, AVFoundation 12/12, MediaFoundation 55/55; one pre-existing flaky proxy-timing unit test passed on rerun)
+- [ ] T040 Run the quickstart.md manual verification matrix (network/credential/LFS/macOS-discovery/notarization rows) and record results in the PR description; include the release-notes callout for the one-time Windows newline diff (R-10.4)
+
+---
+
+## Dependencies & Execution Order
+
+- **Phase 1 → Phase 2 → user stories**: T001 (config) blocks T008/T014/T016; the four serialization fixes T003–T006 are independent of each other and of T007–T011, but all of Phase 2 blocks every story phase.
+- **US1 (Phase 3)** blocks **US2** (restore commits via `CommitAllAsync`; the tab hosts later UI), and US2's tab (T023) hosts US4/US5/US6 UI (T030/T032/T034).
+- **US3** depends only on Phase 2 (T026–T029 touch discovery/runner/service) plus the tab's degradation state (T027 → after T023; the rest can run parallel to US2).
+- **US5/US6** depend on the coordinator cycle from US2 (T022).
+- Story order for a single implementer: US1 → US2 → US3 → US4 → US5 → US6 → Polish. Suggested PR slicing: T003–T006 as individual prerequisite PRs (T003 is `feat!:`), then one PR per story phase.
+
+### Parallel opportunities
+
+- Phase 2: T003, T004, T005, T006, T007, T010 in parallel (distinct files); T008/T009/T011 sequential on T007.
+- Phase 3: T016, T017, T018 in parallel after T014/T015.
+- Phase 5: T027, T028 in parallel; T026/T029 sequential on the service.
+- Phase 8: T035 parallel to T033/T034.
+
+## Implementation Strategy
+
+**MVP = Phases 1–5 (US1+US2+US3, all P1)**: "every save is a restorable version, restore never loses anything, and the feature can never hurt users who don't want it". Ship/validate there, then add US4 (milestones), US5 (branches), US6 (remotes) as independent increments. Stop at any checkpoint — each story leaves the product consistent.
diff --git a/src/Beutl.AgentToolkit.Mcp/Program.cs b/src/Beutl.AgentToolkit.Mcp/Program.cs
index 019100cb41..cee4a09d98 100644
--- a/src/Beutl.AgentToolkit.Mcp/Program.cs
+++ b/src/Beutl.AgentToolkit.Mcp/Program.cs
@@ -3,6 +3,7 @@
using Beutl.AgentToolkit.Sessions;
using Beutl.AgentToolkit.Tools;
using Beutl.AgentToolkit.Workspace;
+using Beutl.Extensibility;
using Beutl.Logging;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@@ -17,6 +18,7 @@
builder.Services
.AddSingleton(_ => new WorkspaceGuard(workspaceRoot))
+ .AddSingleton(StandaloneOutputOperationLeaseProvider.Instance)
.AddSingleton()
.AddSingleton()
.AddSingleton()
diff --git a/src/Beutl.AgentToolkit/Beutl.AgentToolkit.csproj b/src/Beutl.AgentToolkit/Beutl.AgentToolkit.csproj
index f9a4db86e7..b5c1fba23c 100644
--- a/src/Beutl.AgentToolkit/Beutl.AgentToolkit.csproj
+++ b/src/Beutl.AgentToolkit/Beutl.AgentToolkit.csproj
@@ -7,6 +7,7 @@
+
diff --git a/src/Beutl.AgentToolkit/Common/ErrorCode.cs b/src/Beutl.AgentToolkit/Common/ErrorCode.cs
index 9aad020c2a..a3585f4661 100644
--- a/src/Beutl.AgentToolkit/Common/ErrorCode.cs
+++ b/src/Beutl.AgentToolkit/Common/ErrorCode.cs
@@ -14,4 +14,5 @@ public static class ErrorCode
public const string NoActiveEditorSession = "no_active_editor_session";
public const string DestructiveIntent = "destructive_intent";
public const string ProjectConflict = "project_conflict";
+ public const string WorkspaceBusy = "workspace_busy";
}
diff --git a/src/Beutl.AgentToolkit/Common/ToolErrorMapper.cs b/src/Beutl.AgentToolkit/Common/ToolErrorMapper.cs
index 223b08161f..1ac454d730 100644
--- a/src/Beutl.AgentToolkit/Common/ToolErrorMapper.cs
+++ b/src/Beutl.AgentToolkit/Common/ToolErrorMapper.cs
@@ -30,6 +30,11 @@ public static ToolError Map(Exception exception)
ex.Path,
"Reload the project or save to a different path."),
SessionUnavailableException ex => ex.ToError(),
+ OutputOperationBusyException ex => new ToolError(
+ ex.Code,
+ ex.Message,
+ null,
+ "Retry after the conflicting workspace operation completes."),
RenderingUnavailableException ex => new ToolError(ex.Code, ex.Message),
CodecUnavailableException ex => new ToolError(ex.Code, ex.Message),
UnsupportedMediaException ex => new ToolError(ErrorCode.MediaUnsupported, ex.Message, ex.FileName),
diff --git a/src/Beutl.AgentToolkit/Rendering/OutputOperationBusyException.cs b/src/Beutl.AgentToolkit/Rendering/OutputOperationBusyException.cs
new file mode 100644
index 0000000000..c9817c7d04
--- /dev/null
+++ b/src/Beutl.AgentToolkit/Rendering/OutputOperationBusyException.cs
@@ -0,0 +1,13 @@
+using Beutl.AgentToolkit.Common;
+
+namespace Beutl.AgentToolkit.Rendering;
+
+internal sealed class OutputOperationBusyException : Exception
+{
+ public OutputOperationBusyException()
+ : base("Another workspace operation is in progress, so the output operation cannot start.")
+ {
+ }
+
+ public string Code => ErrorCode.WorkspaceBusy;
+}
diff --git a/src/Beutl.AgentToolkit/Rendering/RenderJobManager.cs b/src/Beutl.AgentToolkit/Rendering/RenderJobManager.cs
index f86a291e02..539d5646df 100644
--- a/src/Beutl.AgentToolkit/Rendering/RenderJobManager.cs
+++ b/src/Beutl.AgentToolkit/Rendering/RenderJobManager.cs
@@ -25,7 +25,7 @@ public sealed record RenderJobSnapshot(
// Background render/export jobs so a long render is not killed by the MCP client request timeout.
// Jobs are serialized (single-flight) because all stills share the one RenderThread and each export
// builds its own graphics context; concurrent background renders would race those resources.
-public sealed class RenderJobManager : IDisposable
+public sealed class RenderJobManager : IAsyncDisposable
{
private sealed class JobRecord
{
@@ -33,7 +33,10 @@ private sealed class JobRecord
public required string Kind { get; init; }
public required DateTimeOffset StartedAt { get; init; }
public required CancellationTokenSource Cts { get; init; }
+ public required IDisposable OutputOperationLease { get; init; }
public object Sync { get; } = new();
+ public TaskCompletionSource Completion { get; } = new(
+ TaskCreationOptions.RunContinuationsAsynchronously);
public RenderJobState State { get; set; } = RenderJobState.Running;
public JsonNode? Result { get; set; }
public Exception? Failure { get; set; }
@@ -42,25 +45,53 @@ private sealed class JobRecord
private readonly ConcurrentDictionary _jobs = new();
private readonly SemaphoreSlim _gate = new(1, 1);
- private bool _disposed;
+ private readonly object _lifecycleLock = new();
+ private bool _draining;
+ private Task? _drainTask;
+ private Task? _disposalTask;
- public string Enqueue(string kind, Func> work)
+ ///
+ /// Queues a background render or export and transfers the output-operation lease to the job.
+ ///
+ /// The job kind reported in snapshots.
+ /// The asynchronous work to run under the single-flight gate.
+ ///
+ /// The lease to hold through the terminal path. The caller retains ownership when this method
+ /// throws; after a successful return, the job owns and releases it exactly once.
+ ///
+ /// The generated job identifier.
+ public string Enqueue(
+ string kind,
+ Func> work,
+ IDisposable outputOperationLease)
{
ArgumentException.ThrowIfNullOrWhiteSpace(kind);
ArgumentNullException.ThrowIfNull(work);
- ObjectDisposedException.ThrowIf(_disposed, this);
+ ArgumentNullException.ThrowIfNull(outputOperationLease);
- string jobId = Convert.ToHexString(RandomNumberGenerator.GetBytes(8)).ToLowerInvariant();
- var record = new JobRecord
+ JobRecord record;
+ lock (_lifecycleLock)
{
- JobId = jobId,
- Kind = kind,
- StartedAt = DateTimeOffset.UtcNow,
- Cts = new CancellationTokenSource()
- };
- _jobs[jobId] = record;
+ ObjectDisposedException.ThrowIf(_disposalTask is not null, this);
+ if (_draining)
+ {
+ throw new InvalidOperationException("Render jobs are being drained.");
+ }
+
+ string jobId = Convert.ToHexString(RandomNumberGenerator.GetBytes(8)).ToLowerInvariant();
+ record = new JobRecord
+ {
+ JobId = jobId,
+ Kind = kind,
+ StartedAt = DateTimeOffset.UtcNow,
+ Cts = new CancellationTokenSource(),
+ OutputOperationLease = outputOperationLease
+ };
+ _jobs[jobId] = record;
+ }
+
_ = RunAsync(record, work);
- return jobId;
+ return record.JobId;
}
public RenderJobSnapshot? Get(string jobId)
@@ -132,48 +163,149 @@ public bool HasRunningJobs
}
}
- private async Task RunAsync(JobRecord record, Func> work)
+ ///
+ /// Cancels and awaits all jobs accepted before this operation began. The manager remains
+ /// usable after the operation completes, which lets a reversible shutdown resume the host.
+ ///
+ ///
+ /// This drain is deliberately non-cancelable: accepted jobs and their output-operation leases
+ /// must reach a terminal state. Cancellation-callback failures are reported as an
+ /// only after every accepted job has drained. The manager is
+ /// reusable after either a successful or faulted drain.
+ ///
+ internal Task CancelAndDrainAsync()
{
- bool acquired = false;
- try
+ JobRecord[] jobs;
+ TaskCompletionSource completion;
+ lock (_lifecycleLock)
{
- await _gate.WaitAsync(record.Cts.Token).ConfigureAwait(false);
- acquired = true;
- JsonNode result = await work(record.Cts.Token).ConfigureAwait(false);
- lock (record.Sync)
+ ObjectDisposedException.ThrowIf(_disposalTask is not null, this);
+ if (_drainTask is not null)
{
- record.Result = result;
- record.State = RenderJobState.Completed;
+ return _drainTask;
}
+
+ _draining = true;
+ jobs = [.. _jobs.Values];
+ completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ _drainTask = completion.Task;
}
- catch (OperationCanceledException)
+
+ _ = CompleteCancelAndDrainAsync(jobs, completion);
+ return completion.Task;
+ }
+
+ private async Task CompleteCancelAndDrainAsync(
+ JobRecord[] jobs,
+ TaskCompletionSource completion)
+ {
+ List failures = [];
+ Exception? failure = null;
+ try
{
- lock (record.Sync)
+ foreach (JobRecord record in jobs)
+ {
+ try
+ {
+ record.Cts.Cancel();
+ }
+ catch (ObjectDisposedException)
+ {
+ }
+ catch (AggregateException ex)
+ {
+ failures.AddRange(ex.Flatten().InnerExceptions);
+ }
+ catch (Exception ex)
+ {
+ failures.Add(ex);
+ }
+ }
+
+ await Task.WhenAll(jobs.Select(static record => record.Completion.Task)).ConfigureAwait(false);
+ if (failures.Count > 0)
{
- record.State = RenderJobState.Cancelled;
+ failure = new AggregateException(
+ "One or more render jobs failed while cancellation callbacks ran.",
+ failures);
}
}
catch (Exception ex)
{
- lock (record.Sync)
+ failure = ex;
+ }
+
+ lock (_lifecycleLock)
+ {
+ if (ReferenceEquals(_drainTask, completion.Task))
{
- record.Failure = ex;
- record.State = RenderJobState.Failed;
+ _draining = false;
+ _drainTask = null;
}
}
+
+ if (failure is null)
+ {
+ completion.TrySetResult();
+ }
+ else
+ {
+ completion.TrySetException(failure);
+ }
+ }
+
+ private async Task RunAsync(JobRecord record, Func> work)
+ {
+ bool acquired = false;
+ RenderJobState terminalState = RenderJobState.Running;
+ JsonNode? result = null;
+ Exception? failure = null;
+ try
+ {
+ await _gate.WaitAsync(record.Cts.Token).ConfigureAwait(false);
+ acquired = true;
+ result = await work(record.Cts.Token).ConfigureAwait(false);
+ terminalState = RenderJobState.Completed;
+ }
+ catch (OperationCanceledException)
+ {
+ terminalState = RenderJobState.Cancelled;
+ }
+ catch (Exception ex)
+ {
+ failure = ex;
+ terminalState = RenderJobState.Failed;
+ }
finally
{
- lock (record.Sync)
+ try
{
- record.CompletedAt = DateTimeOffset.UtcNow;
+ record.OutputOperationLease.Dispose();
+ }
+ catch (Exception ex)
+ {
+ failure ??= ex;
+ terminalState = RenderJobState.Failed;
+ }
+ finally
+ {
+ if (acquired)
+ {
+ _gate.Release();
+ }
+
+ record.Cts.Dispose();
}
- if (acquired)
+ lock (record.Sync)
{
- _gate.Release();
+ record.Result = terminalState == RenderJobState.Completed ? result : null;
+ record.Failure = failure;
+ record.State = terminalState;
+ record.CompletedAt = DateTimeOffset.UtcNow;
}
- record.Cts.Dispose();
+ record.Completion.TrySetResult();
}
}
@@ -189,15 +321,39 @@ private static string StateToString(RenderJobState state)
};
}
- public void Dispose()
+ public ValueTask DisposeAsync()
{
- if (_disposed)
+ TaskCompletionSource completion;
+ JobRecord[] jobs;
+ Task? activeDrain;
+ Task disposalTask;
+
+ lock (_lifecycleLock)
{
- return;
+ if (_disposalTask is not null)
+ {
+ return new ValueTask(_disposalTask);
+ }
+
+ completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ disposalTask = completion.Task;
+ _disposalTask = disposalTask;
+ jobs = [.. _jobs.Values];
+ activeDrain = _drainTask;
+ _draining = true;
}
- _disposed = true;
- foreach (JobRecord record in _jobs.Values)
+ _ = CompleteDisposalAsync(jobs, activeDrain, completion);
+ return new ValueTask(disposalTask);
+ }
+
+ private async Task CompleteDisposalAsync(
+ JobRecord[] jobs,
+ Task? activeDrain,
+ TaskCompletionSource completion)
+ {
+ List failures = [];
+ foreach (JobRecord record in jobs)
{
try
{
@@ -206,8 +362,54 @@ public void Dispose()
catch (ObjectDisposedException)
{
}
+ catch (AggregateException ex)
+ {
+ failures.AddRange(ex.Flatten().InnerExceptions);
+ }
+ catch (Exception ex)
+ {
+ failures.Add(ex);
+ }
+ }
+
+ try
+ {
+ if (activeDrain is not null)
+ {
+ await activeDrain.ConfigureAwait(false);
+ }
+ else
+ {
+ await Task.WhenAll(jobs.Select(static record => record.Completion.Task)).ConfigureAwait(false);
+ }
+ }
+ catch (AggregateException ex)
+ {
+ failures.AddRange(ex.Flatten().InnerExceptions);
+ }
+ catch (Exception ex)
+ {
+ failures.Add(ex);
}
- _gate.Dispose();
+ try
+ {
+ _gate.Dispose();
+ }
+ catch (Exception ex)
+ {
+ failures.Add(ex);
+ }
+
+ if (failures.Count == 0)
+ {
+ completion.TrySetResult();
+ }
+ else
+ {
+ completion.TrySetException(new AggregateException(
+ "One or more render jobs failed while the manager was draining.",
+ failures));
+ }
}
}
diff --git a/src/Beutl.AgentToolkit/Tools/RenderTools.cs b/src/Beutl.AgentToolkit/Tools/RenderTools.cs
index fb73f24254..8ab0f36491 100644
--- a/src/Beutl.AgentToolkit/Tools/RenderTools.cs
+++ b/src/Beutl.AgentToolkit/Tools/RenderTools.cs
@@ -9,6 +9,7 @@
using Beutl.AgentToolkit.Rendering;
using Beutl.AgentToolkit.Sessions;
using Beutl.AgentToolkit.Workspace;
+using Beutl.Extensibility;
using Beutl.Extensions.FFmpeg;
using Beutl.Graphics;
using Beutl.Graphics.Rendering;
@@ -42,7 +43,8 @@ public sealed class RenderTools(
AudioRhythmAnalyzer audioRhythmAnalyzer,
QualityAnalyzer qualityAnalyzer,
VideoExporter videoExporter,
- RenderJobManager renderJobs) : ToolBase
+ RenderJobManager renderJobs,
+ IOutputOperationLeaseProvider outputOperations) : ToolBase
{
private static readonly JsonSerializerOptions s_jobResultOptions = new(JsonSerializerDefaults.Web);
private static readonly JsonSerializerOptions s_toolResultOptions = new(JsonSerializerDefaults.Web);
@@ -72,6 +74,7 @@ public ValueTask RenderStill(
{
return ExecuteMcpAsync(async () =>
{
+ using OwnedOutputOperation outputOperation = BeginOutputOperation();
Scene scene = RequireSceneSnapshot();
renderScale = ValidateRenderScale(scene, renderScale, "render_still");
string resolvedPath = workspace.ResolveForWrite(NormalizeOutputPath(outputPath));
@@ -116,6 +119,7 @@ public ValueTask RenderStoryboard(
{
return ExecuteMcpAsync(async () =>
{
+ using OwnedOutputOperation outputOperation = BeginOutputOperation();
if (background && returnImageContent)
{
throw new ReconcileException(new ToolError(
@@ -220,11 +224,12 @@ async Task RunStoryboardAsync(CancellationToken token)
if (background)
{
- string jobId = renderJobs.Enqueue(
+ string jobId = outputOperation.Transfer(lease => renderJobs.Enqueue(
"storyboard",
async token => JsonSerializer.SerializeToNode(
await RunStoryboardAsync(token).ConfigureAwait(false),
- s_jobResultOptions)!);
+ s_jobResultOptions)!,
+ lease));
return (new RenderStoryboardResult("running", jobId, null), (ImageContentBlock?)null);
}
@@ -270,6 +275,7 @@ public ValueTask> EvaluateMotionVariation(
{
return ExecuteAsync(async () =>
{
+ using OwnedOutputOperation outputOperation = BeginOutputOperation();
Scene scene = RequireSceneSnapshot();
renderScale = ValidateRenderScale(scene, renderScale, "evaluate_motion_variation");
IReadOnlyList sampleTimes = ResolveSampleTimes(scene, timeSeconds, sampleCount);
@@ -370,6 +376,7 @@ public ValueTask> EvaluateEditQuality(
{
return ExecuteAsync(async () =>
{
+ using OwnedOutputOperation? outputOperation = staticLayout ? null : BeginOutputOperation();
ValidateVideoType(videoType);
IEditingSession snapshotSession = sessions.RequireSession();
Scene scene = CreateSceneSnapshot(snapshotSession);
@@ -529,6 +536,9 @@ public ValueTask> SuggestQualityFixes(
{
return ExecuteAsync(async () =>
{
+ using OwnedOutputOperation? outputOperation = includeMotion
+ ? BeginOutputOperation()
+ : null;
ValidateVideoType(videoType);
Scene scene = RequireSceneSnapshot();
renderScale = ValidateRenderScale(scene, renderScale, "suggest_quality_fixes");
@@ -617,6 +627,7 @@ public ValueTask> FinalPreflight(
{
return ExecuteAsync(async () =>
{
+ using OwnedOutputOperation outputOperation = BeginOutputOperation();
ValidateVideoType(videoType);
IEditingSession snapshotSession = sessions.RequireSession();
Scene scene = CreateSceneSnapshot(snapshotSession);
@@ -755,6 +766,7 @@ public ValueTask CompareRevisions(
{
return ExecuteMcpManyAsync(async () =>
{
+ using OwnedOutputOperation outputOperation = BeginOutputOperation();
IEditingSession snapshotSession = sessions.RequireSession();
Scene scene = CreateSceneSnapshot(snapshotSession);
// Capture the session key first, then fetch the baseline FOR that key: a session switch
@@ -841,6 +853,7 @@ public ValueTask> ExportVideo(
{
return ExecuteAsync(async () =>
{
+ using OwnedOutputOperation outputOperation = BeginOutputOperation();
Scene scene = RequireSceneSnapshot();
renderScale = ValidateRenderScale(scene, renderScale, "export_video");
@@ -907,11 +920,12 @@ async Task RunExportAsync(CancellationToken token)
if (background)
{
- string jobId = renderJobs.Enqueue(
+ string jobId = outputOperation.Transfer(lease => renderJobs.Enqueue(
"export",
async token => JsonSerializer.SerializeToNode(
await RunExportAsync(token).ConfigureAwait(false),
- s_jobResultOptions)!);
+ s_jobResultOptions)!,
+ lease));
return new ExportVideoResult("running", jobId, null);
}
@@ -1260,6 +1274,13 @@ private async ValueTask> RenderBaselineStillsAsync(
return stillPaths;
}
+ private OwnedOutputOperation BeginOutputOperation()
+ {
+ IDisposable lease = outputOperations.TryBeginOutputOperation()
+ ?? throw new OutputOperationBusyException();
+ return new OwnedOutputOperation(lease);
+ }
+
private void StoreQualityBaseline(
string sessionKey,
IReadOnlyList sampleTimes,
@@ -1638,6 +1659,26 @@ private static CutEyeTrace[] BuildCutEyeTrace(IReadOnlyList(Func transfer)
+ {
+ ArgumentNullException.ThrowIfNull(transfer);
+ IDisposable current = _lease
+ ?? throw new InvalidOperationException("The output operation lease was already transferred.");
+ T result = transfer(current);
+ _lease = null;
+ return result;
+ }
+
+ public void Dispose()
+ {
+ Interlocked.Exchange(ref _lease, null)?.Dispose();
+ }
+ }
+
private static IReadOnlyList ResolveStoryboardShots(
Scene scene,
StoryboardShotInput[]? shots)
diff --git a/src/Beutl.Configuration/GlobalConfiguration.cs b/src/Beutl.Configuration/GlobalConfiguration.cs
index dea87d9319..eeb42242fc 100644
--- a/src/Beutl.Configuration/GlobalConfiguration.cs
+++ b/src/Beutl.Configuration/GlobalConfiguration.cs
@@ -45,6 +45,8 @@ private GlobalConfiguration()
public ProxyStoreConfig ProxyStoreConfig { get; } = new();
+ public VersionControlConfig VersionControlConfig { get; } = new();
+
[AllowNull]
public string LastStartedVersion { get; private set; } = BeutlApplication.Version;
@@ -85,6 +87,8 @@ public void Save(string file)
json["ProxyStore"] = CoreSerializer.SerializeToJsonObject(ProxyStoreConfig);
+ json["VersionControl"] = CoreSerializer.SerializeToJsonObject(VersionControlConfig);
+
json.JsonSave(file);
}
finally
@@ -144,6 +148,9 @@ static void Deserialize(ICoreSerializable serializable, JsonObject obj)
if (json["ProxyStore"] is JsonObject proxyStore)
Deserialize(ProxyStoreConfig, proxyStore);
+ if (json["VersionControl"] is JsonObject versionControl)
+ Deserialize(VersionControlConfig, versionControl);
+
if (json["Version"] is JsonValue version
&& version.TryGetValue(out string? versionString))
{
@@ -169,6 +176,7 @@ private void AddHandlers()
TutorialConfig.ConfigurationChanged += OnConfigurationChanged;
AiAgentConfig.ConfigurationChanged += OnConfigurationChanged;
ProxyStoreConfig.ConfigurationChanged += OnConfigurationChanged;
+ VersionControlConfig.ConfigurationChanged += OnConfigurationChanged;
}
private void RemoveHandlers()
@@ -183,6 +191,7 @@ private void RemoveHandlers()
TutorialConfig.ConfigurationChanged -= OnConfigurationChanged;
AiAgentConfig.ConfigurationChanged -= OnConfigurationChanged;
ProxyStoreConfig.ConfigurationChanged -= OnConfigurationChanged;
+ VersionControlConfig.ConfigurationChanged -= OnConfigurationChanged;
}
private void OnConfigurationChanged(object? sender, EventArgs e)
diff --git a/src/Beutl.Configuration/VersionControlConfig.cs b/src/Beutl.Configuration/VersionControlConfig.cs
new file mode 100644
index 0000000000..52b34cd2a7
--- /dev/null
+++ b/src/Beutl.Configuration/VersionControlConfig.cs
@@ -0,0 +1,86 @@
+using System.ComponentModel;
+
+namespace Beutl.Configuration;
+
+public sealed class VersionControlConfig : ConfigurationBase
+{
+ public static readonly CoreProperty EnableForNewProjectsProperty;
+ public static readonly CoreProperty AutoCommitOnSaveProperty;
+ public static readonly CoreProperty AutoCommitOnCloseProperty;
+ public static readonly CoreProperty GitExecutablePathProperty;
+ public static readonly CoreProperty UseLfsWhenAvailableProperty;
+ public static readonly CoreProperty LargeMediaWarningThresholdMbProperty;
+
+ static VersionControlConfig()
+ {
+ EnableForNewProjectsProperty = ConfigureProperty(nameof(EnableForNewProjects))
+ .DefaultValue(true)
+ .Register();
+
+ AutoCommitOnSaveProperty = ConfigureProperty(nameof(AutoCommitOnSave))
+ .DefaultValue(true)
+ .Register();
+
+ AutoCommitOnCloseProperty = ConfigureProperty(nameof(AutoCommitOnClose))
+ .DefaultValue(true)
+ .Register();
+
+ GitExecutablePathProperty = ConfigureProperty(nameof(GitExecutablePath))
+ .DefaultValue(null)
+ .Register();
+
+ UseLfsWhenAvailableProperty = ConfigureProperty(nameof(UseLfsWhenAvailable))
+ .DefaultValue(true)
+ .Register();
+
+ LargeMediaWarningThresholdMbProperty
+ = ConfigureProperty(nameof(LargeMediaWarningThresholdMb))
+ .DefaultValue(50)
+ .Register();
+ }
+
+ public bool EnableForNewProjects
+ {
+ get => GetValue(EnableForNewProjectsProperty);
+ set => SetValue(EnableForNewProjectsProperty, value);
+ }
+
+ public bool AutoCommitOnSave
+ {
+ get => GetValue(AutoCommitOnSaveProperty);
+ set => SetValue(AutoCommitOnSaveProperty, value);
+ }
+
+ public bool AutoCommitOnClose
+ {
+ get => GetValue(AutoCommitOnCloseProperty);
+ set => SetValue(AutoCommitOnCloseProperty, value);
+ }
+
+ public string? GitExecutablePath
+ {
+ get => GetValue(GitExecutablePathProperty);
+ set => SetValue(GitExecutablePathProperty, value);
+ }
+
+ public bool UseLfsWhenAvailable
+ {
+ get => GetValue(UseLfsWhenAvailableProperty);
+ set => SetValue(UseLfsWhenAvailableProperty, value);
+ }
+
+ public int LargeMediaWarningThresholdMb
+ {
+ get => GetValue(LargeMediaWarningThresholdMbProperty);
+ set => SetValue(LargeMediaWarningThresholdMbProperty, value);
+ }
+
+ protected override void OnPropertyChanged(PropertyChangedEventArgs args)
+ {
+ base.OnPropertyChanged(args);
+ if (args.PropertyName is not (nameof(Id) or nameof(Name)))
+ {
+ OnChanged();
+ }
+ }
+}
diff --git a/src/Beutl.Controls/Styles.axaml b/src/Beutl.Controls/Styles.axaml
index c73648fa23..450e9d8f8c 100644
--- a/src/Beutl.Controls/Styles.axaml
+++ b/src/Beutl.Controls/Styles.axaml
@@ -85,6 +85,22 @@
1501,1,1,1
+
+
+
+
+
+
+
+
+
+
+
+ M2.5009 1.99669C2.49885 2 2.50098 13 2.50074 12.9968C2.5 13.25 2.75 13.5 3.00036 13.4966C3 13.5005 14 13.5 14.005 13.4968C14.2549 13.5007 14.5021 13.2467 14.5011 12.9967C14.5 12.7467 14.2566 12.5007 14.0008 12.4969C14 12.5 4.2 12.4969 4.2 12.4969L12.5007 4.2C12.5007 4.2 12.4968 8 12.5007 8C12.5047 8.26172 12.75 8.5 13.0007 8.4967C13.25 8.50061 13.501 8.2465 13.501 7.99671C13.501 8 13.5042 3 13.501 2.99669C13.4978 2.75 13.252 2.49614 13.0009 2.49695C13.0009 2.49695 8 2.5 7.99683 2.5C7.75 2.5 7.50074 2.74751 7.50074 2.99669C7.50074 3.24586 7.75 3.49669 7.99683 3.49669C8 3.49669 11.8 3.49669 11.8 3.49669L3.50085 11.8C3.50085 11.8 3.49683 2 3.50073 1.99669C3.50463 1.75 3.2507 1.5 3.00075 1.5C2.75079 1.5 2.50325 1.75 2.5009 1.99669ZM8.00001 2C4.6863 2 2 4.6863 2 8.00001C2 10.0518 3.03952 11.91 4.68207 13.0006L3.5 13C3.22386 13 3 13.2239 3 13.5C3 13.7532 3.1881 13.9624 3.43216 13.9955L3.5 14H6.16667C6.41981 14 6.629 13.8119 6.66211 13.5679L6.66667 13.5V10.8333C6.66667 10.5572 6.44281 10.3333 6.16667 10.3333C5.91354 10.3333 5.70435 10.5215 5.67124 10.7655L5.66667 10.8333L5.66603 12.4233C4.05062 11.5705 3 9.88314 3 8.00001C3 5.23858 5.23858 3 8.00001 3C10.7614 3 13 5.23858 13 8.00001C13 8.27614 13.2239 8.50001 13.5 8.50001C13.7762 8.50001 14 8.27614 14 8.00001C14 4.6863 11.3137 2 8.00001 2ZM13.0007 7.5C12.9968 7.5 13.0007 3.7 13.0007 3.7L9.32087 7.38348C9.23404 7.45621 9.12213 7.5 9 7.5C8.72386 7.5 8.5 7.27614 8.5 7C8.5 6.86779 8.55131 6.74757 8.63511 6.65816L12.3 2.99669L8.49683 2.99669C8.25 2.99669 8.00074 2.74586 8.00074 2.49669C8.00074 2.24751 8.25 2 8.49683 2L13.5009 1.99695C13.752 1.99614 13.9978 2.25 14.001 2.49669C14.0042 2.5 14.001 7.5 14.001 7.49671C14.001 7.7465 13.75 8.00061 13.5007 7.9967C13.25 8 13.0047 7.76172 13.0007 7.5Z M3.00165 8.49695C3.00556 8.49695 3.00166 12.2969 3.00166 12.2969L6.68153 8.61347C6.76836 8.54074 6.88026 8.49695 7.0024 8.49695C7.27854 8.49695 7.5024 8.72081 7.5024 8.99695C7.5024 9.12916 7.45108 9.24938 7.36729 9.33879L3.70239 13.0003L7.50556 13.0003C7.75239 13.0003 8.00165 13.2511 8.00165 13.5003C8.00165 13.7494 7.75239 13.997 7.50556 13.997L2.50148 14C2.25044 14.0008 2.0046 13.7469 2.00141 13.5003C1.99823 13.4969 2.00143 8.49695 2.00143 8.50024C2.00143 8.25044 2.2524 7.99634 2.50165 8.00025C2.7524 7.99695 2.99775 8.23523 3.00165 8.49695Z
diff --git a/src/Beutl.Core/Beutl.Core.csproj b/src/Beutl.Core/Beutl.Core.csproj
index 6869223ec3..e6cc129eb9 100644
--- a/src/Beutl.Core/Beutl.Core.csproj
+++ b/src/Beutl.Core/Beutl.Core.csproj
@@ -14,6 +14,7 @@
+
diff --git a/src/Beutl.Core/CoreObject.cs b/src/Beutl.Core/CoreObject.cs
index 3818b1637b..9c5ef2967a 100644
--- a/src/Beutl.Core/CoreObject.cs
+++ b/src/Beutl.Core/CoreObject.cs
@@ -74,6 +74,8 @@ public string Name
public Uri? Uri { get; set; }
+ internal bool WasTypeDiscriminatorAddedDuringRestore { get; set; }
+
private Dictionary Values => _values ??= [];
private Dictionary Errors => _errors ??= [];
diff --git a/src/Beutl.Core/JsonHelper.cs b/src/Beutl.Core/JsonHelper.cs
index 1a259cdbfe..c5ab12c288 100644
--- a/src/Beutl.Core/JsonHelper.cs
+++ b/src/Beutl.Core/JsonHelper.cs
@@ -42,11 +42,13 @@ private static ILogger Logger
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
Indented = true,
+ NewLine = "\n",
};
public static JsonSerializerOptions SerializerOptions { get; } = new()
{
WriteIndented = true,
+ NewLine = "\n",
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
TypeInfoResolver = null,
NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals | JsonNumberHandling.AllowReadingFromString,
diff --git a/src/Beutl.Core/Project.cs b/src/Beutl.Core/Project.cs
index 85a35dd056..0037004e50 100644
--- a/src/Beutl.Core/Project.cs
+++ b/src/Beutl.Core/Project.cs
@@ -1,6 +1,7 @@
using System.Diagnostics;
using Beutl.Collections;
using Beutl.Serialization;
+using NuGet.Versioning;
namespace Beutl;
@@ -65,9 +66,23 @@ public override void Deserialize(ICoreSerializationContext context)
using Activity? activity = BeutlApplication.ActivitySource.StartActivity("Project.Deserialize");
base.Deserialize(context);
+ if (context.GetValue("appVersion") is { } appVersion)
+ {
+ AppVersion = appVersion;
+ }
+
+ if (context.GetValue("minAppVersion") is { } minAppVersion)
+ {
+ MinAppVersion = minAppVersion;
+ }
+
if (context.GetValue("items") is { } items)
{
Items.Replace(items);
+ if (items.Any(static item => item.HasMigratedPersistedContent))
+ {
+ MarkAsMigrated();
+ }
}
if (context.GetValue>("variables") is { } vars)
@@ -79,22 +94,41 @@ public override void Deserialize(ICoreSerializationContext context)
}
}
- activity?.SetTag("appVersion", BeutlApplication.Version);
- activity?.SetTag("minAppVersion", DefaultMinAppVersion);
+ activity?.SetTag("appVersion", AppVersion);
+ activity?.SetTag("minAppVersion", MinAppVersion);
activity?.SetTag("itemsCount", Items.Count);
}
+ // Call only after a migration has rewritten persisted content. Project-item migrations,
+ // including extension-provided item types, are aggregated during deserialization; a plain
+ // load/save keeps the version from disk.
+ internal void MarkAsMigrated()
+ {
+ AppVersion = BeutlApplication.Version;
+ MinAppVersion = GetMaximumVersion(MinAppVersion, DefaultMinAppVersion);
+ }
+
+ private static string GetMaximumVersion(string persistedVersion, string requiredVersion)
+ {
+ // An unknown persisted constraint is retained so migration cannot weaken it.
+ return NuGetVersion.TryParse(persistedVersion, out NuGetVersion? persisted)
+ && NuGetVersion.TryParse(requiredVersion, out NuGetVersion? required)
+ && VersionComparer.VersionRelease.Compare(persisted, required) < 0
+ ? requiredVersion
+ : persistedVersion;
+ }
+
public override void Serialize(ICoreSerializationContext context)
{
using Activity? activity = BeutlApplication.ActivitySource.StartActivity("Project.Serialize");
- activity?.SetTag("appVersion", BeutlApplication.Version);
- activity?.SetTag("minAppVersion", DefaultMinAppVersion);
+ activity?.SetTag("appVersion", AppVersion);
+ activity?.SetTag("minAppVersion", MinAppVersion);
activity?.SetTag("itemsCount", Items.Count);
base.Serialize(context);
- context.SetValue("appVersion", BeutlApplication.Version);
- context.SetValue("minAppVersion", DefaultMinAppVersion);
+ context.SetValue("appVersion", AppVersion);
+ context.SetValue("minAppVersion", MinAppVersion);
context.SetValue("items", Items);
diff --git a/src/Beutl.Core/ProjectItem.cs b/src/Beutl.Core/ProjectItem.cs
index 6de5bb41f7..9c7a505a41 100644
--- a/src/Beutl.Core/ProjectItem.cs
+++ b/src/Beutl.Core/ProjectItem.cs
@@ -2,4 +2,12 @@
public abstract class ProjectItem : Hierarchical
{
+ ///
+ /// Gets whether deserializing this item migrated persisted content to the current format.
+ ///
+ ///
+ /// Override this in project-item types that rewrite legacy data during deserialization so the
+ /// containing project can update its application-version requirements.
+ ///
+ protected internal virtual bool HasMigratedPersistedContent => false;
}
diff --git a/src/Beutl.Core/Serialization/CoreSerializer.cs b/src/Beutl.Core/Serialization/CoreSerializer.cs
index 961f3c66af..e68acbded5 100644
--- a/src/Beutl.Core/Serialization/CoreSerializer.cs
+++ b/src/Beutl.Core/Serialization/CoreSerializer.cs
@@ -158,15 +158,18 @@ public static object RestoreFromUri(Uri uri, Type type)
// 互換性処理
// 1.x で作成されたファイルでは一部のオブジェクトに $type が付与されないため、
// 期待される型に基づいてディスクリミネータを補完する。
+ bool addedTypeDiscriminator = false;
if (!node.TryGetDiscriminator(out Type? _))
{
if (type == typeof(ProjectItem))
{
node["$type"] = LegacyTypeNames.SceneDiscriminator;
+ addedTypeDiscriminator = true;
}
else if (type.FullName == LegacyTypeNames.ElementFullName)
{
node["$type"] = LegacyTypeNames.ElementDiscriminator;
+ addedTypeDiscriminator = true;
}
}
@@ -184,6 +187,7 @@ public static object RestoreFromUri(Uri uri, Type type)
if (obj is CoreObject coreObj)
{
coreObj.Uri = uri;
+ coreObj.WasTypeDiscriminatorAddedDuringRestore = addedTypeDiscriminator;
}
var options = new CoreSerializerOptions { BaseUri = uri, Mode = CoreSerializationMode.Read };
diff --git a/src/Beutl.Editor.Components/FileBrowserTab/Services/DirectoryWatcherService.cs b/src/Beutl.Editor.Components/FileBrowserTab/Services/DirectoryWatcherService.cs
index b29c99483b..f83611536c 100644
--- a/src/Beutl.Editor.Components/FileBrowserTab/Services/DirectoryWatcherService.cs
+++ b/src/Beutl.Editor.Components/FileBrowserTab/Services/DirectoryWatcherService.cs
@@ -127,8 +127,13 @@ internal bool TryRearmAfterError()
}
// プロジェクト、シーン、要素のファイルは頻繁に変更されるため除外
- private bool ShouldExcludePath(string path)
+ internal bool ShouldExcludePath(string path)
{
+ if (HasGitMetadataSegment(path))
+ {
+ return true;
+ }
+
// templatesディレクトリは例外
if (PathScope.IsUnderDirectory(path, BeutlEnvironment.GetTemplatesDirectoryPath()))
{
@@ -147,6 +152,33 @@ private bool ShouldExcludePath(string path)
path.Contains(".beutl");
}
+ private static bool HasGitMetadataSegment(string path)
+ {
+ ReadOnlySpan remaining = path;
+ while (!remaining.IsEmpty)
+ {
+ int separator = remaining.IndexOfAny(
+ Path.DirectorySeparatorChar,
+ Path.AltDirectorySeparatorChar);
+ ReadOnlySpan segment = separator >= 0
+ ? remaining[..separator]
+ : remaining;
+ if (segment.Equals(".git", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ if (separator < 0)
+ {
+ break;
+ }
+
+ remaining = remaining[(separator + 1)..];
+ }
+
+ return false;
+ }
+
private void OnFileSystemEvent(object sender, FileSystemEventArgs e)
{
if (ShouldExcludePath(e.FullPath))
diff --git a/src/Beutl.Editor.Components/Properties/AssemblyInfo.cs b/src/Beutl.Editor.Components/Properties/AssemblyInfo.cs
index 98c8d84d0c..283880fa1a 100644
--- a/src/Beutl.Editor.Components/Properties/AssemblyInfo.cs
+++ b/src/Beutl.Editor.Components/Properties/AssemblyInfo.cs
@@ -1,4 +1,5 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Beutl")]
+[assembly: InternalsVisibleTo("Beutl.HeadlessUITests")]
[assembly: InternalsVisibleTo("Beutl.UnitTests")]
diff --git a/src/Beutl.Editor.Components/VersionControl/ViewModels/TitleBarBranchViewModel.cs b/src/Beutl.Editor.Components/VersionControl/ViewModels/TitleBarBranchViewModel.cs
new file mode 100644
index 0000000000..fac658f4e6
--- /dev/null
+++ b/src/Beutl.Editor.Components/VersionControl/ViewModels/TitleBarBranchViewModel.cs
@@ -0,0 +1,636 @@
+using System.Collections.ObjectModel;
+using System.Globalization;
+using System.Reactive.Disposables;
+using Avalonia.Threading;
+using Beutl.Editor.VersionControl;
+using Reactive.Bindings;
+using Reactive.Bindings.Extensions;
+
+namespace Beutl.Editor.Components.VersionControl.ViewModels;
+
+internal sealed class TitleBarBranchViewModel : IDisposable
+{
+ private readonly IProjectVersionControlCoordinator _coordinator;
+ private readonly Action _postToUi;
+ private readonly CompositeDisposable _disposables = [];
+ private readonly CancellationTokenSource _lifetimeCancellation = new();
+ private readonly ObservableCollection _branches = [];
+ private IProjectVersionControlService? _service;
+ private CancellationTokenSource? _serviceBindingCancellation;
+ private int _serviceRevision;
+ private int _statusRevision;
+ private bool _gitAvailable;
+ private bool _coordinatorGitAvailable;
+ private bool _disposed;
+
+ internal TitleBarBranchViewModel(
+ IReadOnlyReactiveProperty serviceSource,
+ IReadOnlyReactiveProperty gitAvailabilitySource,
+ IProjectVersionControlCoordinator coordinator)
+ : this(
+ serviceSource,
+ gitAvailabilitySource,
+ coordinator,
+ PostToUiThread)
+ {
+ }
+
+ internal TitleBarBranchViewModel(
+ IReadOnlyReactiveProperty serviceSource,
+ IReadOnlyReactiveProperty gitAvailabilitySource,
+ IProjectVersionControlCoordinator coordinator,
+ Action postToUi)
+ {
+ ArgumentNullException.ThrowIfNull(serviceSource);
+ ArgumentNullException.ThrowIfNull(gitAvailabilitySource);
+ _coordinator = coordinator ?? throw new ArgumentNullException(nameof(coordinator));
+ _postToUi = postToUi ?? throw new ArgumentNullException(nameof(postToUi));
+ _coordinatorGitAvailable = gitAvailabilitySource.Value;
+
+ Branches =
+ new ReadOnlyObservableCollection(
+ _branches);
+ IsVisible = new ReactivePropertySlim()
+ .DisposeWith(_disposables);
+ IsBusy = new ReactivePropertySlim()
+ .DisposeWith(_disposables);
+ DisplayText = new ReactivePropertySlim()
+ .DisposeWith(_disposables);
+ CurrentBranchName = new ReactivePropertySlim()
+ .DisposeWith(_disposables);
+ AheadBehindText = new ReactivePropertySlim()
+ .DisposeWith(_disposables);
+ AheadCount = new ReactivePropertySlim()
+ .DisposeWith(_disposables);
+ BehindCount = new ReactivePropertySlim()
+ .DisposeWith(_disposables);
+ HasAhead = new ReactivePropertySlim()
+ .DisposeWith(_disposables);
+ HasBehind = new ReactivePropertySlim()
+ .DisposeWith(_disposables);
+ CreateBranchCommand = new AsyncReactiveCommand(
+ IsVisible.CombineLatest(
+ IsBusy,
+ static (visible, busy) => visible && !busy))
+ .WithSubscribe(CreateBranchAsync)
+ .DisposeWith(_disposables);
+ RequestNewBranchNameAsync = static () => Task.FromResult(null);
+
+ Initialization = RebindServiceAsync(serviceSource.Value);
+ serviceSource
+ .Subscribe(OnServicePublished)
+ .DisposeWith(_disposables);
+ gitAvailabilitySource
+ .Subscribe(OnGitAvailabilityPublished)
+ .DisposeWith(_disposables);
+ }
+
+ internal ReadOnlyObservableCollection Branches { get; }
+
+ internal ReactivePropertySlim IsVisible { get; }
+
+ internal ReactivePropertySlim IsBusy { get; }
+
+ internal ReactivePropertySlim DisplayText { get; }
+
+ internal ReactivePropertySlim CurrentBranchName { get; }
+
+ internal ReactivePropertySlim AheadBehindText { get; }
+
+ internal ReactivePropertySlim AheadCount { get; }
+
+ internal ReactivePropertySlim BehindCount { get; }
+
+ internal ReactivePropertySlim HasAhead { get; }
+
+ internal ReactivePropertySlim HasBehind { get; }
+
+ internal AsyncReactiveCommand CreateBranchCommand { get; }
+
+ internal Func> RequestNewBranchNameAsync { get; set; }
+
+ internal Task Initialization { get; private set; }
+
+ internal async Task PrepareFlyoutAsync(
+ CancellationToken cancellationToken = default)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ await RefreshAsync(cancellationToken);
+ }
+
+ internal async Task RefreshAsync(CancellationToken cancellationToken = default)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ IProjectVersionControlService? service = _service;
+ CancellationTokenSource? bindingCancellation =
+ Volatile.Read(ref _serviceBindingCancellation);
+ if (service is null || bindingCancellation is null)
+ {
+ return;
+ }
+
+ CancellationToken bindingToken;
+ try
+ {
+ bindingToken = bindingCancellation.Token;
+ }
+ catch (ObjectDisposedException)
+ {
+ return;
+ }
+
+ if (bindingToken.IsCancellationRequested)
+ {
+ return;
+ }
+
+ CancellationTokenSource? linkedCancellation = TryCreateLinkedCancellation(
+ bindingToken,
+ cancellationToken);
+ if (linkedCancellation is null)
+ {
+ return;
+ }
+
+ using (linkedCancellation)
+ {
+ await RefreshCoreAsync(
+ service,
+ _serviceRevision,
+ linkedCancellation.Token);
+ }
+ }
+
+ internal static CancellationTokenSource? TryCreateLinkedCancellation(
+ CancellationToken bindingToken,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ return CancellationTokenSource.CreateLinkedTokenSource(
+ bindingToken,
+ cancellationToken);
+ }
+ catch (ObjectDisposedException)
+ {
+ return null;
+ }
+ }
+
+ internal async Task SwitchBranchAsync(
+ string branchName,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(branchName);
+ if (_disposed
+ || !IsVisible.Value
+ || IsBusy.Value
+ || _branches.FirstOrDefault(branch =>
+ string.Equals(
+ branch.Name,
+ branchName,
+ StringComparison.Ordinal)) is not { IsCurrent: false })
+ {
+ return;
+ }
+
+ if (!TryGetLifetimeToken(out CancellationToken lifetimeToken))
+ {
+ return;
+ }
+
+ IsBusy.Value = true;
+ using var operationCancellation =
+ CancellationTokenSource.CreateLinkedTokenSource(
+ cancellationToken,
+ lifetimeToken);
+ try
+ {
+ await _coordinator.SwitchBranchAsync(
+ branchName,
+ operationCancellation.Token);
+ await RefreshAsync(operationCancellation.Token);
+ }
+ catch (OperationCanceledException)
+ when (_lifetimeCancellation.IsCancellationRequested)
+ {
+ }
+ finally
+ {
+ if (!_disposed)
+ {
+ IsBusy.Value = false;
+ }
+ }
+ }
+
+ internal async Task CreateBranchAsync()
+ {
+ if (_disposed || !IsVisible.Value || IsBusy.Value)
+ {
+ return;
+ }
+
+ IProjectVersionControlService? service = _service;
+ int revision = _serviceRevision;
+ if (service is null
+ || !IsCurrentServiceBinding(service, revision, CancellationToken.None))
+ {
+ return;
+ }
+
+ if (!TryGetLifetimeToken(out CancellationToken lifetimeToken))
+ {
+ return;
+ }
+
+ string? branchName = await RequestNewBranchNameAsync();
+ if (!IsCurrentServiceBinding(service, revision, lifetimeToken)
+ || string.IsNullOrWhiteSpace(branchName))
+ {
+ return;
+ }
+
+ IsBusy.Value = true;
+ using var operationCancellation =
+ CancellationTokenSource.CreateLinkedTokenSource(
+ lifetimeToken);
+ try
+ {
+ await _coordinator.CreateBranchAsync(
+ branchName.Trim(),
+ operationCancellation.Token);
+ await RefreshAsync(operationCancellation.Token);
+ }
+ catch (OperationCanceledException)
+ when (_lifetimeCancellation.IsCancellationRequested)
+ {
+ }
+ finally
+ {
+ if (!_disposed)
+ {
+ IsBusy.Value = false;
+ }
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ _lifetimeCancellation.Cancel();
+ CancellationTokenSource? bindingCancellation =
+ Interlocked.Exchange(ref _serviceBindingCancellation, null);
+ bindingCancellation?.Cancel();
+ bindingCancellation?.Dispose();
+ DetachService();
+ ClearBranches();
+ _disposables.Dispose();
+ _lifetimeCancellation.Dispose();
+ }
+
+ internal static string FormatDisplayText(
+ string branchName,
+ int ahead,
+ int behind,
+ CultureInfo? culture = null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(branchName);
+ culture ??= CultureInfo.CurrentCulture;
+
+ string result = branchName;
+ if (ahead > 0)
+ {
+ result += $" ↑{ahead.ToString(culture)}";
+ }
+
+ if (behind > 0)
+ {
+ result += $" ↓{behind.ToString(culture)}";
+ }
+
+ return result;
+ }
+
+ private void OnServicePublished(IProjectVersionControlService? service)
+ {
+ _postToUi(() =>
+ {
+ if (!_disposed && !ReferenceEquals(service, _service))
+ {
+ Initialization = RebindServiceAsync(service);
+ }
+ });
+ }
+
+ private async Task RebindServiceAsync(IProjectVersionControlService? service)
+ {
+ int revision = Interlocked.Increment(ref _serviceRevision);
+ var replacementCancellation = new CancellationTokenSource();
+ CancellationTokenSource? previousCancellation =
+ Interlocked.Exchange(
+ ref _serviceBindingCancellation,
+ replacementCancellation);
+ previousCancellation?.Cancel();
+ previousCancellation?.Dispose();
+ CancellationToken cancellationToken = replacementCancellation.Token;
+
+ DetachService();
+ _service = service;
+ ResetState();
+ if (service is null)
+ {
+ return;
+ }
+
+ service.StatusChanged += OnStatusChanged;
+ await RefreshCoreAsync(service, revision, cancellationToken);
+ }
+
+ private async Task RefreshCoreAsync(
+ IProjectVersionControlService service,
+ int revision,
+ CancellationToken cancellationToken)
+ {
+ GitAvailability availability;
+ try
+ {
+ availability = await service.GetAvailabilityAsync(cancellationToken);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+ catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+
+ if (!IsCurrentServiceBinding(service, revision, cancellationToken))
+ {
+ return;
+ }
+
+ if (availability.State != GitAvailabilityState.Installed
+ || service.Repository is null
+ || !_coordinatorGitAvailable)
+ {
+ _postToUi(() =>
+ {
+ if (IsCurrentServiceBinding(service, revision, cancellationToken))
+ {
+ _gitAvailable =
+ availability.State == GitAvailabilityState.Installed;
+ ResetRepositoryState();
+ }
+ });
+ return;
+ }
+
+ WorkspaceStatus status;
+ IReadOnlyList branches;
+ int statusRevision = Volatile.Read(ref _statusRevision);
+ try
+ {
+ status = await service.GetStatusAsync(cancellationToken);
+ branches = await service.GetBranchesAsync(cancellationToken);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+ catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+
+ if (!IsCurrentService(service, revision, cancellationToken)
+ || statusRevision != Volatile.Read(ref _statusRevision))
+ {
+ return;
+ }
+
+ _postToUi(() =>
+ {
+ if (IsCurrentService(service, revision, cancellationToken)
+ && statusRevision == Volatile.Read(ref _statusRevision))
+ {
+ ApplyState(status, branches);
+ }
+ });
+ }
+
+ private bool IsCurrentService(
+ IProjectVersionControlService service,
+ int revision,
+ CancellationToken cancellationToken)
+ {
+ return _coordinatorGitAvailable
+ && IsCurrentServiceBinding(service, revision, cancellationToken);
+ }
+
+ private bool IsCurrentServiceBinding(
+ IProjectVersionControlService service,
+ int revision,
+ CancellationToken cancellationToken)
+ {
+ return !_disposed
+ && !cancellationToken.IsCancellationRequested
+ && revision == _serviceRevision
+ && ReferenceEquals(service, _service);
+ }
+
+ private void ApplyState(
+ WorkspaceStatus status,
+ IReadOnlyList branches)
+ {
+ string branchName = status.Branch
+ ?? branches.FirstOrDefault(branch => branch.IsCurrent)?.Name
+ ?? "—";
+ _gitAvailable = true;
+ IsVisible.Value = true;
+ ApplyBranchSummary(branchName, status.Ahead, status.Behind);
+
+ ClearBranches();
+ foreach (BranchInfo branch in branches)
+ {
+ _branches.Add(new TitleBarBranchItemViewModel(
+ branch,
+ IsBusy));
+ }
+ }
+
+ private void OnStatusChanged(object? sender, WorkspaceStatus status)
+ {
+ if (sender is not IProjectVersionControlService eventService
+ || !ReferenceEquals(eventService, _service))
+ {
+ return;
+ }
+
+ _postToUi(() =>
+ {
+ if (_disposed || !ReferenceEquals(eventService, _service))
+ {
+ return;
+ }
+
+ Interlocked.Increment(ref _statusRevision);
+ string branchName = status.Branch ?? "—";
+ IsVisible.Value =
+ _gitAvailable
+ && _coordinatorGitAvailable
+ && eventService.Repository is not null;
+ ApplyBranchSummary(branchName, status.Ahead, status.Behind);
+ // The event carries no ordering, so one raised before a branch change can arrive after
+ // the refresh that already read the new branch. Applying it above keeps the widget
+ // responsive; re-reading afterwards is what makes the state it settles on the current
+ // one. The read discards itself if a later event supersedes it.
+ _ = RefreshAsync();
+ });
+ }
+
+ private void OnGitAvailabilityPublished(bool available)
+ {
+ _postToUi(() =>
+ {
+ if (_disposed || available == _coordinatorGitAvailable)
+ {
+ return;
+ }
+
+ _coordinatorGitAvailable = available;
+ if (available)
+ {
+ _ = RefreshAsync();
+ }
+ else
+ {
+ ResetRepositoryState();
+ }
+ });
+ }
+
+ private void ResetState()
+ {
+ _gitAvailable = false;
+ ResetRepositoryState();
+ }
+
+ private void ResetRepositoryState()
+ {
+ IsVisible.Value = false;
+ DisplayText.Value = string.Empty;
+ CurrentBranchName.Value = string.Empty;
+ AheadBehindText.Value = string.Empty;
+ AheadCount.Value = 0;
+ BehindCount.Value = 0;
+ HasAhead.Value = false;
+ HasBehind.Value = false;
+ ClearBranches();
+ }
+
+ private void DetachService()
+ {
+ if (_service is not null)
+ {
+ _service.StatusChanged -= OnStatusChanged;
+ _service = null;
+ }
+ }
+
+ private void ClearBranches()
+ {
+ foreach (TitleBarBranchItemViewModel branch in _branches)
+ {
+ branch.Dispose();
+ }
+
+ _branches.Clear();
+ }
+
+ private void ApplyBranchSummary(
+ string branchName,
+ int ahead,
+ int behind)
+ {
+ DisplayText.Value = FormatDisplayText(branchName, ahead, behind);
+ CurrentBranchName.Value = branchName;
+ AheadBehindText.Value = string.Format(
+ CultureInfo.CurrentCulture,
+ Strings.VersionControl_AheadBehindFormat,
+ ahead,
+ behind);
+ AheadCount.Value = ahead;
+ BehindCount.Value = behind;
+ HasAhead.Value = ahead > 0;
+ HasBehind.Value = behind > 0;
+ }
+
+ private bool TryGetLifetimeToken(out CancellationToken cancellationToken)
+ {
+ try
+ {
+ cancellationToken = _lifetimeCancellation.Token;
+ return !_disposed && !cancellationToken.IsCancellationRequested;
+ }
+ catch (ObjectDisposedException)
+ {
+ cancellationToken = default;
+ return false;
+ }
+ }
+
+ private static void PostToUiThread(Action action)
+ {
+ if (Dispatcher.UIThread.CheckAccess())
+ {
+ action();
+ }
+ else
+ {
+ Dispatcher.UIThread.Post(action);
+ }
+ }
+}
+
+internal sealed class TitleBarBranchItemViewModel : IDisposable
+{
+ internal TitleBarBranchItemViewModel(
+ BranchInfo branch,
+ IObservable isBusy)
+ {
+ ArgumentNullException.ThrowIfNull(branch);
+ ArgumentNullException.ThrowIfNull(isBusy);
+
+ Name = branch.Name;
+ IsCurrent = branch.IsCurrent;
+ CanSwitch = isBusy
+ .Select(busy => !IsCurrent && !busy)
+ .ToReadOnlyReactivePropertySlim(!IsCurrent);
+ }
+
+ internal string Name { get; }
+
+ internal bool IsCurrent { get; }
+
+ internal ReadOnlyReactivePropertySlim CanSwitch { get; }
+
+ public void Dispose()
+ {
+ CanSwitch.Dispose();
+ }
+}
diff --git a/src/Beutl.Editor.Components/VersionControl/Views/VersionControlPickerFlyout.cs b/src/Beutl.Editor.Components/VersionControl/Views/VersionControlPickerFlyout.cs
new file mode 100644
index 0000000000..fcd278b96f
--- /dev/null
+++ b/src/Beutl.Editor.Components/VersionControl/Views/VersionControlPickerFlyout.cs
@@ -0,0 +1,330 @@
+using System.ComponentModel;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Threading;
+using FluentAvalonia.UI.Controls;
+using FluentAvalonia.UI.Controls.Primitives;
+
+namespace Beutl.Editor.Components.VersionControl.Views;
+
+internal sealed class VersionControlPickerFlyout : PickerFlyoutBase
+{
+ private sealed record CancellationRequest(
+ VersionControlPickerFlyout Flyout,
+ TaskCompletionSource Completion,
+ CancellationToken CancellationToken);
+
+ private const double PresenterWidth = 320;
+ private const double PresenterHorizontalPadding = 8;
+
+ private readonly StackPanel _contentPanel;
+ private TaskCompletionSource? _completion;
+ private Func? _canConfirm;
+ private bool _confirmOnEnter;
+
+ public VersionControlPickerFlyout()
+ {
+ TitleTextBlock = new TextBlock
+ {
+ FontWeight = FontWeight.SemiBold,
+ TextWrapping = TextWrapping.Wrap,
+ };
+ MessageTextBlock = new TextBlock
+ {
+ TextWrapping = TextWrapping.Wrap,
+ };
+ PrimaryLabelTextBlock = new TextBlock
+ {
+ TextWrapping = TextWrapping.Wrap,
+ };
+ PrimaryTextBox = new TextBox();
+ SecondaryLabelTextBlock = new TextBlock
+ {
+ TextWrapping = TextWrapping.Wrap,
+ };
+ SecondaryTextBox = new TextBox();
+ _contentPanel = new StackPanel
+ {
+ Spacing = 8,
+ Children =
+ {
+ TitleTextBlock,
+ MessageTextBlock,
+ PrimaryLabelTextBlock,
+ PrimaryTextBox,
+ SecondaryLabelTextBlock,
+ SecondaryTextBox,
+ },
+ };
+
+ PrimaryTextBox.KeyDown += OnInputKeyDown;
+ SecondaryTextBox.KeyDown += OnInputKeyDown;
+ Closed += (_, _) => Complete(confirmed: false, hide: false);
+ }
+
+ internal TextBlock TitleTextBlock { get; }
+
+ internal TextBlock MessageTextBlock { get; }
+
+ internal TextBlock PrimaryLabelTextBlock { get; }
+
+ internal TextBox PrimaryTextBox { get; }
+
+ internal TextBlock SecondaryLabelTextBlock { get; }
+
+ internal TextBox SecondaryTextBox { get; }
+
+ internal PickerFlyoutPresenter? Presenter { get; private set; }
+
+ public async Task ShowTextInputAsync(
+ Control anchor,
+ string title,
+ string watermark,
+ string? initialText,
+ CancellationToken cancellationToken = default)
+ {
+ ResetPendingRequest();
+ ConfigureContent(title);
+ PrimaryLabelTextBlock.IsVisible = false;
+ PrimaryTextBox.IsVisible = true;
+ PrimaryTextBox.Watermark = watermark;
+ PrimaryTextBox.Text = initialText;
+ _confirmOnEnter = true;
+
+ bool confirmed = await ShowAsync(
+ anchor,
+ () => !string.IsNullOrWhiteSpace(PrimaryTextBox.Text),
+ cancellationToken);
+ return confirmed ? PrimaryTextBox.Text : null;
+ }
+
+ public Task ShowConfirmationAsync(
+ Control anchor,
+ string title,
+ string message)
+ {
+ ResetPendingRequest();
+ ConfigureContent(title);
+ MessageTextBlock.Text = message;
+ MessageTextBlock.IsVisible = true;
+ _confirmOnEnter = false;
+ return ShowAsync(anchor, static () => true);
+ }
+
+ public async Task ShowIdentityAsync(
+ Control anchor,
+ string title,
+ string nameLabel,
+ string emailLabel,
+ string? initialName,
+ string? initialEmail,
+ CancellationToken cancellationToken)
+ {
+ ResetPendingRequest();
+ ConfigureContent(title);
+ PrimaryLabelTextBlock.Text = nameLabel;
+ PrimaryLabelTextBlock.IsVisible = true;
+ PrimaryTextBox.IsVisible = true;
+ PrimaryTextBox.Text = initialName;
+ SecondaryLabelTextBlock.Text = emailLabel;
+ SecondaryLabelTextBlock.IsVisible = true;
+ SecondaryTextBox.IsVisible = true;
+ SecondaryTextBox.Text = initialEmail;
+ _confirmOnEnter = true;
+
+ bool confirmed = await ShowAsync(
+ anchor,
+ () => !string.IsNullOrWhiteSpace(PrimaryTextBox.Text)
+ && !string.IsNullOrWhiteSpace(SecondaryTextBox.Text),
+ cancellationToken);
+ cancellationToken.ThrowIfCancellationRequested();
+ return confirmed
+ ? new VersionControlIdentityInput(
+ PrimaryTextBox.Text!.Trim(),
+ SecondaryTextBox.Text!.Trim())
+ : null;
+ }
+
+ protected override Control CreatePresenter()
+ {
+ Presenter = new PickerFlyoutPresenter
+ {
+ Width = PresenterWidth,
+ Padding = new(PresenterHorizontalPadding, 4),
+ Content = _contentPanel,
+ };
+ ScrollViewer.SetHorizontalScrollBarVisibility(
+ Presenter,
+ ScrollBarVisibility.Disabled);
+ Presenter.Confirmed += OnPresenterConfirmed;
+ Presenter.Dismissed += OnPresenterDismissed;
+ return Presenter;
+ }
+
+ protected override void OnOpening(CancelEventArgs args)
+ {
+ base.OnOpening(args);
+ Dispatcher.UIThread.Post(() =>
+ {
+ if (PrimaryTextBox.IsVisible)
+ {
+ PrimaryTextBox.Focus();
+ PrimaryTextBox.SelectAll();
+ }
+ });
+ }
+
+ protected override void OnConfirmed()
+ {
+ if (_canConfirm?.Invoke() != true)
+ {
+ return;
+ }
+
+ Complete(confirmed: true, hide: true);
+ }
+
+ protected override bool ShouldShowConfirmationButtons() => true;
+
+ private void ConfigureContent(string title)
+ {
+ TitleTextBlock.Text = title;
+ MessageTextBlock.Text = null;
+ MessageTextBlock.IsVisible = false;
+ PrimaryLabelTextBlock.Text = null;
+ PrimaryLabelTextBlock.IsVisible = false;
+ PrimaryTextBox.Watermark = null;
+ PrimaryTextBox.Text = null;
+ PrimaryTextBox.IsVisible = false;
+ SecondaryLabelTextBlock.Text = null;
+ SecondaryLabelTextBlock.IsVisible = false;
+ SecondaryTextBox.Watermark = null;
+ SecondaryTextBox.Text = null;
+ SecondaryTextBox.IsVisible = false;
+ _confirmOnEnter = false;
+ }
+
+ private async Task ShowAsync(
+ Control anchor,
+ Func canConfirm,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(anchor);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ _canConfirm = canConfirm;
+ var completion = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ _completion = completion;
+ Task task = completion.Task;
+ try
+ {
+ ShowAt(anchor);
+ }
+ catch
+ {
+ Complete(confirmed: false, hide: false);
+ throw;
+ }
+
+ using CancellationTokenRegistration registration = cancellationToken.Register(
+ static state =>
+ {
+ var request = (CancellationRequest)state!;
+ if (Dispatcher.UIThread.CheckAccess())
+ {
+ request.Flyout.CancelPendingRequest(
+ request.Completion,
+ request.CancellationToken);
+ }
+ else
+ {
+ Dispatcher.UIThread.Post(
+ () => request.Flyout.CancelPendingRequest(
+ request.Completion,
+ request.CancellationToken));
+ }
+ },
+ new CancellationRequest(this, completion, cancellationToken));
+ return await task;
+ }
+
+ private void OnInputKeyDown(object? sender, KeyEventArgs e)
+ {
+ if (!_confirmOnEnter
+ || e.Key is not (Key.Enter or Key.Return)
+ || e.KeyModifiers != KeyModifiers.None
+ || _canConfirm?.Invoke() != true)
+ {
+ return;
+ }
+
+ e.Handled = true;
+ OnConfirmed();
+ }
+
+ private void OnPresenterConfirmed(
+ PickerFlyoutPresenter sender,
+ object args)
+ {
+ OnConfirmed();
+ }
+
+ private void OnPresenterDismissed(
+ PickerFlyoutPresenter sender,
+ object args)
+ {
+ Complete(confirmed: false, hide: true);
+ }
+
+ private void ResetPendingRequest()
+ {
+ TaskCompletionSource? completion = _completion;
+ _completion = null;
+ _canConfirm = null;
+ _confirmOnEnter = false;
+ completion?.TrySetResult(false);
+ if (IsOpen)
+ {
+ Hide();
+ }
+ }
+
+ private void CancelPendingRequest(
+ TaskCompletionSource completion,
+ CancellationToken cancellationToken)
+ {
+ if (!ReferenceEquals(_completion, completion))
+ {
+ return;
+ }
+
+ _completion = null;
+ _canConfirm = null;
+ _confirmOnEnter = false;
+ if (IsOpen)
+ {
+ Hide();
+ }
+
+ completion.TrySetCanceled(cancellationToken);
+ }
+
+ private void Complete(bool confirmed, bool hide)
+ {
+ TaskCompletionSource? completion = _completion;
+ _completion = null;
+ _canConfirm = null;
+ _confirmOnEnter = false;
+ completion?.TrySetResult(confirmed);
+ if (hide && IsOpen)
+ {
+ Hide();
+ }
+ }
+}
+
+internal readonly record struct VersionControlIdentityInput(string Name, string Email);
diff --git a/src/Beutl.Editor.Components/VersionControlTab/ViewModels/VersionControlPrimaryAction.cs b/src/Beutl.Editor.Components/VersionControlTab/ViewModels/VersionControlPrimaryAction.cs
new file mode 100644
index 0000000000..b7cc340c7d
--- /dev/null
+++ b/src/Beutl.Editor.Components/VersionControlTab/ViewModels/VersionControlPrimaryAction.cs
@@ -0,0 +1,18 @@
+using System.Windows.Input;
+
+namespace Beutl.Editor.Components.VersionControlTab.ViewModels;
+
+internal enum VersionControlPrimaryActionKind
+{
+ Commit,
+ Pull,
+ Push,
+ UpToDate,
+ PublishBranch,
+ Cancel,
+}
+
+internal sealed record VersionControlPrimaryAction(
+ VersionControlPrimaryActionKind Kind,
+ string Label,
+ ICommand Command);
diff --git a/src/Beutl.Editor.Components/VersionControlTab/ViewModels/VersionControlTabViewModel.cs b/src/Beutl.Editor.Components/VersionControlTab/ViewModels/VersionControlTabViewModel.cs
new file mode 100644
index 0000000000..c342039625
--- /dev/null
+++ b/src/Beutl.Editor.Components/VersionControlTab/ViewModels/VersionControlTabViewModel.cs
@@ -0,0 +1,2324 @@
+using System.Collections.ObjectModel;
+using System.Globalization;
+using System.Reactive.Disposables;
+using System.Resources;
+using System.Text.Json.Nodes;
+using System.Windows.Input;
+using Avalonia.Threading;
+using Beutl.Editor.VersionControl;
+using Beutl.Extensibility;
+using Beutl.Logging;
+using Beutl.Services;
+using Microsoft.Extensions.Logging;
+using Reactive.Bindings;
+using Reactive.Bindings.Extensions;
+
+namespace Beutl.Editor.Components.VersionControlTab.ViewModels;
+
+public sealed class VersionControlTabViewModel : IToolContext
+{
+ internal const int HistoryPageSize = 50;
+ private static readonly Uri s_gitDownloadsUri = new("https://git-scm.com/downloads");
+
+ private readonly IEditorContext _editorContext;
+ private readonly ILogger _logger = Log.CreateLogger();
+ private readonly IProjectVersionControlCoordinator? _versionControlCoordinator;
+ private readonly Action _postToUi;
+ private readonly VersionControlRelativeTimeFormatter _relativeTimeFormatter;
+ private readonly CompositeDisposable _disposables = [];
+ private readonly SemaphoreSlim _historyGate = new(1, 1);
+ private readonly ReactivePropertySlim _showingDetail;
+ private readonly ReactivePropertySlim _primaryAction;
+ private readonly ReactiveCommandSlim _disabledPrimaryActionCommand;
+ private readonly ReactivePropertySlim _isPrimaryActionEnabled;
+ private readonly ReactivePropertySlim _isConfiguringRemote;
+ private ICommand? _observedPrimaryActionCommand;
+ private IProjectVersionControlService? _service;
+ private IRepositoryLockRecoveryService? _lockRecoveryService;
+ private CancellationTokenSource? _serviceBindingCancellation;
+ private CancellationTokenSource? _selectionCancellation;
+ private CancellationTokenSource? _remoteOperationCancellation;
+ private int _remoteOperationUserCancellation;
+ private int _remoteOperationGeneration;
+ private RemoteMutationLease? _remoteMutationOwner;
+ private TaskCompletionSource _remoteOperationCompletion =
+ CompletedCompletion();
+ private TaskCompletionSource _configureRemoteCompletion =
+ CompletedCompletion();
+ private int _serviceRevision;
+ private int _statusRefreshRevision;
+ private int _pendingRecoveryQueryRevision;
+ private int _nextHistoryOffset;
+ private int _aheadCount;
+ private int _behindCount;
+ private int _restoreRequestActive;
+ private int _pendingRecoveryRequestActive;
+ private string? _pendingRecoveryId;
+ private HistoryIdentity? _historyIdentity;
+ private bool _hasMoreHistory;
+ private bool _hasUncommittedChanges;
+ private bool _disposed;
+
+ private static TaskCompletionSource CompletedCompletion()
+ {
+ var completion = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ completion.TrySetResult();
+ return completion;
+ }
+
+ public VersionControlTabViewModel(
+ ToolTabExtension extension,
+ IEditorContext editorContext)
+ : this(
+ extension,
+ editorContext,
+ editorContext.GetService(
+ typeof(IReadOnlyReactiveProperty))
+ as IReadOnlyReactiveProperty
+ ?? throw new InvalidOperationException(
+ "The editor context does not provide the version-control service observable."),
+ editorContext.GetService(typeof(IProjectVersionControlCoordinator))
+ as IProjectVersionControlCoordinator,
+ PostToUiThread,
+ timeProvider: null,
+ culture: null)
+ {
+ }
+
+ internal VersionControlTabViewModel(
+ ToolTabExtension extension,
+ IEditorContext editorContext,
+ IReadOnlyReactiveProperty serviceSource,
+ IProjectVersionControlCoordinator? versionControlCoordinator,
+ Action postToUi,
+ TimeProvider? timeProvider = null,
+ CultureInfo? culture = null)
+ {
+ Extension = extension ?? throw new ArgumentNullException(nameof(extension));
+ _editorContext = editorContext ?? throw new ArgumentNullException(nameof(editorContext));
+ ArgumentNullException.ThrowIfNull(serviceSource);
+ IProjectVersionControlService? service = serviceSource.Value;
+ _versionControlCoordinator = versionControlCoordinator;
+ _postToUi = postToUi ?? throw new ArgumentNullException(nameof(postToUi));
+ if (_versionControlCoordinator is not null)
+ {
+ _versionControlCoordinator.PendingPullRecoveriesChanged +=
+ OnPendingPullRecoveriesChanged;
+ }
+ _relativeTimeFormatter = new VersionControlRelativeTimeFormatter(
+ timeProvider ?? TimeProvider.System,
+ culture ?? CultureInfo.CurrentUICulture);
+
+ IsTracked = new ReactivePropertySlim(service?.Repository is not null)
+ .DisposeWith(_disposables);
+ IsGitAvailable = new ReactivePropertySlim()
+ .DisposeWith(_disposables);
+ IsUnavailable = new ReactivePropertySlim()
+ .DisposeWith(_disposables);
+ IsConflicted = new ReactivePropertySlim()
+ .DisposeWith(_disposables);
+ HasBlockingGuidance = new ReactivePropertySlim()
+ .DisposeWith(_disposables);
+ HasRecoverableLock = new ReactivePropertySlim(
+ _lockRecoveryService?.RecoverableLock is not null)
+ .DisposeWith(_disposables);
+ HasPendingPullRecovery = new ReactivePropertySlim()
+ .DisposeWith(_disposables);
+ DirtySummary = new ReactivePropertySlim()
+ .DisposeWith(_disposables);
+ StatusMessage = new ReactivePropertySlim(
+ IsTracked.Value
+ ? string.Empty
+ : Strings.VersionControl_NoRepository)
+ .DisposeWith(_disposables);
+ IsLoading = new ReactivePropertySlim()
+ .DisposeWith(_disposables);
+ HasMoreHistory = new ReactivePropertySlim(IsTracked.Value)
+ .DisposeWith(_disposables);
+ IsHistoryEmpty = new ReactivePropertySlim(true)
+ .DisposeWith(_disposables);
+ _showingDetail = new ReactivePropertySlim()
+ .DisposeWith(_disposables);
+ ShowingDetail = _showingDetail
+ .ToReadOnlyReactivePropertySlim()
+ .DisposeWith(_disposables);
+ SelectedCommit = new ReactivePropertySlim()
+ .DisposeWith(_disposables);
+ SelectedFile = new ReactivePropertySlim()
+ .DisposeWith(_disposables);
+ HasSelectedCommit = SelectedCommit
+ .Select(static commit => commit is not null)
+ .ToReadOnlyReactivePropertySlim()
+ .DisposeWith(_disposables);
+ HasSelectedFile = SelectedFile
+ .Select(static file => file is not null)
+ .ToReadOnlyReactivePropertySlim()
+ .DisposeWith(_disposables);
+ CommitMessage = new ReactivePropertySlim()
+ .DisposeWith(_disposables);
+ RemoteUrl = new ReactivePropertySlim()
+ .DisposeWith(_disposables);
+ HasRemote = new ReactivePropertySlim()
+ .DisposeWith(_disposables);
+ RemoteProgress = new ReactivePropertySlim()
+ .DisposeWith(_disposables);
+ IsRemoteOperationRunning = new ReactivePropertySlim()
+ .DisposeWith(_disposables);
+ _isConfiguringRemote = new ReactivePropertySlim()
+ .DisposeWith(_disposables);
+ IsNestedRepository = new ReactivePropertySlim(
+ service?.Repository?.IsNestedInForeignRepo == true)
+ .DisposeWith(_disposables);
+ RepositoryScopeText = new ReactivePropertySlim(
+ service?.Repository is { IsNestedInForeignRepo: true } repository
+ ? string.Format(
+ CultureInfo.CurrentCulture,
+ Strings.VersionControl_EnclosingRepositoryScopeFormat,
+ repository.RepoRoot)
+ : string.Empty)
+ .DisposeWith(_disposables);
+ CanEnableVersionControl = IsGitAvailable.CombineLatest(
+ IsTracked,
+ static (available, tracked) => available && !tracked)
+ .ToReadOnlyReactivePropertySlim()
+ .DisposeWith(_disposables);
+ IsEnablingVersionControl = new ReactivePropertySlim()
+ .DisposeWith(_disposables);
+ EnableActionLabel = IsEnablingVersionControl
+ .Select(static enabling => enabling
+ ? Strings.VersionControl_Enabling
+ : Strings.VersionControl_Enable)
+ .ToReadOnlyReactivePropertySlim(Strings.VersionControl_Enable)!
+ .DisposeWith(_disposables);
+
+ LoadMoreCommand = new AsyncReactiveCommand()
+ .WithSubscribe(LoadMoreAsync)
+ .DisposeWith(_disposables);
+ BackToHistoryCommand = new ReactiveCommandSlim(ShowingDetail)
+ .WithSubscribe(ShowHistory)
+ .DisposeWith(_disposables);
+ EnableVersionControlCommand = new AsyncReactiveCommand(CanEnableVersionControl)
+ .WithSubscribe(EnableVersionControlAsync)
+ .DisposeWith(_disposables);
+ DownloadGitCommand = new AsyncReactiveCommand(IsUnavailable)
+ .WithSubscribe(DownloadGitAsync)
+ .DisposeWith(_disposables);
+ RemoveStaleLockCommand = new AsyncReactiveCommand(HasRecoverableLock)
+ .WithSubscribe(RemoveStaleLockAsync)
+ .DisposeWith(_disposables);
+ RecoverPendingPullCommand = new AsyncReactiveCommand(HasPendingPullRecovery)
+ .WithSubscribe(RecoverPendingPullAsync)
+ .DisposeWith(_disposables);
+ IObservable canMutate = IsTracked.CombineLatest(
+ HasBlockingGuidance,
+ IsRemoteOperationRunning,
+ _isConfiguringRemote,
+ static (tracked, blocked, isRunning, isConfiguring) =>
+ tracked && !blocked && !isRunning && !isConfiguring);
+ CommitCommand = new AsyncReactiveCommand(
+ canMutate.CombineLatest(
+ CommitMessage.Select(static message => !string.IsNullOrWhiteSpace(message)),
+ static (canRun, hasMessage) => canRun && hasMessage))
+ .WithSubscribe(CommitManualAsync)
+ .DisposeWith(_disposables);
+ SetRemoteCommand = new AsyncReactiveCommand(canMutate)
+ .WithSubscribe(SetRemoteAsync)
+ .DisposeWith(_disposables);
+ PublishBranchCommand = new AsyncReactiveCommand(
+ canMutate.CombineLatest(
+ HasRemote,
+ static (canRun, hasRemote) => canRun && !hasRemote))
+ .WithSubscribe(PublishBranchAsync)
+ .DisposeWith(_disposables);
+ IObservable canRunRemoteOperation = canMutate.CombineLatest(
+ HasRemote,
+ IsRemoteOperationRunning,
+ static (canRun, hasRemote, isRunning) => canRun && hasRemote && !isRunning);
+ PushCommand = new AsyncReactiveCommand(canRunRemoteOperation)
+ .WithSubscribe(PushAsync)
+ .DisposeWith(_disposables);
+ PullCommand = new AsyncReactiveCommand(canRunRemoteOperation)
+ .WithSubscribe(PullAsync)
+ .DisposeWith(_disposables);
+ CancelRemoteOperationCommand = new ReactiveCommandSlim(
+ IsRemoteOperationRunning)
+ .WithSubscribe(CancelRemoteOperation)
+ .DisposeWith(_disposables);
+ _disabledPrimaryActionCommand = new ReactiveCommandSlim(Observable.Return(false))
+ .DisposeWith(_disposables);
+ _primaryAction = new ReactivePropertySlim(
+ new(
+ VersionControlPrimaryActionKind.UpToDate,
+ Strings.VersionControl_UpToDate,
+ _disabledPrimaryActionCommand))
+ .DisposeWith(_disposables);
+ PrimaryAction = _primaryAction
+ .ToReadOnlyReactivePropertySlim(_primaryAction.Value)!
+ .DisposeWith(_disposables);
+ _isPrimaryActionEnabled = new ReactivePropertySlim()
+ .DisposeWith(_disposables);
+ IsPrimaryActionEnabled = _isPrimaryActionEnabled
+ .ToReadOnlyReactivePropertySlim()
+ .DisposeWith(_disposables);
+ InvokePrimaryActionCommand = new ReactiveCommandSlim()
+ .WithSubscribe(InvokePrimaryAction)
+ .DisposeWith(_disposables);
+ RequestBranchNameAsync = static _ => Task.FromResult(null);
+ RequestRemoteUrlAsync = static (_, _) => Task.FromResult(null);
+ ShowRemoteResultAsync = ShowRemoteResultNotificationAsync;
+ RequestEnableVersionControlAsync = static () => Task.CompletedTask;
+ LaunchUriAsync = static _ => Task.FromResult(false);
+ IsRemoteOperationRunning
+ .Subscribe(_ => UpdatePrimaryAction())
+ .DisposeWith(_disposables);
+ HasRemote
+ .Subscribe(_ => UpdatePrimaryAction())
+ .DisposeWith(_disposables);
+
+ Initialization = RebindServiceAsync(service);
+ serviceSource
+ .Subscribe(publishedService =>
+ {
+ if (!ReferenceEquals(publishedService, _service))
+ {
+ OnServicePublished(publishedService);
+ }
+ })
+ .DisposeWith(_disposables);
+ }
+
+ public ToolTabExtension Extension { get; }
+
+ public IReactiveProperty IsSelected { get; } = new ReactivePropertySlim();
+
+ public IReadOnlyReactiveProperty Header { get; } =
+ new ReactivePropertySlim(Strings.VersionControl);
+
+ public ReactivePropertySlim IsTracked { get; }
+
+ public ReactivePropertySlim IsGitAvailable { get; }
+
+ public ReactivePropertySlim IsUnavailable { get; }
+
+ public ReactivePropertySlim IsConflicted { get; }
+
+ public ReactivePropertySlim HasBlockingGuidance { get; }
+
+ public ReactivePropertySlim HasRecoverableLock { get; }
+
+ public ReactivePropertySlim HasPendingPullRecovery { get; }
+
+ public ReactivePropertySlim DirtySummary { get; }
+
+ public ReactivePropertySlim StatusMessage { get; }
+
+ public ReactivePropertySlim IsLoading { get; }
+
+ public ReactivePropertySlim HasMoreHistory { get; }
+
+ public ReactivePropertySlim IsHistoryEmpty { get; }
+
+ public ReadOnlyReactivePropertySlim ShowingDetail { get; }
+
+ public ObservableCollection Commits { get; } = [];
+
+ public ObservableCollection ChangedFiles { get; } = [];
+
+ public ObservableCollection DiffLines { get; } = [];
+
+ public ReactivePropertySlim SelectedCommit { get; }
+
+ public ReactivePropertySlim SelectedFile { get; }
+
+ public ReadOnlyReactivePropertySlim HasSelectedCommit { get; }
+
+ public ReadOnlyReactivePropertySlim HasSelectedFile { get; }
+
+ public ReactivePropertySlim CommitMessage { get; }
+
+ public ReactivePropertySlim RemoteUrl { get; }
+
+ public ReactivePropertySlim HasRemote { get; }
+
+ public ReactivePropertySlim RemoteProgress { get; }
+
+ public ReactivePropertySlim IsRemoteOperationRunning { get; }
+
+ public ReactivePropertySlim IsNestedRepository { get; }
+
+ public ReactivePropertySlim RepositoryScopeText { get; }
+
+ public ReadOnlyReactivePropertySlim CanEnableVersionControl { get; }
+
+ public ReactivePropertySlim IsEnablingVersionControl { get; }
+
+ public ReadOnlyReactivePropertySlim EnableActionLabel { get; }
+
+ public AsyncReactiveCommand LoadMoreCommand { get; }
+
+ public ReactiveCommandSlim BackToHistoryCommand { get; }
+
+ public AsyncReactiveCommand EnableVersionControlCommand { get; }
+
+ public AsyncReactiveCommand DownloadGitCommand { get; }
+
+ public AsyncReactiveCommand RemoveStaleLockCommand { get; }
+
+ public AsyncReactiveCommand RecoverPendingPullCommand { get; }
+
+ public AsyncReactiveCommand CommitCommand { get; }
+
+ public AsyncReactiveCommand SetRemoteCommand { get; }
+
+ public AsyncReactiveCommand PublishBranchCommand { get; }
+
+ public AsyncReactiveCommand PushCommand { get; }
+
+ public AsyncReactiveCommand PullCommand { get; }
+
+ public ReactiveCommandSlim CancelRemoteOperationCommand { get; }
+
+ internal ReadOnlyReactivePropertySlim PrimaryAction { get; }
+
+ internal ReadOnlyReactivePropertySlim IsPrimaryActionEnabled { get; }
+
+ internal ReactiveCommandSlim InvokePrimaryActionCommand { get; }
+
+ public Task Initialization { get; private set; }
+
+ public Func> RequestBranchNameAsync { get; set; }
+
+ public Func> RequestRemoteUrlAsync { get; set; }
+
+ public Func ShowRemoteResultAsync { get; set; }
+
+ public Func RequestEnableVersionControlAsync { get; set; }
+
+ public Func> LaunchUriAsync { get; set; }
+
+ public async Task EnableVersionControlAsync()
+ {
+ if (!CanEnableVersionControl.Value || IsEnablingVersionControl.Value)
+ {
+ return;
+ }
+
+ // Initialization saves the project, runs git init and writes the first commit, so the panel
+ // has to stay in a running state until the shell flow reports back.
+ IsEnablingVersionControl.Value = true;
+ try
+ {
+ await RequestEnableVersionControlAsync();
+ }
+ finally
+ {
+ IsEnablingVersionControl.Value = false;
+ }
+
+ if (_service?.Repository is not null)
+ {
+ IsTracked.Value = true;
+ }
+ }
+
+ public async Task DownloadGitAsync()
+ {
+ if (IsUnavailable.Value)
+ {
+ await LaunchUriAsync(s_gitDownloadsUri);
+ }
+ }
+
+ public async Task LoadMoreAsync()
+ {
+ IProjectVersionControlService? service = _service;
+ if (service?.Repository is null || !HasMoreHistory.Value)
+ {
+ return;
+ }
+
+ CancellationToken cancellationToken =
+ _serviceBindingCancellation?.Token ?? CancellationToken.None;
+ try
+ {
+ await _historyGate.WaitAsync(cancellationToken);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+ catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+
+ try
+ {
+ await LoadNextPageCoreAsync(service, cancellationToken);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ }
+ catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested)
+ {
+ }
+ finally
+ {
+ _historyGate.Release();
+ }
+ }
+
+ public async Task CommitManualAsync()
+ {
+ if (_versionControlCoordinator is null || string.IsNullOrWhiteSpace(CommitMessage.Value))
+ {
+ return;
+ }
+
+ try
+ {
+ CommitResult result = await _versionControlCoordinator.CommitManualAsync(
+ CommitMessage.Value.Trim(),
+ CancellationToken.None);
+ switch (result)
+ {
+ case CommitResult.NoChanges:
+ StatusMessage.Value = Strings.VersionControl_NothingToCommit;
+ break;
+ case CommitResult.Committed:
+ CommitMessage.Value = string.Empty;
+ StatusMessage.Value = Strings.VersionControl_CommitCreated;
+ break;
+ }
+ }
+ catch (GitIdentityRequiredException)
+ {
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "The manual commit command failed.");
+ NotificationService.ShowError(Strings.VersionControl_ErrorTitle, ex.Message);
+ }
+ }
+
+ public async Task SetRemoteAsync()
+ {
+ RemoteMutationLease? lease = TryAcquireRemoteMutation();
+ if (lease is not null)
+ {
+ try
+ {
+ await ConfigureRemoteAsync(lease);
+ }
+ finally
+ {
+ lease.Release();
+ }
+ }
+ }
+
+ public async Task PublishBranchAsync()
+ {
+ RemoteMutationLease? lease = TryAcquireRemoteMutation();
+ if (lease is null)
+ {
+ return;
+ }
+
+ TaskCompletionSource publishCompletion = new(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ _remoteOperationCompletion = publishCompletion;
+ try
+ {
+ if (await ConfigureRemoteAsync(lease))
+ {
+ await RunRemoteOperationAsync(
+ (progress, cancellationToken) => _versionControlCoordinator!.PushAsync(
+ progress,
+ cancellationToken),
+ Strings.VersionControl_Pushing,
+ lease,
+ publishCompletion);
+ }
+ }
+ finally
+ {
+ publishCompletion.TrySetResult();
+ lease.Release();
+ }
+ }
+
+ private RemoteMutationLease? TryAcquireRemoteMutation()
+ {
+ var lease = new RemoteMutationLease(this);
+ return Interlocked.CompareExchange(ref _remoteMutationOwner, lease, null) is null
+ ? lease
+ : null;
+ }
+
+ private async Task ConfigureRemoteAsync(RemoteMutationLease lease)
+ {
+ IProjectVersionControlCoordinator? coordinator = _versionControlCoordinator;
+ IProjectVersionControlService? service = _service;
+ int revision = _serviceRevision;
+ if (coordinator is null
+ || service is null
+ || _disposed
+ || IsRemoteOperationRunning.Value)
+ {
+ return false;
+ }
+
+ CancellationToken cancellationToken;
+ try
+ {
+ cancellationToken =
+ _serviceBindingCancellation?.Token ?? CancellationToken.None;
+ }
+ catch (ObjectDisposedException)
+ {
+ return false;
+ }
+
+ if (!IsCurrentService(service, revision, cancellationToken))
+ {
+ return false;
+ }
+
+ TaskCompletionSource completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ _configureRemoteCompletion = completion;
+ _isConfiguringRemote.Value = true;
+ try
+ {
+ string? remoteUrl = await RequestRemoteUrlAsync(
+ HasRemote.Value ? RemoteUrl.Value : null,
+ cancellationToken);
+ if (!IsCurrentService(service, revision, cancellationToken)
+ || string.IsNullOrWhiteSpace(remoteUrl))
+ {
+ return false;
+ }
+
+ string normalizedUrl = remoteUrl.Trim();
+ await coordinator.SetRemoteAsync(normalizedUrl, cancellationToken);
+ if (!IsCurrentService(service, revision, cancellationToken))
+ {
+ return false;
+ }
+
+ await RefreshRemotesAsync(
+ service,
+ cancellationToken,
+ serviceRevision: revision,
+ freshness: () => IsCurrentService(service, revision, cancellationToken));
+ if (!IsCurrentService(service, revision, cancellationToken))
+ {
+ return false;
+ }
+
+ RemoteUrl.Value = normalizedUrl;
+ HasRemote.Value = true;
+ StatusMessage.Value = Strings.VersionControl_RemoteConnected;
+ return true;
+ }
+ catch (ArgumentException ex)
+ {
+ if (IsCurrentService(service, revision, cancellationToken))
+ {
+ NotificationService.ShowError(Strings.VersionControl_ErrorTitle, ex.Message);
+ }
+ return false;
+ }
+ catch (OperationCanceledException)
+ {
+ return false;
+ }
+ catch (ObjectDisposedException) when (!IsCurrentService(service, revision, cancellationToken))
+ {
+ return false;
+ }
+ catch (Exception ex)
+ {
+ if (IsCurrentService(service, revision, cancellationToken))
+ {
+ _logger.LogError(ex, "Failed to configure the remote.");
+ NotificationService.ShowError(
+ Strings.VersionControl_ErrorTitle,
+ MessageStrings.OperationFailed);
+ }
+ return false;
+ }
+ finally
+ {
+ completion.TrySetResult();
+ if (!_disposed
+ && ReferenceEquals(completion, _configureRemoteCompletion))
+ {
+ _isConfiguringRemote.Value = false;
+ }
+ }
+ }
+
+ public Task PushAsync()
+ {
+ return RunRemoteOperationAsync(
+ (progress, cancellationToken) => _versionControlCoordinator!.PushAsync(
+ progress,
+ cancellationToken),
+ Strings.VersionControl_Pushing,
+ lease: null);
+ }
+
+ internal Task RemoteOperationCompletion => _remoteOperationCompletion.Task;
+ internal Task ConfigureRemoteCompletion => _configureRemoteCompletion.Task;
+
+ public Task PullAsync()
+ {
+ return RunRemoteOperationAsync(
+ (_, cancellationToken) => _versionControlCoordinator!.PullAsync(cancellationToken),
+ Strings.VersionControl_Pulling);
+ }
+
+ public async Task SelectCommitAsync(VersionControlCommitViewModel? commit)
+ {
+ SelectedCommit.Value = commit;
+ if (commit is null)
+ {
+ _showingDetail.Value = false;
+ }
+
+ SelectedFile.Value = null;
+ ChangedFiles.Clear();
+ DiffLines.Clear();
+ CancellationToken cancellationToken = ReplaceSelectionCancellation();
+ if (_service is null || commit is null)
+ {
+ return;
+ }
+
+ IReadOnlyList files;
+ try
+ {
+ files = await _service.GetCommitFilesAsync(
+ commit.Commit.Sha,
+ cancellationToken);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+
+ if (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+
+ foreach (FileChange file in files)
+ {
+ ChangedFiles.Add(new VersionControlFileChangeViewModel(file));
+ }
+ }
+
+ internal async Task OpenCommitDetailAsync(VersionControlCommitViewModel commit)
+ {
+ ArgumentNullException.ThrowIfNull(commit);
+ _showingDetail.Value = true;
+ await SelectCommitAsync(commit);
+ }
+
+ internal void ShowSelectedCommitDetail()
+ {
+ if (SelectedCommit.Value is not null)
+ {
+ _showingDetail.Value = true;
+ }
+ }
+
+ private void ShowHistory()
+ {
+ _showingDetail.Value = false;
+ }
+
+ public async Task SelectFileAsync(VersionControlFileChangeViewModel? file)
+ {
+ SelectedFile.Value = file;
+ DiffLines.Clear();
+ CancellationToken cancellationToken = ReplaceSelectionCancellation();
+ if (_service is null || SelectedCommit.Value is not { } commit || file is null)
+ {
+ return;
+ }
+
+ string diff;
+ try
+ {
+ diff = await _service.GetDiffAsync(
+ commit.Commit.Sha,
+ file.Change.Path,
+ cancellationToken);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+
+ if (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+
+ foreach (VersionControlDiffLineViewModel line in VersionControlDiffLineViewModel.Parse(diff))
+ {
+ DiffLines.Add(line);
+ }
+ }
+
+ public object? GetService(Type serviceType)
+ {
+ return _editorContext.GetService(serviceType);
+ }
+
+ public void ReadFromJson(JsonObject json)
+ {
+ }
+
+ public void WriteToJson(JsonObject json)
+ {
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ Interlocked.Increment(ref _statusRefreshRevision);
+ Interlocked.Increment(ref _pendingRecoveryQueryRevision);
+ if (_versionControlCoordinator is not null)
+ {
+ _versionControlCoordinator.PendingPullRecoveriesChanged -=
+ OnPendingPullRecoveriesChanged;
+ }
+
+ DetachServiceEvents();
+ _serviceBindingCancellation?.Cancel();
+ _serviceBindingCancellation?.Dispose();
+
+ _selectionCancellation?.Cancel();
+ _selectionCancellation?.Dispose();
+ TryCancel(Volatile.Read(ref _remoteOperationCancellation));
+ if (_observedPrimaryActionCommand is not null)
+ {
+ _observedPrimaryActionCommand.CanExecuteChanged -=
+ OnPrimaryActionCanExecuteChanged;
+ _observedPrimaryActionCommand = null;
+ }
+
+ foreach (VersionControlCommitViewModel commit in Commits)
+ {
+ commit.Dispose();
+ }
+
+ Commits.Clear();
+ ChangedFiles.Clear();
+ DiffLines.Clear();
+ IsSelected.Dispose();
+ _disposables.Dispose();
+ }
+
+ internal Task RestoreAsync(CommitInfo commit)
+ {
+ return RunRestoreForCurrentServiceAsync(
+ (coordinator, _, _, cancellationToken) => coordinator.RestoreAsync(
+ commit.Sha,
+ cancellationToken));
+ }
+
+ internal Task RestoreToNewBranchAsync(CommitInfo commit)
+ {
+ return RunRestoreForCurrentServiceAsync(async (
+ coordinator,
+ service,
+ revision,
+ cancellationToken) =>
+ {
+ string? branchName = await RequestBranchNameAsync(commit);
+ if (!IsCurrentService(service, revision, cancellationToken)
+ || string.IsNullOrWhiteSpace(branchName))
+ {
+ return false;
+ }
+
+ return await coordinator.RestoreToNewBranchAsync(
+ commit.Sha,
+ branchName.Trim(),
+ cancellationToken);
+ });
+ }
+
+ private Task RunRestoreForCurrentServiceAsync(
+ Func<
+ IProjectVersionControlCoordinator,
+ IProjectVersionControlService,
+ int,
+ CancellationToken,
+ Task> operation)
+ {
+ IProjectVersionControlCoordinator? coordinator = _versionControlCoordinator;
+ IProjectVersionControlService? service = _service;
+ int revision = _serviceRevision;
+ if (coordinator is null
+ || service is null
+ || _disposed)
+ {
+ return Task.FromResult(false);
+ }
+
+ CancellationToken cancellationToken;
+ try
+ {
+ cancellationToken =
+ _serviceBindingCancellation?.Token ?? CancellationToken.None;
+ }
+ catch (ObjectDisposedException)
+ {
+ return Task.FromResult(false);
+ }
+
+ if (!IsCurrentService(service, revision, cancellationToken))
+ {
+ return Task.FromResult(false);
+ }
+
+ return RunRestoreRequestAsync(async () =>
+ {
+ if (!IsCurrentService(service, revision, cancellationToken))
+ {
+ return false;
+ }
+
+ try
+ {
+ bool result = await operation(
+ coordinator,
+ service,
+ revision,
+ cancellationToken);
+ return IsCurrentService(service, revision, cancellationToken)
+ && result;
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ return false;
+ }
+ });
+ }
+
+ internal async Task RemoveStaleLockAsync()
+ {
+ if (_lockRecoveryService is null)
+ {
+ return;
+ }
+
+ await _lockRecoveryService.RemoveRecoverableLockAsync(CancellationToken.None);
+ HasRecoverableLock.Value = _lockRecoveryService.RecoverableLock is not null;
+ }
+
+ internal async Task RecoverPendingPullAsync()
+ {
+ string? recoveryId = _pendingRecoveryId;
+ if (_versionControlCoordinator is null
+ || recoveryId is null
+ || Interlocked.CompareExchange(ref _pendingRecoveryRequestActive, 1, 0) != 0)
+ {
+ return;
+ }
+
+ try
+ {
+ ProjectRecoveryResult result =
+ await _versionControlCoordinator.RecoverPendingPullAsync(
+ recoveryId,
+ CancellationToken.None);
+ bool recovered = result is ProjectRecoveryResult.RestoredOriginal
+ or ProjectRecoveryResult.ReappliedCheckpoint;
+ if (recovered && !_disposed)
+ {
+ _pendingRecoveryId = null;
+ HasPendingPullRecovery.Value = false;
+ }
+ }
+ finally
+ {
+ Volatile.Write(ref _pendingRecoveryRequestActive, 0);
+ }
+ }
+
+ private async Task RunRestoreRequestAsync(Func> operation)
+ {
+ if (Interlocked.CompareExchange(ref _restoreRequestActive, 1, 0) != 0)
+ {
+ return false;
+ }
+
+ try
+ {
+ return await operation();
+ }
+ finally
+ {
+ Volatile.Write(ref _restoreRequestActive, 0);
+ }
+ }
+
+ private void OnServicePublished(IProjectVersionControlService? service)
+ {
+ _serviceBindingCancellation?.Cancel();
+ _postToUi(() =>
+ {
+ if (!_disposed)
+ {
+ Initialization = RebindServiceAsync(service);
+ }
+ });
+ }
+
+ private Task RebindServiceAsync(IProjectVersionControlService? service)
+ {
+ _serviceBindingCancellation?.Cancel();
+ _serviceBindingCancellation?.Dispose();
+ _serviceBindingCancellation = new CancellationTokenSource();
+ int revision = ++_serviceRevision;
+
+ DetachServiceEvents();
+ _service = service;
+ _lockRecoveryService = service as IRepositoryLockRecoveryService;
+ if (_service is not null)
+ {
+ _service.StatusChanged += OnStatusChanged;
+ }
+
+ if (_lockRecoveryService is not null)
+ {
+ _lockRecoveryService.RecoverableLockAvailable += OnRecoverableLockAvailable;
+ }
+
+ ResetRepositoryState();
+ return InitializeAsync(
+ service,
+ revision,
+ _serviceBindingCancellation.Token);
+ }
+
+ private void DetachServiceEvents()
+ {
+ if (_service is not null)
+ {
+ _service.StatusChanged -= OnStatusChanged;
+ }
+
+ if (_lockRecoveryService is not null)
+ {
+ _lockRecoveryService.RecoverableLockAvailable -= OnRecoverableLockAvailable;
+ }
+ }
+
+ private void ResetRepositoryState()
+ {
+ _selectionCancellation?.Cancel();
+ _selectionCancellation?.Dispose();
+ _selectionCancellation = null;
+ TryCancel(Volatile.Read(ref _remoteOperationCancellation));
+
+ foreach (VersionControlCommitViewModel commit in Commits)
+ {
+ commit.Dispose();
+ }
+
+ Commits.Clear();
+ ChangedFiles.Clear();
+ DiffLines.Clear();
+ SelectedCommit.Value = null;
+ SelectedFile.Value = null;
+ _showingDetail.Value = false;
+ _nextHistoryOffset = 0;
+ _historyIdentity = null;
+ _hasMoreHistory = false;
+ _aheadCount = 0;
+ _behindCount = 0;
+ _hasUncommittedChanges = false;
+ Interlocked.Increment(ref _statusRefreshRevision);
+ Interlocked.Increment(ref _pendingRecoveryQueryRevision);
+
+ bool isTracked = _service?.Repository is not null;
+ IsTracked.Value = isTracked;
+ IsGitAvailable.Value = false;
+ IsUnavailable.Value = false;
+ IsConflicted.Value = false;
+ HasBlockingGuidance.Value = false;
+ HasRecoverableLock.Value = _lockRecoveryService?.RecoverableLock is not null;
+ HasPendingPullRecovery.Value = false;
+ _pendingRecoveryId = null;
+ DirtySummary.Value = string.Empty;
+ StatusMessage.Value = isTracked
+ ? string.Empty
+ : Strings.VersionControl_NoRepository;
+ IsLoading.Value = false;
+ HasMoreHistory.Value = isTracked;
+ IsHistoryEmpty.Value = true;
+ CommitMessage.Value = string.Empty;
+ RemoteUrl.Value = string.Empty;
+ HasRemote.Value = false;
+ RemoteProgress.Value = string.Empty;
+ IsNestedRepository.Value = _service?.Repository?.IsNestedInForeignRepo == true;
+ RepositoryScopeText.Value =
+ _service?.Repository is { IsNestedInForeignRepo: true } repository
+ ? string.Format(
+ CultureInfo.CurrentCulture,
+ Strings.VersionControl_EnclosingRepositoryScopeFormat,
+ repository.RepoRoot)
+ : string.Empty;
+ UpdatePrimaryAction();
+ }
+
+ private async Task InitializeAsync(
+ IProjectVersionControlService? service,
+ int revision,
+ CancellationToken cancellationToken)
+ {
+ if (service is null)
+ {
+ return;
+ }
+
+ GitAvailability availability;
+ try
+ {
+ availability = await service.GetAvailabilityAsync(cancellationToken);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+ catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+
+ if (!IsCurrentService(service, revision, cancellationToken))
+ {
+ return;
+ }
+
+ IsGitAvailable.Value = availability.State == GitAvailabilityState.Installed;
+ if (availability.State != GitAvailabilityState.Installed)
+ {
+ IsUnavailable.Value = true;
+ HasBlockingGuidance.Value = true;
+ IsTracked.Value = false;
+ HasMoreHistory.Value = false;
+ StatusMessage.Value = GetAvailabilityMessage(availability);
+ return;
+ }
+
+ if (service.Repository is null)
+ {
+ StatusMessage.Value = Strings.VersionControl_NoRepository;
+ return;
+ }
+
+ await RefreshPendingPullRecoveryAsync(service, revision, cancellationToken);
+ if (!IsCurrentService(service, revision, cancellationToken))
+ {
+ return;
+ }
+
+ int statusRefreshRevision = Volatile.Read(ref _statusRefreshRevision);
+ WorkspaceStatus status;
+ try
+ {
+ status = await service.GetStatusAsync(cancellationToken);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+ catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+
+ if (!IsCurrentService(service, revision, cancellationToken)
+ || !IsCurrentStatusRefresh(
+ service,
+ statusRefreshRevision,
+ cancellationToken))
+ {
+ return;
+ }
+
+ ApplyStatus(status);
+ if (!status.HasConflicts)
+ {
+ try
+ {
+ await RefreshRemotesAsync(
+ service,
+ cancellationToken,
+ statusRefreshRevision);
+ if (!IsCurrentStatusRefresh(
+ service,
+ statusRefreshRevision,
+ cancellationToken))
+ {
+ return;
+ }
+
+ await RefreshHistoryAsync(
+ service,
+ status.Branch,
+ statusRefreshRevision,
+ cancellationToken);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ }
+ catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested)
+ {
+ }
+ }
+ }
+
+ private async Task RefreshPendingPullRecoveryAsync(
+ IProjectVersionControlService service,
+ int revision,
+ CancellationToken cancellationToken)
+ {
+ int queryRevision = Interlocked.Increment(ref _pendingRecoveryQueryRevision);
+ if (_versionControlCoordinator is null)
+ {
+ return;
+ }
+
+ try
+ {
+ IReadOnlyList recoveries =
+ await _versionControlCoordinator.GetPendingPullRecoveriesAsync(
+ cancellationToken);
+ if (!IsCurrentService(service, revision, cancellationToken)
+ || queryRevision != Volatile.Read(ref _pendingRecoveryQueryRevision))
+ {
+ return;
+ }
+
+ ProjectRecoveryInfo? recovery = recoveries
+ .OrderBy(static item => item.CreatedAt)
+ .ThenBy(static item => item.Id, StringComparer.Ordinal)
+ .FirstOrDefault();
+ _pendingRecoveryId = recovery?.Id;
+ HasPendingPullRecovery.Value = recovery is not null;
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+ catch (ObjectDisposedException)
+ {
+ return;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to refresh pending pull recovery state.");
+ }
+ }
+
+ private void OnPendingPullRecoveriesChanged(object? sender, EventArgs e)
+ {
+ _postToUi(() =>
+ {
+ if (_disposed || _service is not { } service)
+ {
+ return;
+ }
+
+ int revision = _serviceRevision;
+ CancellationToken cancellationToken =
+ _serviceBindingCancellation?.Token ?? CancellationToken.None;
+ Initialization = RefreshPendingPullRecoveryAsync(
+ service,
+ revision,
+ cancellationToken);
+ });
+ }
+
+ private bool IsCurrentService(
+ IProjectVersionControlService service,
+ int revision,
+ CancellationToken cancellationToken)
+ {
+ return !_disposed
+ && !cancellationToken.IsCancellationRequested
+ && revision == _serviceRevision
+ && ReferenceEquals(service, _service);
+ }
+
+ private bool IsCurrentStatusRefresh(
+ IProjectVersionControlService service,
+ int revision,
+ CancellationToken cancellationToken)
+ {
+ return !_disposed
+ && !cancellationToken.IsCancellationRequested
+ && revision == Volatile.Read(ref _statusRefreshRevision)
+ && ReferenceEquals(service, _service);
+ }
+
+ internal static string GetAvailabilityMessage(GitAvailability availability)
+ {
+ ArgumentNullException.ThrowIfNull(availability);
+ string stateMessage = availability.State switch
+ {
+ GitAvailabilityState.VersionTooOld => string.Format(
+ CultureInfo.CurrentCulture,
+ Strings.VersionControl_GitTooOldFormat,
+ availability.Version?.ToString() ?? "—"),
+ _ => Strings.VersionControl_GitNotInstalled,
+ };
+ string installMessage = OperatingSystem.IsWindows()
+ ? Strings.VersionControl_InstallGitWindows
+ : OperatingSystem.IsMacOS()
+ ? Strings.VersionControl_InstallGitMacOS
+ : Strings.VersionControl_InstallGitLinux;
+ return $"{stateMessage}\n\n{installMessage}";
+ }
+
+ private async Task RefreshHistoryAsync(
+ IProjectVersionControlService service,
+ string? branch,
+ int statusRefreshRevision,
+ CancellationToken cancellationToken)
+ {
+ if (service.Repository is null)
+ {
+ return;
+ }
+
+ await _historyGate.WaitAsync(cancellationToken);
+ try
+ {
+ if (!IsCurrentStatusRefresh(
+ service,
+ statusRefreshRevision,
+ cancellationToken))
+ {
+ return;
+ }
+
+ await ReloadHistoryCoreAsync(
+ service,
+ branch,
+ statusRefreshRevision,
+ cancellationToken);
+ }
+ finally
+ {
+ _historyGate.Release();
+ }
+ }
+
+ private async Task RefreshHistoryIfChangedAsync(
+ IProjectVersionControlService service,
+ string? branch,
+ int statusRefreshRevision,
+ CancellationToken cancellationToken)
+ {
+ await _historyGate.WaitAsync(cancellationToken);
+ try
+ {
+ if (!IsCurrentStatusRefresh(
+ service,
+ statusRefreshRevision,
+ cancellationToken))
+ {
+ return;
+ }
+
+ IReadOnlyList tip = await service.GetHistoryAsync(
+ 0,
+ 1,
+ cancellationToken);
+ if (!IsCurrentStatusRefresh(
+ service,
+ statusRefreshRevision,
+ cancellationToken))
+ {
+ return;
+ }
+
+ var identity = new HistoryIdentity(branch, tip.FirstOrDefault()?.Sha);
+ if (_historyIdentity == identity)
+ {
+ HasMoreHistory.Value = _hasMoreHistory;
+ UpdateHistoryStatusMessage();
+ return;
+ }
+
+ await ReloadHistoryCoreAsync(
+ service,
+ branch,
+ statusRefreshRevision,
+ cancellationToken);
+ }
+ finally
+ {
+ _historyGate.Release();
+ }
+ }
+
+ private async Task ReloadHistoryCoreAsync(
+ IProjectVersionControlService service,
+ string? branch,
+ int statusRefreshRevision,
+ CancellationToken cancellationToken)
+ {
+ IsLoading.Value = true;
+ try
+ {
+ IReadOnlyList page = await service.GetHistoryAsync(
+ 0,
+ HistoryPageSize,
+ cancellationToken);
+ if (!IsCurrentStatusRefresh(
+ service,
+ statusRefreshRevision,
+ cancellationToken))
+ {
+ return;
+ }
+
+ string? selectedSha = SelectedCommit.Value?.Commit.Sha;
+ _historyIdentity = null;
+ _selectionCancellation?.Cancel();
+ _selectionCancellation?.Dispose();
+ _selectionCancellation = null;
+ foreach (VersionControlCommitViewModel commit in Commits)
+ {
+ commit.Dispose();
+ }
+
+ Commits.Clear();
+ ChangedFiles.Clear();
+ DiffLines.Clear();
+ SelectedCommit.Value = null;
+ SelectedFile.Value = null;
+ foreach (CommitInfo commit in page)
+ {
+ Commits.Add(new VersionControlCommitViewModel(
+ this,
+ commit,
+ _relativeTimeFormatter));
+ }
+
+ _nextHistoryOffset = page.Count;
+ _hasMoreHistory = page.Count == HistoryPageSize;
+ HasMoreHistory.Value = _hasMoreHistory;
+ UpdateHistoryStatusMessage();
+ _historyIdentity = new HistoryIdentity(
+ branch,
+ page.FirstOrDefault()?.Sha);
+
+ if (selectedSha is not null)
+ {
+ VersionControlCommitViewModel? restoredCommit = Commits.FirstOrDefault(
+ item => string.Equals(
+ item.Commit.Sha,
+ selectedSha,
+ StringComparison.Ordinal));
+ if (restoredCommit is null)
+ {
+ _showingDetail.Value = false;
+ }
+ else
+ {
+ await SelectCommitAsync(restoredCommit);
+ }
+ }
+ else
+ {
+ _showingDetail.Value = false;
+ }
+ }
+ finally
+ {
+ IsLoading.Value = false;
+ }
+ }
+
+ private async Task RefreshRemotesAsync()
+ {
+ IProjectVersionControlService? service = _service;
+ if (service?.Repository is null)
+ {
+ return;
+ }
+
+ CancellationToken cancellationToken =
+ _serviceBindingCancellation?.Token ?? CancellationToken.None;
+ await RefreshRemotesAsync(service, cancellationToken);
+ }
+
+ private async Task RefreshRemotesAsync(
+ IProjectVersionControlService service,
+ CancellationToken cancellationToken,
+ int? statusRefreshRevision = null,
+ int? serviceRevision = null,
+ Func? freshness = null)
+ {
+ RemoteInfo? remote = (await service.GetRemotesAsync(cancellationToken))
+ .FirstOrDefault();
+ if (cancellationToken.IsCancellationRequested
+ || !ReferenceEquals(service, _service)
+ || serviceRevision is { } bindingRevision
+ && bindingRevision != Volatile.Read(ref _serviceRevision)
+ || statusRefreshRevision is { } revision
+ && !IsCurrentStatusRefresh(service, revision, cancellationToken)
+ || freshness is not null
+ && !freshness())
+ {
+ return;
+ }
+
+ HasRemote.Value = remote is not null;
+ RemoteUrl.Value = GetRemoteUrlForPresentation(remote?.Url);
+ }
+
+ private static string GetRemoteUrlForPresentation(string? url)
+ {
+ if (string.IsNullOrWhiteSpace(url))
+ {
+ return string.Empty;
+ }
+
+ if (!Uri.TryCreate(url, UriKind.Absolute, out Uri? uri))
+ {
+ return url;
+ }
+
+ if (!string.IsNullOrEmpty(uri.Query)
+ || !string.IsNullOrEmpty(uri.Fragment))
+ {
+ return string.Empty;
+ }
+
+ if (string.IsNullOrEmpty(uri.UserInfo))
+ {
+ return url;
+ }
+
+ bool isSsh = string.Equals(uri.Scheme, "ssh", StringComparison.OrdinalIgnoreCase);
+ bool hasPassword = Uri.UnescapeDataString(uri.UserInfo).Contains(':');
+ return isSsh && !hasPassword ? url : string.Empty;
+ }
+
+ private async Task LoadNextPageCoreAsync(
+ IProjectVersionControlService service,
+ CancellationToken cancellationToken)
+ {
+ if (!ReferenceEquals(service, _service))
+ {
+ return;
+ }
+
+ IsLoading.Value = true;
+ try
+ {
+ IReadOnlyList page = await service.GetHistoryAsync(
+ _nextHistoryOffset,
+ HistoryPageSize,
+ cancellationToken);
+ if (cancellationToken.IsCancellationRequested
+ || !ReferenceEquals(service, _service))
+ {
+ return;
+ }
+
+ foreach (CommitInfo commit in page)
+ {
+ Commits.Add(new VersionControlCommitViewModel(
+ this,
+ commit,
+ _relativeTimeFormatter));
+ }
+
+ _nextHistoryOffset += page.Count;
+ _hasMoreHistory = page.Count == HistoryPageSize;
+ HasMoreHistory.Value = _hasMoreHistory;
+ UpdateHistoryStatusMessage();
+ }
+ finally
+ {
+ IsLoading.Value = false;
+ }
+ }
+
+ private void UpdateHistoryStatusMessage()
+ {
+ IsHistoryEmpty.Value = Commits.Count == 0;
+ StatusMessage.Value = Commits.Count == 0
+ ? Strings.VersionControl_HistoryEmptyHint
+ : string.Empty;
+ }
+
+ private void OnStatusChanged(object? sender, WorkspaceStatus status)
+ {
+ if (sender is not IProjectVersionControlService eventService
+ || !ReferenceEquals(eventService, _service))
+ {
+ return;
+ }
+
+ _postToUi(() =>
+ {
+ if (_disposed || !ReferenceEquals(eventService, _service))
+ {
+ return;
+ }
+
+ int statusRefreshRevision =
+ Interlocked.Increment(ref _statusRefreshRevision);
+ ApplyStatus(status);
+ CancellationToken cancellationToken =
+ _serviceBindingCancellation?.Token ?? CancellationToken.None;
+ Task pendingRecoveryRefresh = RefreshPendingPullRecoveryAsync(
+ eventService,
+ _serviceRevision,
+ cancellationToken);
+ Task statusRefresh = Task.CompletedTask;
+ if (!status.HasConflicts)
+ {
+ statusRefresh = RefreshAfterStatusChangedAsync(
+ eventService,
+ status.Branch,
+ statusRefreshRevision,
+ cancellationToken);
+ }
+
+ Initialization = Task.WhenAll(
+ pendingRecoveryRefresh,
+ statusRefresh);
+ });
+ }
+
+ private async Task RefreshAfterStatusChangedAsync(
+ IProjectVersionControlService service,
+ string? branch,
+ int statusRefreshRevision,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ await RefreshRemotesAsync(
+ service,
+ cancellationToken,
+ statusRefreshRevision);
+ if (!IsCurrentStatusRefresh(
+ service,
+ statusRefreshRevision,
+ cancellationToken))
+ {
+ return;
+ }
+
+ await RefreshHistoryIfChangedAsync(
+ service,
+ branch,
+ statusRefreshRevision,
+ cancellationToken);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ }
+ catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested)
+ {
+ }
+ }
+
+ private void OnRecoverableLockAvailable(object? sender, RepositoryLockInfo lockInfo)
+ {
+ if (!ReferenceEquals(sender, _service))
+ {
+ return;
+ }
+
+ _postToUi(() =>
+ {
+ if (!_disposed
+ && ReferenceEquals(sender, _service)
+ && Equals(_lockRecoveryService?.RecoverableLock, lockInfo))
+ {
+ HasRecoverableLock.Value = true;
+ }
+ });
+ }
+
+ private void ApplyStatus(WorkspaceStatus status)
+ {
+ IsTracked.Value = _service?.Repository is not null;
+ IsConflicted.Value = status.HasConflicts;
+ HasBlockingGuidance.Value = IsUnavailable.Value || status.HasConflicts;
+ if (status.HasConflicts)
+ {
+ StatusMessage.Value = Strings.VersionControl_ConflictGuidance;
+ HasMoreHistory.Value = false;
+ }
+ else if (_historyIdentity is not null)
+ {
+ HasMoreHistory.Value = _hasMoreHistory;
+ UpdateHistoryStatusMessage();
+ }
+
+ _aheadCount = status.Ahead;
+ _behindCount = status.Behind;
+ _hasUncommittedChanges = !status.IsClean;
+ DirtySummary.Value = status.IsClean
+ ? Strings.VersionControl_WorktreeClean
+ : string.Format(
+ CultureInfo.CurrentCulture,
+ Strings.VersionControl_DirtySummaryFormat,
+ status.Changes.Count);
+ UpdatePrimaryAction();
+ }
+
+ private void UpdatePrimaryAction()
+ {
+ VersionControlPrimaryAction action = IsRemoteOperationRunning.Value
+ ? new(
+ VersionControlPrimaryActionKind.Cancel,
+ Strings.Cancel,
+ CancelRemoteOperationCommand)
+ : _hasUncommittedChanges
+ ? new(
+ VersionControlPrimaryActionKind.Commit,
+ Strings.VersionControl_CommitNow,
+ CommitCommand)
+ : _behindCount > 0
+ ? new(
+ VersionControlPrimaryActionKind.Pull,
+ string.Format(
+ CultureInfo.CurrentCulture,
+ Strings.VersionControl_PullCountFormat,
+ _behindCount),
+ PullCommand)
+ : _aheadCount > 0
+ ? new(
+ VersionControlPrimaryActionKind.Push,
+ string.Format(
+ CultureInfo.CurrentCulture,
+ Strings.VersionControl_PushCountFormat,
+ _aheadCount),
+ PushCommand)
+ : HasRemote.Value
+ ? new(
+ VersionControlPrimaryActionKind.UpToDate,
+ Strings.VersionControl_UpToDate,
+ _disabledPrimaryActionCommand)
+ : new(
+ VersionControlPrimaryActionKind.PublishBranch,
+ Strings.VersionControl_PublishBranch,
+ PublishBranchCommand);
+ ObservePrimaryAction(action);
+ }
+
+ private void ObservePrimaryAction(VersionControlPrimaryAction action)
+ {
+ if (_observedPrimaryActionCommand is not null)
+ {
+ _observedPrimaryActionCommand.CanExecuteChanged -=
+ OnPrimaryActionCanExecuteChanged;
+ }
+
+ _primaryAction.Value = action;
+ _observedPrimaryActionCommand = action.Command;
+ _observedPrimaryActionCommand.CanExecuteChanged +=
+ OnPrimaryActionCanExecuteChanged;
+ UpdatePrimaryActionCanExecute();
+ }
+
+ private void OnPrimaryActionCanExecuteChanged(object? sender, EventArgs e)
+ {
+ UpdatePrimaryActionCanExecute();
+ }
+
+ private void UpdatePrimaryActionCanExecute()
+ {
+ _isPrimaryActionEnabled.Value =
+ PrimaryAction.Value.Command.CanExecute(null);
+ }
+
+ private void InvokePrimaryAction()
+ {
+ VersionControlPrimaryAction action = PrimaryAction.Value;
+ if (action.Command.CanExecute(null))
+ {
+ action.Command.Execute(null);
+ }
+ }
+
+ private CancellationToken ReplaceSelectionCancellation()
+ {
+ _selectionCancellation?.Cancel();
+ _selectionCancellation?.Dispose();
+ _selectionCancellation = new CancellationTokenSource();
+ return _selectionCancellation.Token;
+ }
+
+ private async Task RunRemoteOperationAsync(
+ Func, CancellationToken, Task> operation,
+ string initialProgress,
+ RemoteMutationLease? lease = null,
+ TaskCompletionSource? completionOverride = null)
+ {
+ if (_versionControlCoordinator is null
+ || _service is null
+ || _disposed)
+ {
+ return;
+ }
+
+ bool ownsMutation = lease is null;
+ RemoteMutationLease? operationLease = lease ?? TryAcquireRemoteMutation();
+ if (operationLease is null)
+ {
+ return;
+ }
+
+ CancellationTokenSource operationCancellation = new();
+ IProjectVersionControlService operationService = _service;
+ int operationRevision = _serviceRevision;
+ int operationGeneration = Interlocked.Increment(ref _remoteOperationGeneration);
+ TaskCompletionSource operationCompletion = completionOverride ?? new(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ if (completionOverride is null)
+ {
+ _remoteOperationCompletion = operationCompletion;
+ }
+ bool operationFinished = false;
+ CancellationTokenSource? previous = Interlocked.Exchange(
+ ref _remoteOperationCancellation,
+ operationCancellation);
+ previous?.Dispose();
+ Volatile.Write(ref _remoteOperationUserCancellation, 0);
+ IsRemoteOperationRunning.Value = true;
+ RemoteProgress.Value = initialProgress;
+ CancellationToken serviceBindingToken;
+ try
+ {
+ serviceBindingToken = _serviceBindingCancellation?.Token
+ ?? CancellationToken.None;
+ }
+ catch (ObjectDisposedException)
+ {
+ serviceBindingToken = new CancellationToken(canceled: true);
+ }
+ bool IsCurrentOperation() =>
+ !operationFinished
+ && operationGeneration == Volatile.Read(ref _remoteOperationGeneration)
+ && ReferenceEquals(
+ operationCancellation,
+ Volatile.Read(ref _remoteOperationCancellation))
+ && !operationCancellation.IsCancellationRequested
+ && operationService is not null
+ && IsCurrentService(operationService, operationRevision, serviceBindingToken);
+ bool IsCurrentOperationForCancellation() =>
+ operationGeneration == Volatile.Read(ref _remoteOperationGeneration)
+ && ReferenceEquals(
+ operationCancellation,
+ Volatile.Read(ref _remoteOperationCancellation))
+ && operationService is not null
+ && IsCurrentServiceIgnoringCancellation(
+ operationService,
+ operationRevision,
+ serviceBindingToken);
+ var progress = new CallbackProgress(value =>
+ {
+ if (IsCurrentOperation())
+ {
+ _postToUi(() =>
+ {
+ if (IsCurrentOperation())
+ {
+ RemoteProgress.Value = value;
+ }
+ });
+ }
+ });
+ try
+ {
+ RemoteOpResult result = await operation(
+ progress,
+ operationCancellation.Token);
+ if (!IsCurrentOperation())
+ {
+ return;
+ }
+ if (result is RemoteOpResult.Success)
+ {
+ await RefreshRemotesAsync(
+ operationService,
+ operationCancellation.Token,
+ serviceRevision: operationRevision,
+ freshness: IsCurrentOperation);
+ if (!IsCurrentOperation())
+ {
+ if (operationCancellation.IsCancellationRequested
+ && Volatile.Read(ref _remoteOperationUserCancellation) != 0
+ && IsCurrentOperationForCancellation())
+ {
+ StatusMessage.Value = Strings.VersionControl_RemoteOperationCanceled;
+ }
+ return;
+ }
+ StatusMessage.Value = Strings.VersionControl_RemoteOperationSucceeded;
+ }
+ else if (result is not RemoteOpResult.Failed { Stderr.Length: 0 })
+ {
+ await DispatchRemoteResultAsync(
+ result,
+ IsCurrentOperation,
+ operationCancellation.Token);
+ }
+ }
+ catch (VersionControlConflictedException ex)
+ {
+ if (!IsCurrentOperation())
+ {
+ return;
+ }
+ StatusMessage.Value = ex.Guidance;
+ NotificationService.ShowError(Strings.VersionControl_ErrorTitle, ex.Guidance);
+ }
+ catch (OperationCanceledException) when (operationCancellation.IsCancellationRequested)
+ {
+ if (Volatile.Read(ref _remoteOperationUserCancellation) != 0)
+ {
+ if (IsCurrentOperationForCancellation())
+ {
+ StatusMessage.Value = Strings.VersionControl_RemoteOperationCanceled;
+ }
+ }
+ }
+ catch (OperationCanceledException) when (serviceBindingToken.IsCancellationRequested)
+ {
+ }
+ catch (Exception ex)
+ {
+ if (!IsCurrentOperation())
+ {
+ return;
+ }
+ _logger.LogError(ex, "The remote operation command failed.");
+ NotificationService.ShowError(
+ Strings.VersionControl_ErrorTitle,
+ MessageStrings.OperationFailed);
+ }
+ finally
+ {
+ bool isCurrentOperation = ReferenceEquals(
+ operationCancellation,
+ Volatile.Read(ref _remoteOperationCancellation));
+ operationFinished = true;
+ if (isCurrentOperation)
+ {
+ if (!_disposed)
+ {
+ IsRemoteOperationRunning.Value = false;
+ }
+ Interlocked.CompareExchange(
+ ref _remoteOperationCancellation,
+ null,
+ operationCancellation);
+ }
+ operationCancellation.Dispose();
+ operationCompletion.TrySetResult();
+ if (ownsMutation)
+ {
+ operationLease.Release();
+ }
+ }
+ }
+
+ private void CancelRemoteOperation()
+ {
+ Volatile.Write(ref _remoteOperationUserCancellation, 1);
+ TryCancel(Volatile.Read(ref _remoteOperationCancellation));
+ }
+
+ private Task DispatchRemoteResultAsync(
+ RemoteOpResult result,
+ Func isCurrentOperation,
+ CancellationToken cancellationToken)
+ {
+ var completion = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ CancellationTokenRegistration registration = default;
+ try
+ {
+ registration = cancellationToken.Register(
+ static state =>
+ {
+ ((TaskCompletionSource)state!).TrySetCanceled();
+ },
+ completion);
+ _postToUi(() => _ = DispatchRemoteResultCoreAsync(
+ result,
+ isCurrentOperation,
+ completion,
+ cancellationToken));
+ }
+ catch (Exception ex)
+ {
+ completion.TrySetException(ex);
+ }
+
+ return AwaitDispatchCompletionAsync(completion.Task, registration);
+ }
+
+ private static async Task AwaitDispatchCompletionAsync(
+ Task completion,
+ CancellationTokenRegistration registration)
+ {
+ try
+ {
+ await completion;
+ }
+ finally
+ {
+ registration.Dispose();
+ }
+ }
+
+ private async Task DispatchRemoteResultCoreAsync(
+ RemoteOpResult result,
+ Func isCurrentOperation,
+ TaskCompletionSource completion,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ if (!completion.Task.IsCompleted
+ && !cancellationToken.IsCancellationRequested
+ && isCurrentOperation())
+ {
+ await ShowRemoteResultAsync(result);
+ }
+
+ completion.TrySetResult();
+ }
+ catch (Exception ex)
+ {
+ completion.TrySetException(ex);
+ }
+ }
+
+ private static void TryCancel(CancellationTokenSource? cancellation)
+ {
+ try
+ {
+ cancellation?.Cancel();
+ }
+ catch (ObjectDisposedException)
+ {
+ }
+ }
+
+ private sealed class RemoteMutationLease(VersionControlTabViewModel owner)
+ {
+ private int _released;
+
+ public void Release()
+ {
+ if (Interlocked.Exchange(ref _released, 1) == 0)
+ {
+ Interlocked.CompareExchange(ref owner._remoteMutationOwner, null, this);
+ }
+ }
+ }
+
+ private bool IsCurrentServiceIgnoringCancellation(
+ IProjectVersionControlService service,
+ int revision,
+ CancellationToken cancellationToken)
+ {
+ return !_disposed
+ && !cancellationToken.IsCancellationRequested
+ && revision == _serviceRevision
+ && ReferenceEquals(service, _service);
+ }
+
+ private static void PostToUiThread(Action action)
+ {
+ if (Dispatcher.UIThread.CheckAccess())
+ {
+ action();
+ }
+ else
+ {
+ Dispatcher.UIThread.Post(action);
+ }
+ }
+
+ internal static string GetRemoteResultMessage(RemoteOpResult result)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+ return result switch
+ {
+ RemoteOpResult.AuthFailed authFailed => authFailed.Guidance,
+ RemoteOpResult.Diverged => Strings.VersionControl_Diverged,
+ RemoteOpResult.Offline => Strings.VersionControl_Offline,
+ RemoteOpResult.RepositoryDirty => Strings.VersionControl_RepositoryDirty,
+ RemoteOpResult.Failed failed => failed.Stderr,
+ _ => string.Empty,
+ };
+ }
+
+ private static Task ShowRemoteResultNotificationAsync(RemoteOpResult result)
+ {
+ string message = GetRemoteResultMessage(result);
+ if (!string.IsNullOrWhiteSpace(message))
+ {
+ NotificationService.ShowError(
+ Strings.VersionControl_ErrorTitle,
+ message);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ private readonly record struct HistoryIdentity(string? Branch, string? TipSha);
+
+ private sealed class CallbackProgress(Action callback) : IProgress
+ {
+ public void Report(T value)
+ {
+ callback(value);
+ }
+ }
+}
+
+internal sealed class VersionControlRelativeTimeFormatter
+{
+ private static readonly ResourceManager s_resourceManager =
+ new("Beutl.Language.Strings", typeof(Strings).Assembly);
+
+ private readonly TimeProvider _timeProvider;
+ private readonly CultureInfo _culture;
+
+ public VersionControlRelativeTimeFormatter(
+ TimeProvider timeProvider,
+ CultureInfo culture)
+ {
+ _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
+ _culture = culture ?? throw new ArgumentNullException(nameof(culture));
+ }
+
+ public string Format(DateTimeOffset timestamp)
+ {
+ TimeSpan elapsed = _timeProvider.GetUtcNow() - timestamp.ToUniversalTime();
+ if (elapsed < TimeSpan.FromMinutes(1))
+ {
+ return GetString("VersionControl_TimeJustNow");
+ }
+
+ int minutes = (int)Math.Floor(elapsed.TotalMinutes);
+ if (minutes < 60)
+ {
+ return minutes == 1
+ ? GetString("VersionControl_TimeMinuteAgo")
+ : FormatCount("VersionControl_TimeMinutesAgoFormat", minutes);
+ }
+
+ int hours = (int)Math.Floor(elapsed.TotalHours);
+ if (hours < 24)
+ {
+ return hours == 1
+ ? GetString("VersionControl_TimeHourAgo")
+ : FormatCount("VersionControl_TimeHoursAgoFormat", hours);
+ }
+
+ int days = (int)Math.Floor(elapsed.TotalDays);
+ return days == 1
+ ? GetString("VersionControl_TimeDayAgo")
+ : FormatCount("VersionControl_TimeDaysAgoFormat", days);
+ }
+
+ public string FormatAbsoluteLocal(DateTimeOffset timestamp)
+ {
+ return TimeZoneInfo.ConvertTime(timestamp, _timeProvider.LocalTimeZone)
+ .ToString("g", _culture);
+ }
+
+ private string FormatCount(string key, int value)
+ {
+ return string.Format(_culture, GetString(key), value);
+ }
+
+ private string GetString(string key)
+ {
+ return s_resourceManager.GetString(key, _culture)
+ ?? throw new MissingManifestResourceException(
+ $"The localized resource '{key}' is missing.");
+ }
+}
+
+public sealed class VersionControlCommitViewModel : IDisposable
+{
+ private readonly VersionControlTabViewModel _owner;
+
+ internal VersionControlCommitViewModel(
+ VersionControlTabViewModel owner,
+ CommitInfo commit,
+ VersionControlRelativeTimeFormatter relativeTimeFormatter)
+ {
+ _owner = owner;
+ Commit = commit;
+ KindText = GetKindText(commit.Kind);
+ DisplayMessage = commit.Subject;
+ AuthorAndRelativeDate = string.Format(
+ CultureInfo.CurrentCulture,
+ "{0} · {1}",
+ commit.AuthorName,
+ relativeTimeFormatter.Format(commit.AuthorDate));
+ AbsoluteLocalDate = relativeTimeFormatter.FormatAbsoluteLocal(commit.AuthorDate);
+ RestoreCommand = new AsyncReactiveCommand()
+ .WithSubscribe(() => _owner.RestoreAsync(Commit));
+ RestoreToNewBranchCommand = new AsyncReactiveCommand()
+ .WithSubscribe(() => _owner.RestoreToNewBranchAsync(Commit));
+ }
+
+ public CommitInfo Commit { get; }
+
+ public string KindText { get; }
+
+ public bool IsManual => Commit.Kind == SnapshotKind.Manual;
+
+ public bool IsSave => Commit.Kind == SnapshotKind.Save;
+
+ public bool IsClose => Commit.Kind == SnapshotKind.Close;
+
+ public bool IsSafety => Commit.Kind == SnapshotKind.Safety;
+
+ public bool IsRestore => Commit.Kind is SnapshotKind.Restore or SnapshotKind.Recovery;
+
+ public bool IsInit => Commit.Kind == SnapshotKind.Init;
+
+ public string DisplayMessage { get; }
+
+ public string AuthorAndRelativeDate { get; }
+
+ public string AbsoluteLocalDate { get; }
+
+ public AsyncReactiveCommand RestoreCommand { get; }
+
+ public AsyncReactiveCommand RestoreToNewBranchCommand { get; }
+
+ public void Dispose()
+ {
+ RestoreCommand.Dispose();
+ RestoreToNewBranchCommand.Dispose();
+ }
+
+ private static string GetKindText(SnapshotKind kind)
+ {
+ return kind switch
+ {
+ SnapshotKind.Save => Strings.VersionControl_SnapshotSave,
+ SnapshotKind.Close => Strings.VersionControl_SnapshotClose,
+ SnapshotKind.Safety => Strings.VersionControl_SnapshotSafety,
+ SnapshotKind.Restore => Strings.VersionControl_SnapshotRestore,
+ SnapshotKind.Recovery => Strings.VersionControl_SnapshotRecovery,
+ SnapshotKind.Init => Strings.VersionControl_SnapshotInit,
+ _ => Strings.VersionControl_SnapshotManual,
+ };
+ }
+}
+
+public sealed class VersionControlFileChangeViewModel
+{
+ public VersionControlFileChangeViewModel(FileChange change)
+ {
+ Change = change;
+ }
+
+ public FileChange Change { get; }
+
+ public string StatusText => Change.Status switch
+ {
+ FileChangeStatus.Added => "A",
+ FileChangeStatus.Deleted => "D",
+ FileChangeStatus.Renamed => "R",
+ _ => "M",
+ };
+
+ public string PathText => Change.OldPath is null
+ ? Change.Path
+ : $"{Change.OldPath} → {Change.Path}";
+}
+
+public enum VersionControlDiffLineKind
+{
+ Context,
+ Added,
+ Removed,
+ Header,
+}
+
+public sealed record VersionControlDiffLineViewModel(
+ string Text,
+ VersionControlDiffLineKind Kind)
+{
+ public bool IsAdded => Kind == VersionControlDiffLineKind.Added;
+
+ public bool IsRemoved => Kind == VersionControlDiffLineKind.Removed;
+
+ public bool IsHeader => Kind == VersionControlDiffLineKind.Header;
+
+ public static IReadOnlyList Parse(string diff)
+ {
+ ArgumentNullException.ThrowIfNull(diff);
+ return diff.Replace("\r\n", "\n", StringComparison.Ordinal)
+ .Split('\n')
+ .Select(line => new VersionControlDiffLineViewModel(line, GetKind(line)))
+ .ToArray();
+ }
+
+ private static VersionControlDiffLineKind GetKind(string line)
+ {
+ if (line.StartsWith("+++", StringComparison.Ordinal)
+ || line.StartsWith("---", StringComparison.Ordinal)
+ || line.StartsWith("@@", StringComparison.Ordinal)
+ || line.StartsWith("diff ", StringComparison.Ordinal)
+ || line.StartsWith("index ", StringComparison.Ordinal))
+ {
+ return VersionControlDiffLineKind.Header;
+ }
+
+ if (line.StartsWith("+", StringComparison.Ordinal))
+ {
+ return VersionControlDiffLineKind.Added;
+ }
+
+ if (line.StartsWith("-", StringComparison.Ordinal))
+ {
+ return VersionControlDiffLineKind.Removed;
+ }
+
+ return VersionControlDiffLineKind.Context;
+ }
+}
diff --git a/src/Beutl.Editor.Components/VersionControlTab/Views/VersionControlChangesView.axaml b/src/Beutl.Editor.Components/VersionControlTab/Views/VersionControlChangesView.axaml
new file mode 100644
index 0000000000..21e697b077
--- /dev/null
+++ b/src/Beutl.Editor.Components/VersionControlTab/Views/VersionControlChangesView.axaml
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Beutl.Editor.Components/VersionControlTab/Views/VersionControlChangesView.axaml.cs b/src/Beutl.Editor.Components/VersionControlTab/Views/VersionControlChangesView.axaml.cs
new file mode 100644
index 0000000000..c6e7ec32a6
--- /dev/null
+++ b/src/Beutl.Editor.Components/VersionControlTab/Views/VersionControlChangesView.axaml.cs
@@ -0,0 +1,79 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Beutl.Editor.Components.VersionControlTab.ViewModels;
+using Beutl.Language;
+using Beutl.Services;
+
+namespace Beutl.Editor.Components.VersionControlTab.Views;
+
+internal sealed partial class VersionControlChangesView : UserControl
+{
+ public VersionControlChangesView()
+ {
+ InitializeComponent();
+ }
+
+ private async void OnChangedFileSelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ if (IsEffectivelyVisible
+ && DataContext is VersionControlTabViewModel viewModel
+ && sender is ListBox listBox)
+ {
+ await VersionControlViewEventBoundary.RunSafelyAsync(() => viewModel.SelectFileAsync(
+ listBox.SelectedItem as VersionControlFileChangeViewModel));
+ }
+ }
+
+ private async void OnRestoreClick(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is VersionControlTabViewModel
+ {
+ SelectedCommit.Value: { } selectedCommit,
+ } viewModel)
+ {
+ await VersionControlViewEventBoundary.RunSafelyAsync(
+ () => viewModel.RestoreAsync(selectedCommit.Commit));
+ }
+ }
+
+ private async void OnRestoreToNewBranchClick(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is VersionControlTabViewModel
+ {
+ SelectedCommit.Value: { } selectedCommit,
+ } viewModel)
+ {
+ await VersionControlViewEventBoundary.RunSafelyAsync(
+ () => viewModel.RestoreToNewBranchAsync(selectedCommit.Commit));
+ }
+ }
+}
+
+internal static class VersionControlViewEventBoundary
+{
+ internal static Task RunSafelyAsync(Func operation)
+ {
+ return RunSafelyAsync(
+ operation,
+ static exception => NotificationService.ShowError(
+ Strings.VersionControl_ErrorTitle,
+ exception.Message));
+ }
+
+ internal static async Task RunSafelyAsync(
+ Func operation,
+ Action reportException)
+ {
+ try
+ {
+ await operation();
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ catch (Exception ex)
+ {
+ reportException(ex);
+ }
+ }
+}
diff --git a/src/Beutl.Editor.Components/VersionControlTab/Views/VersionControlDetailHeaderView.axaml b/src/Beutl.Editor.Components/VersionControlTab/Views/VersionControlDetailHeaderView.axaml
new file mode 100644
index 0000000000..36a3c2cfde
--- /dev/null
+++ b/src/Beutl.Editor.Components/VersionControlTab/Views/VersionControlDetailHeaderView.axaml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Beutl.Editor.Components/VersionControlTab/Views/VersionControlDetailHeaderView.axaml.cs b/src/Beutl.Editor.Components/VersionControlTab/Views/VersionControlDetailHeaderView.axaml.cs
new file mode 100644
index 0000000000..4e99493874
--- /dev/null
+++ b/src/Beutl.Editor.Components/VersionControlTab/Views/VersionControlDetailHeaderView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace Beutl.Editor.Components.VersionControlTab.Views;
+
+internal sealed partial class VersionControlDetailHeaderView : UserControl
+{
+ public VersionControlDetailHeaderView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/src/Beutl.Editor.Components/VersionControlTab/Views/VersionControlHistoryView.axaml b/src/Beutl.Editor.Components/VersionControlTab/Views/VersionControlHistoryView.axaml
new file mode 100644
index 0000000000..de767fdec0
--- /dev/null
+++ b/src/Beutl.Editor.Components/VersionControlTab/Views/VersionControlHistoryView.axaml
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+