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 @@ 150 1,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.99669Z M8.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 2Z M13.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/VersionControlTabView.axaml.cs b/src/Beutl.Editor.Components/VersionControlTab/Views/VersionControlTabView.axaml.cs new file mode 100644 index 0000000000..990583b8fa --- /dev/null +++ b/src/Beutl.Editor.Components/VersionControlTab/Views/VersionControlTabView.axaml.cs @@ -0,0 +1,157 @@ +using System.Windows.Input; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Beutl.Editor.Components.VersionControl.Views; +using Beutl.Editor.Components.VersionControlTab.ViewModels; +using Beutl.Editor.VersionControl; +using Beutl.Extensibility; +using Beutl.Language; + +namespace Beutl.Editor.Components.VersionControlTab.Views; + +public sealed partial class VersionControlTabView : UserControl +{ + public static readonly StyledProperty IsNarrowLayoutProperty = + AvaloniaProperty.Register( + nameof(IsNarrowLayout), + defaultValue: true); + + internal VersionControlPickerFlyout PromptFlyout { get; } = new(); + + public VersionControlTabView() + { + InitializeComponent(); + AddHandler( + KeyDownEvent, + OnCommitMessageKeyDown, + RoutingStrategies.Tunnel); + DataContextChanged += OnDataContextChanged; + SizeChanged += OnSizeChanged; + ConfigureCallbacks(); + } + + public bool IsNarrowLayout + { + get => GetValue(IsNarrowLayoutProperty); + private set => SetValue(IsNarrowLayoutProperty, value); + } + + private void OnSizeChanged(object? sender, SizeChangedEventArgs e) + { + UpdateLayoutMode(e.NewSize.Width); + } + + private void OnDataContextChanged(object? sender, EventArgs e) + { + ConfigureCallbacks(); + } + + private void ConfigureCallbacks() + { + if (DataContext is VersionControlTabViewModel viewModel) + { + viewModel.RequestEnableVersionControlAsync = ExecuteEnableVersionControlAsync; + viewModel.RequestBranchNameAsync = RequestBranchNameAsync; + viewModel.RequestRemoteUrlAsync = RequestRemoteUrlAsync; + viewModel.LaunchUriAsync = LaunchUriAsync; + } + + UpdateLayoutMode(Bounds.Width); + } + + private void UpdateLayoutMode(double availableWidth) + { + IsNarrowLayout = VersionControlTabLayout.IsNarrow(availableWidth); + } + + private void OnCommitMessageKeyDown(object? sender, KeyEventArgs e) + { + if (!CommitMessageTextBox.IsKeyboardFocusWithin) + { + return; + } + + ICommand? commitCommand = + (DataContext as VersionControlTabViewModel)?.CommitCommand; + if (TryExecuteCommitShortcut(e.Key, e.KeyModifiers, commitCommand)) + { + e.Handled = true; + } + } + + internal static bool TryExecuteCommitShortcut( + Key key, + KeyModifiers modifiers, + ICommand? command) + { + if (key is not (Key.Enter or Key.Return) + || modifiers is not (KeyModifiers.Control or KeyModifiers.Meta)) + { + return false; + } + + if (command?.CanExecute(null) == true) + { + command.Execute(null); + } + + return true; + } + + private Task ExecuteEnableVersionControlAsync() + { + return RequestContextCommandAsync( + TopLevel.GetTopLevel(this)?.DataContext as IContextCommandHandler, + "EnableVersionControl"); + } + + internal static Task RequestContextCommandAsync( + IContextCommandHandler? handler, + string commandName) + { + if (handler is null) + { + return Task.CompletedTask; + } + + var execution = new ContextCommandExecution(commandName); + if (!handler.CanExecute(execution)) + { + return Task.CompletedTask; + } + + handler.Execute(execution); + return execution.Completion; + } + + private Task LaunchUriAsync(Uri uri) + { + return TopLevel.GetTopLevel(this)?.Launcher.LaunchUriAsync(uri) + ?? Task.FromResult(false); + } + + private Task RequestBranchNameAsync(CommitInfo commit) + { + PrimaryActionSplitButton.Flyout?.Hide(); + return PromptFlyout.ShowTextInputAsync( + PrimaryActionSplitButton, + Strings.VersionControl_CreateBranchTitle, + Strings.VersionControl_BranchName, + $"restore-{commit.ShortSha}"); + } + + private Task RequestRemoteUrlAsync( + string? currentRemoteUrl, + CancellationToken cancellationToken) + { + PrimaryActionSplitButton.Flyout?.Hide(); + return PromptFlyout.ShowTextInputAsync( + PrimaryActionSplitButton, + Strings.VersionControl_SetRemoteTitle, + Strings.VersionControl_RemoteUrl, + currentRemoteUrl, + cancellationToken); + } +} diff --git a/src/Beutl.Editor/AutoSaveService.cs b/src/Beutl.Editor/AutoSaveService.cs index 5082bb0456..ca2d17b1b5 100644 --- a/src/Beutl.Editor/AutoSaveService.cs +++ b/src/Beutl.Editor/AutoSaveService.cs @@ -33,7 +33,6 @@ public void AutoSave(IEnumerable operations) public void SaveObjects(IEnumerable objectsToSave) { ThrowIfDisposed(); - // 各オブジェクトを保存 foreach (CoreObject obj in objectsToSave) { diff --git a/src/Beutl.Editor/Beutl.Editor.csproj b/src/Beutl.Editor/Beutl.Editor.csproj index 539d9d03ba..02577ca63c 100644 --- a/src/Beutl.Editor/Beutl.Editor.csproj +++ b/src/Beutl.Editor/Beutl.Editor.csproj @@ -6,6 +6,8 @@ + + diff --git a/src/Beutl.Editor/ExternalResourceCollector.cs b/src/Beutl.Editor/ExternalResourceCollector.cs index d1f5b1da83..a7ebe6efe2 100644 --- a/src/Beutl.Editor/ExternalResourceCollector.cs +++ b/src/Beutl.Editor/ExternalResourceCollector.cs @@ -1,6 +1,20 @@ -using Beutl.Engine; +using System.Buffers.Binary; +using System.Collections; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Beutl.Engine; +using Beutl.Extensibility; +using Beutl.Graphics; using Beutl.IO; using Beutl.Media; +using Beutl.NodeGraph; +using Beutl.Serialization; namespace Beutl.Editor; @@ -11,6 +25,8 @@ public sealed class ExternalResourceCollector { private readonly HashSet<(Guid Object, string PropertyName, Uri OriginalUri)> _fileSources = []; private readonly HashSet _fontFamilies = []; + private readonly HashSet _unaddressableFileSources = []; + private readonly HashSet _relocationOwners = new(ReferenceEqualityComparer.Instance); private ExternalResourceCollector() { @@ -26,6 +42,10 @@ private ExternalResourceCollector() /// public IEnumerable FontFamilies => _fontFamilies; + internal IReadOnlySet UnaddressableFileSources => _unaddressableFileSources; + + internal IReadOnlySet RelocationOwners => _relocationOwners; + /// /// Collects all resource references within the hierarchy. /// @@ -33,37 +53,67 @@ private ExternalResourceCollector() /// The path of the project directory. /// The collected resource information. public static ExternalResourceCollector Collect(IHierarchical root, string projectDirectory) + { + return Collect(root, projectDirectory, stagedStorageObjects: null); + } + + internal static ExternalResourceCollector Collect( + IHierarchical root, + string projectDirectory, + IReadOnlySet? stagedStorageObjects) { ArgumentNullException.ThrowIfNull(root); ArgumentNullException.ThrowIfNull(projectDirectory); - ExternalResourceCollector collector = new(); + return Collect(DiscoverSerializationGraph(root), projectDirectory, stagedStorageObjects); + } - // Traverse all EngineObjects within the hierarchy - foreach (CoreObject obj in root.EnumerateAllChildren()) + internal static ExternalResourceCollector Collect( + SerializationGraph graph, + string projectDirectory, + IReadOnlySet? stagedStorageObjects) + { + ExternalResourceCollector collector = new(); + foreach (CoreObject obj in graph.Objects) { - collector.CollectFromObject(obj, projectDirectory); + collector.CollectFromObject(obj, projectDirectory, stagedStorageObjects); } - // Also process the root itself if it is a CoreObject - if (root is CoreObject rootObj) - { - collector.CollectFromObject(rootObj, projectDirectory); - } + collector._fontFamilies.UnionWith(graph.FontFamilies); + collector._unaddressableFileSources.UnionWith( + graph.UnaddressableFileSources.Where(uri => ShouldRelocateFile(uri, projectDirectory))); return collector; } - private void CollectFromObject(CoreObject obj, string projectDirectory) + internal static SerializationGraph DiscoverSerializationGraph(IHierarchical root) + { + ArgumentNullException.ThrowIfNull(root); + + var visitor = new SerializationGraphVisitor(); + visitor.Visit(root); + return new SerializationGraph( + visitor.Objects, + visitor.UnaddressableFileSources, + visitor.AddressableFileSources, + visitor.FontFamilies); + } + + private void CollectFromObject( + CoreObject obj, + string projectDirectory, + IReadOnlySet? stagedStorageObjects) { if (obj is EngineObject engineObj) { CollectFromEngineObject(engineObj, projectDirectory); } - if (obj.Uri != null && IsExternalFile(obj.Uri, projectDirectory)) + if (obj.Uri != null + && stagedStorageObjects?.Contains(obj) != true + && ShouldRelocateFile(obj.Uri, projectDirectory)) { - _fileSources.Add((obj.Id, "Uri", obj.Uri)); + AddFileSource(obj, "Uri", obj.Uri); } var props = PropertyRegistry.GetRegistered(obj.GetType()); @@ -74,9 +124,9 @@ private void CollectFromObject(CoreObject obj, string projectDirectory) switch (value) { case IFileSource fileSource: - if (fileSource.Uri != null && IsExternalFile(fileSource.Uri, projectDirectory)) + if (fileSource.Uri != null && ShouldRelocateFile(fileSource.Uri, projectDirectory)) { - _fileSources.Add((obj.Id, prop.Name, fileSource.Uri)); + AddFileSource(obj, prop.Name, fileSource.Uri); } break; @@ -95,9 +145,9 @@ private void CollectFromEngineObject(EngineObject obj, string projectDirectory) { // Collect IFileSource case IFileSource fileSource when fileSource.Uri != null: - if (IsExternalFile(fileSource.Uri, projectDirectory)) + if (ShouldRelocateFile(fileSource.Uri, projectDirectory)) { - _fileSources.Add((obj.Id, property.Name, fileSource.Uri)); + AddFileSource(obj, property.Name, fileSource.Uri); } break; @@ -109,20 +159,1921 @@ private void CollectFromEngineObject(EngineObject obj, string projectDirectory) } } + private void AddFileSource(CoreObject owner, string propertyName, Uri uri) + { + _fileSources.Add((owner.Id, propertyName, uri)); + _relocationOwners.Add(owner); + } + /// - /// Determines whether the URI points to a file outside the project directory. + /// Determines whether the URI must be copied into the package's resources directory. /// - private static bool IsExternalFile(Uri uri, string projectDirectory) + private static bool ShouldRelocateFile(Uri uri, string projectDirectory) + { + if (!uri.IsFile) + return false; + + string filePath = Path.GetFullPath(uri.LocalPath); + string fullProjectPath = Path.GetFullPath(projectDirectory); + string relativePath = Path.GetRelativePath(fullProjectPath, filePath); + + // Files outside the project directory are considered external. + if (Path.IsPathRooted(relativePath) + || relativePath == ".." + || relativePath.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal)) + { + return true; + } + + if (ContainsReservedPath(relativePath)) + { + return true; + } + + // Directory staging deliberately skips links. A referenced file that is itself a link, + // or lives below a linked directory, must therefore go through the regular relocation + // path so only that referenced target is materialized in resources. Inspect each lexical + // component without resolving targets, which also identifies broken links and cycles. + string currentPath = fullProjectPath; + string[] segments = relativePath.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries); + for (int i = 0; i < segments.Length; i++) + { + currentPath = Path.Combine(currentPath, segments[i]); + FileSystemInfo info = i == segments.Length - 1 + ? new FileInfo(currentPath) + : new DirectoryInfo(currentPath); + + try + { + if (info.LinkTarget is not null) + { + return true; + } + } + catch (Exception ex) + when (ex is IOException + or UnauthorizedAccessException + or NotSupportedException) + { + // Conservatively relocate when link inspection is unavailable. The relocation + // service will either copy the referenced file or report it as a partial failure. + return true; + } + } + + return false; + } + + internal static bool IsInReservedProjectPath(Uri uri, string projectDirectory) { if (!uri.IsFile) + { return false; + } - string filePath = uri.LocalPath; + string filePath = Path.GetFullPath(uri.LocalPath); string fullProjectPath = Path.GetFullPath(projectDirectory); - if (!fullProjectPath.EndsWith(Path.DirectorySeparatorChar)) - fullProjectPath += Path.DirectorySeparatorChar; + string relativePath = Path.GetRelativePath(fullProjectPath, filePath); + if (Path.IsPathFullyQualified(relativePath) + || relativePath == ".." + || relativePath.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal) + || relativePath.StartsWith($"..{Path.AltDirectorySeparatorChar}", StringComparison.Ordinal)) + { + return false; + } + + return ContainsReservedPath(relativePath); + } - // Files outside the project directory are considered external - return !Path.GetFullPath(filePath).StartsWith(fullProjectPath, StringComparison.OrdinalIgnoreCase); + private static bool ContainsReservedPath(string relativePath) + { + string[] segments = relativePath.Split( + ['/', '\\'], + StringSplitOptions.RemoveEmptyEntries); + return segments.Any(static segment => + { + int streamSeparator = segment.IndexOf(':'); + string portableName = (streamSeparator >= 0 ? segment[..streamSeparator] : segment) + .TrimEnd(' ', '.'); + return string.Equals(portableName, ".git", StringComparison.OrdinalIgnoreCase) + || string.Equals(portableName, ".beutl", StringComparison.OrdinalIgnoreCase); + }); } + + private sealed class SerializationGraphVisitor + { + private readonly List _objects = []; + private readonly HashSet _unaddressableFileSources = []; + private readonly HashSet _addressableFileSources = []; + private readonly HashSet _fontFamilies = []; + private readonly HashSet _visitedCoreObjects = new(ReferenceEqualityComparer.Instance); + private readonly HashSet _visitedCoreCollections = new(ReferenceEqualityComparer.Instance); + private readonly Dictionary> _visitedContracts + = new(ReferenceEqualityComparer.Instance); + private readonly JsonSerializerOptions _passthroughOptions; + private readonly JsonSerializerOptions _captureOptions; + + public SerializationGraphVisitor() + { + _passthroughOptions = new JsonSerializerOptions(JsonHelper.SerializerOptions); + _captureOptions = new JsonSerializerOptions(_passthroughOptions); + _captureOptions.Converters.Insert( + 0, + new CaptureJsonConverterFactory(this, _passthroughOptions)); + _passthroughOptions.MakeReadOnly(populateMissingResolver: true); + } + + public IReadOnlyList Objects => _objects; + + public IReadOnlySet UnaddressableFileSources => _unaddressableFileSources; + + public IReadOnlySet AddressableFileSources => _addressableFileSources; + + public IReadOnlySet FontFamilies => _fontFamilies; + + public void Visit(object? value) + { + VisitCoreSerializedValue( + value, + value?.GetType() ?? typeof(object), + fileSourceIsAddressable: false); + } + + public void VisitSerializedValue(ICoreSerializable owner, string name, T? value) + { + bool fileSourceIsAddressable = value is IFileSource + && IsDirectFileSourceProperty(owner, name, value); + + if (name == "Setter" + && value is JsonNode + && owner is INodeMember { Property: { } property }) + { + object? propertyValue = property.GetValue(); + VisitCoreSerializedValue( + propertyValue, + propertyValue?.GetType() ?? property.PropertyType, + fileSourceIsAddressable: false); + if (property is IAnimatablePropertyAdapter { Animation: { } animation }) + { + VisitCoreSerializable(animation); + } + + return; + } + + if (TryVisitKnownRawJsonContract(owner, name, value)) + { + return; + } + + VisitCoreSerializedValue(value, typeof(T), fileSourceIsAddressable); + } + + private void VisitCoreSerializedValue( + object? value, + Type declaredType, + bool fileSourceIsAddressable) + { + if (value is null or string) + { + return; + } + + if (value is JsonNode or JsonElement or JsonDocument) + { + throw new InvalidDataException( + "Cannot safely inspect raw serialized JSON for external resources."); + } + + switch (value) + { + case IFileSource fileSource: + RecordFileSource(fileSource, fileSourceIsAddressable); + break; + case FontFamily fontFamily: + _fontFamilies.Add(fontFamily); + break; + case Typeface typeface: + _fontFamilies.Add(typeface.FontFamily); + break; + case ICoreSerializable serializable: + VisitCoreSerializable(serializable); + break; + case IReference: + break; + case IEnumerable enumerable: + VisitCoreEnumerable(enumerable, declaredType); + break; + default: + VisitSystemTextJsonValue(value, declaredType); + break; + } + } + + private void VisitCoreSerializable(ICoreSerializable serializable) + { + if (!serializable.GetType().IsValueType && !_visitedCoreObjects.Add(serializable)) + { + return; + } + + if (serializable is CoreObject coreObject) + { + _objects.Add(coreObject); + } + + var context = new SerializationGraphContext(this, serializable); + using (ThreadLocalSerializationContext.Enter(context)) + { + serializable.Serialize(context); + context.Complete(); + } + + // Hierarchy membership is the fallback for custom hierarchical implementations + // whose children are not exposed by Serialize. Run it after the serialization + // contract so a child already emitted under a declared contract wins. + if (serializable is IHierarchical hierarchical) + { + foreach (IHierarchical child in hierarchical.HierarchicalChildren) + { + bool childFileSourceIsAddressable = child is IFileSource + && IsDirectFileSourceValue(serializable, child); + VisitCoreSerializedValue(child, child.GetType(), childFileSourceIsAddressable); + } + } + } + + private void VisitCoreEnumerable(IEnumerable enumerable, Type declaredType) + { + Type runtimeType = enumerable.GetType(); + Type elementType = ArrayTypeHelpers.GetElementType(runtimeType) ?? typeof(object); + if (runtimeType.IsAssignableTo(typeof(IDictionary)) + && ArrayTypeHelpers.GetEntryType(runtimeType) is (Type keyType, Type valueType) + && keyType == typeof(string)) + { + if (valueType.IsValueType) + { + VisitSystemTextJsonValue(enumerable, declaredType); + return; + } + + if (!_visitedCoreCollections.Add(enumerable)) + { + return; + } + + var dictionary = (IDictionary)enumerable; + foreach (object? item in dictionary.Values) + { + VisitCoreSerializedValue(item, valueType, fileSourceIsAddressable: false); + } + + return; + } + + if (!_visitedCoreCollections.Add(enumerable)) + { + return; + } + + foreach (object? item in enumerable) + { + VisitCoreSerializedValue(item, elementType, fileSourceIsAddressable: false); + } + } + + private void VisitSystemTextJsonValue(object value, Type declaredType) + { + Type contractType = Nullable.GetUnderlyingType(declaredType) ?? declaredType; + if (!TryEnterContract(value, contractType)) + { + return; + } + + JsonNode? node = JsonSerializer.SerializeToNode(value, contractType, _captureOptions); + Type serializedType + = _passthroughOptions.GetTypeInfo(contractType).Kind == JsonTypeInfoKind.Object + ? value.GetType() + : contractType; + InspectRoundTrippedValue( + node, + contractType, + _passthroughOptions, + rootFileSourceIsAddressable: false, + validateStableRoundTrip: true, + baseUri: ThreadLocalSerializationContext.Current?.BaseUri, + contractName: contractType.FullName ?? contractType.Name, + serializedType: serializedType); + } + + public void VisitSerializedNodeValue( + ICoreSerializable owner, + string name, + Type declaredType, + Type actualType, + JsonNode? node) + { + if (node is null) + { + return; + } + + if (IsRawJsonCarrier(declaredType) || IsRawJsonCarrier(actualType)) + { + throw new InvalidDataException( + $"Cannot safely inspect raw serialized node '{name}' for external resources."); + } + + if (owner is CoreObject coreObject + && PropertyRegistry.FindRegistered(coreObject, name) is { } property) + { + object? value = coreObject.GetValue(property); + if (value is null) + { + return; + } + + CorePropertyMetadata metadata + = property.GetMetadata(owner.GetType()); + MethodInfo? getSerializerOptions = metadata.GetType().GetMethod( + nameof(CorePropertyMetadata.GetSerializerOptions), + BindingFlags.Instance | BindingFlags.Public, + binder: null, + Type.EmptyTypes, + modifiers: null); + if (getSerializerOptions?.Invoke(metadata, null) is not JsonSerializerOptions options) + { + throw new InvalidOperationException( + $"Cannot inspect the JSON converter for property '{name}'."); + } + + bool fileSourceIsAddressable = value is IFileSource + && IsDirectFileSourceProperty(owner, name, value); + Type serializedContractType + = options.GetTypeInfo(declaredType).Kind == JsonTypeInfoKind.Object + ? value.GetType() + : declaredType; + InspectRoundTrippedValue( + node, + declaredType, + options, + fileSourceIsAddressable, + validateStableRoundTrip: true, + baseUri: (owner as CoreObject)?.Uri, + contractName: name, + serializedType: serializedContractType); + return; + } + + Type inspectionType = declaredType == typeof(object) && actualType != typeof(object) + ? actualType + : declaredType; + try + { + object? restored = CoreSerializer.DeserializeFromJsonNode( + node.DeepClone(), + inspectionType, + new CoreSerializerOptions { BaseUri = (owner as CoreObject)?.Uri }); + Type serializedType = inspectionType.IsAssignableFrom(actualType) + ? actualType + : inspectionType; + SerializedContractInspection inspection = InspectSerializedContract( + node, + serializedType, + JsonHelper.SerializerOptions, + (owner as CoreObject)?.Uri, + fileSourceIsAddressable: false, + name); + ValidateStableJsonRoundTrip( + node, + restored, + inspectionType, + JsonHelper.SerializerOptions, + name, + inspection.CapturedFileSource && inspection.IsComplete); + var visited = new Dictionary>( + ReferenceEqualityComparer.Instance); + ScanRoundTrippedResources( + restored, + new ScanContract(inspectionType, JsonHelper.SerializerOptions), + fileSourceIsAddressable: false, + visited, + opaqueAncestor: false); + } + catch (Exception ex) when (ex is JsonException + or NotSupportedException + or InvalidOperationException) + { + throw new InvalidDataException( + $"Cannot inspect serialized node '{name}' for external resources.", + ex); + } + } + + private void InspectRoundTrippedValue( + JsonNode? node, + Type declaredType, + JsonSerializerOptions options, + bool rootFileSourceIsAddressable, + bool validateStableRoundTrip = false, + Uri? baseUri = null, + string? contractName = null, + Type? serializedType = null) + { + if (node is null) + { + return; + } + + SerializedContractInspection inspection = validateStableRoundTrip + ? InspectSerializedContract( + node, + serializedType ?? declaredType, + options, + baseUri, + rootFileSourceIsAddressable, + contractName ?? declaredType.FullName ?? declaredType.Name) + : default; + object? restored = JsonSerializer.Deserialize(node, declaredType, options); + if (restored is not null + && MayContainExternalResource(declaredType) + && IsOpaqueJsonContract(declaredType, restored.GetType(), options) + && ContainsUnavailableFileSource(restored, [])) + { + throw new InvalidDataException( + $"Serialized node '{contractName ?? declaredType.FullName}' " + + "contains a file source whose URI cannot be recovered safely."); + } + + if (validateStableRoundTrip) + { + ValidateStableJsonRoundTrip( + node, + restored, + declaredType, + options, + contractName ?? declaredType.FullName ?? declaredType.Name, + inspection.CapturedFileSource && inspection.IsComplete); + } + var visited = new Dictionary>( + ReferenceEqualityComparer.Instance); + ScanRoundTrippedResources( + restored, + new ScanContract(declaredType, options), + rootFileSourceIsAddressable, + visited, + opaqueAncestor: false); + } + + private static bool ContainsUnavailableFileSource( + object? value, + HashSet visited) + { + if (value is null or string or JsonNode or JsonElement or JsonDocument) + { + return false; + } + + if (value is IFileSource fileSource) + { + try + { + _ = fileSource.Uri; + return false; + } + catch (InvalidOperationException) + { + return true; + } + } + + Type type = value.GetType(); + if (!MayContainExternalResource(type)) + { + return false; + } + + if (!type.IsValueType && !visited.Add(value)) + { + return false; + } + + if (value is IEnumerable enumerable) + { + foreach (object? item in enumerable) + { + if (ContainsUnavailableFileSource(item, visited)) + { + return true; + } + } + + return false; + } + + foreach (FieldInfo field in GetInstanceFields(type)) + { + if (ContainsUnavailableFileSource(field.GetValue(value), visited)) + { + return true; + } + } + + return false; + } + + private static void ValidateStableJsonRoundTrip( + JsonNode node, + object? restored, + Type declaredType, + JsonSerializerOptions options, + string contractName, + bool allowUnavailableFileSource) + { + JsonNode? roundTripped; + try + { + roundTripped = JsonSerializer.SerializeToNode( + restored, + declaredType, + options); + } + catch (InvalidOperationException ex) when ( + allowUnavailableFileSource + && ex.TargetSite is { Name: "get_Uri", DeclaringType: { } declaringType } + && declaringType == typeof(BlobFileSource)) + { + return; + } + + if (!JsonNode.DeepEquals(node, roundTripped)) + { + throw new InvalidDataException( + $"Serialized node '{contractName}' contains data outside its typed contract."); + } + } + + private SerializedContractInspection InspectSerializedContract( + JsonNode? node, + Type contractType, + JsonSerializerOptions options, + Uri? baseUri, + bool fileSourceIsAddressable, + string contractName) + { + if (node is null) + { + return SerializedContractInspection.Complete; + } + + contractType = Nullable.GetUnderlyingType(contractType) ?? contractType; + if (typeof(IFileSource).IsAssignableFrom(contractType)) + { + if (node is not JsonValue value + || !value.TryGetValue(out string? uriString) + || !Uri.TryCreate( + uriString, + UriKind.RelativeOrAbsolute, + out Uri? uri)) + { + throw new InvalidDataException( + $"Serialized file source '{contractName}' does not contain a valid URI."); + } + + if (!uri.IsAbsoluteUri) + { + if (baseUri is null || !Uri.TryCreate(baseUri, uri, out uri)) + { + throw new InvalidDataException( + $"Serialized file source '{contractName}' has an unresolved relative URI."); + } + } + + if (fileSourceIsAddressable) + { + _addressableFileSources.Add(uri); + } + else + { + _unaddressableFileSources.Add(uri); + } + + return new SerializedContractInspection( + CapturedFileSource: true, + IsComplete: true); + } + + if (contractType == typeof(FileInfo) || contractType == typeof(DirectoryInfo)) + { + if (node is not JsonValue value + || !value.TryGetValue(out string? path) + || !TryResolveOpaqueFileUri( + path, + baseUri, + allowExtensionlessRelative: true, + requireFilePath: true, + out Uri? uri)) + { + throw new InvalidDataException( + $"Serialized file-system path '{contractName}' cannot be resolved."); + } + + _unaddressableFileSources.Add(uri); + return new SerializedContractInspection( + CapturedFileSource: true, + IsComplete: true); + } + + if (IsRawJsonCarrier(contractType)) + { + throw new InvalidDataException( + $"Cannot safely inspect raw serialized node '{contractName}' for external resources."); + } + + JsonTypeInfo contractTypeInfo = options.GetTypeInfo(contractType); + if (contractTypeInfo.Kind == JsonTypeInfoKind.None) + { + if (!IsKnownResourceFreeScalarContract(contractType, contractTypeInfo)) + { + if (!IsSystemTextJsonConverter(contractTypeInfo.Converter) + || MayContainExternalResource(contractType)) + { + CaptureOpaqueFileUris(node, baseUri); + } + + return default; + } + + object? restoredScalar = JsonSerializer.Deserialize(node, contractType, options); + JsonNode? roundTrippedScalar = JsonSerializer.SerializeToNode( + restoredScalar, + contractType, + options); + if (!JsonNode.DeepEquals(node, roundTrippedScalar)) + { + throw new InvalidDataException( + $"Serialized scalar '{contractName}' is not stable under its typed contract."); + } + + return SerializedContractInspection.Complete; + } + + if (node is JsonArray array) + { + Type? elementType = ArrayTypeHelpers.GetElementType(contractType); + if (elementType is null) + { + return default; + } + + SerializedContractInspection inspection = SerializedContractInspection.Complete; + foreach (JsonNode? item in array) + { + inspection = inspection.Combine(InspectSerializedContract( + item, + elementType, + options, + baseUri, + fileSourceIsAddressable: false, + contractName)); + } + + return inspection; + } + + if (node is not JsonObject jsonObject) + { + return SerializedContractInspection.Complete; + } + + if (typeof(IDictionary).IsAssignableFrom(contractType) + && ArrayTypeHelpers.GetEntryType(contractType) + is (Type keyType, Type valueType) + && keyType == typeof(string)) + { + SerializedContractInspection inspection = SerializedContractInspection.Complete; + foreach ((string _, JsonNode? item) in jsonObject) + { + inspection = inspection.Combine(InspectSerializedContract( + item, + valueType, + options, + baseUri, + fileSourceIsAddressable: false, + contractName)); + } + + return inspection; + } + + JsonTypeInfo typeInfo = contractTypeInfo; + if (typeInfo.Kind != JsonTypeInfoKind.Object) + { + return default; + } + + string? discriminator = typeInfo.PolymorphismOptions + ?.TypeDiscriminatorPropertyName; + string discriminatorName = discriminator ?? "$type"; + if (typeInfo.PolymorphismOptions is { } polymorphism + && jsonObject[discriminatorName] is JsonValue discriminatorValue) + { + Type? derivedContractType = null; + foreach (JsonDerivedType candidate in polymorphism.DerivedTypes) + { + bool matches = candidate.TypeDiscriminator switch + { + string text => discriminatorValue.TryGetValue(out string? stringValue) + && string.Equals(stringValue, text, StringComparison.Ordinal), + int number => discriminatorValue.TryGetValue(out int intValue) + && intValue == number, + _ => false, + }; + if (matches) + { + derivedContractType = candidate.DerivedType; + break; + } + } + + if (derivedContractType is not null) + { + typeInfo = options.GetTypeInfo(derivedContractType); + } + } + + StringComparer comparer = options.PropertyNameCaseInsensitive + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + Dictionary properties = typeInfo.Properties + .ToDictionary(property => property.Name, comparer); + SerializedContractInspection result = SerializedContractInspection.Complete; + foreach ((string name, JsonNode? item) in jsonObject) + { + if (!properties.TryGetValue(name, out JsonPropertyInfo? property)) + { + bool isDiscriminator = name is "$type" or "@type" + || string.Equals( + name, + discriminatorName, + StringComparison.Ordinal); + if (isDiscriminator && item is JsonValue) + { + continue; + } + + throw new InvalidDataException( + $"Serialized node '{contractName}' contains unknown member '{name}'."); + } + + if (property.CustomConverter is not null + && !typeof(IFileSource).IsAssignableFrom(property.PropertyType)) + { + if (!IsSystemTextJsonConverter(property.CustomConverter) + || MayContainExternalResource(property.PropertyType)) + { + CaptureOpaqueFileUris(item, baseUri); + } + + result = result.Combine(default); + } + else + { + result = result.Combine(InspectSerializedContract( + item, + property.PropertyType, + options, + baseUri, + fileSourceIsAddressable: false, + contractName)); + } + } + + return result; + } + + private readonly record struct SerializedContractInspection( + bool CapturedFileSource, + bool IsComplete) + { + public static SerializedContractInspection Complete => new(false, true); + + public SerializedContractInspection Combine(SerializedContractInspection other) + { + return new SerializedContractInspection( + CapturedFileSource || other.CapturedFileSource, + IsComplete && other.IsComplete); + } + } + + private readonly record struct ScanContract( + Type DeclaredType, + JsonSerializerOptions Options, + bool ExplicitOpaque = false); + + private readonly record struct RoundTripVisitKey( + Type DeclaredType, + JsonSerializerOptions Options, + bool OpaquePath); + + private void ScanRoundTrippedResources( + object? value, + ScanContract contract, + bool fileSourceIsAddressable, + Dictionary> visited, + bool opaqueAncestor) + { + switch (value) + { + case null or string: + return; + case JsonNode or JsonElement or JsonDocument: + throw new InvalidDataException( + "Cannot safely inspect raw serialized JSON for external resources."); + case IFileSource fileSource: + RecordFileSource(fileSource, fileSourceIsAddressable); + return; + case FontFamily fontFamily: + _fontFamilies.Add(fontFamily); + return; + case Typeface typeface: + _fontFamilies.Add(typeface.FontFamily); + return; + } + + Type type = value.GetType(); + bool opaqueContract = contract.ExplicitOpaque + || IsOpaqueJsonContract( + contract.DeclaredType, + type, + contract.Options); + bool opaquePath = opaqueAncestor || opaqueContract; + if (!type.IsValueType + && !TryEnterRoundTripVisit(value, contract, opaquePath, visited)) + { + return; + } + + if (value is IOptional optional) + { + if (optional.HasValue) + { + ScanRoundTrippedResources( + optional.ToObject().Value, + new ScanContract(optional.GetValueType(), contract.Options), + fileSourceIsAddressable: false, + visited, + opaquePath); + } + + return; + } + + if (value is ICoreSerializable serializable) + { + if (opaquePath) + { + List coreObjectFields = GetInstanceFields(type); + ValidateOpaqueResourceAccessors(type, coreObjectFields); + Dictionary coreObjectFieldContracts + = GetFieldContracts(type, contract.Options); + foreach (FieldInfo field in coreObjectFields) + { + ScanContract fieldContract = coreObjectFieldContracts.GetValueOrDefault( + field, + new ScanContract(field.FieldType, contract.Options)); + ScanRoundTrippedResources( + field.GetValue(value), + fieldContract, + fileSourceIsAddressable: false, + visited, + opaquePath); + } + } + + VisitCoreSerializable(serializable); + return; + } + + if (value is IReference) + { + return; + } + + if (value is IDictionary dictionary) + { + Type valueType = GetDictionaryValueType(contract.DeclaredType) + ?? GetDictionaryValueType(type) + ?? typeof(object); + foreach (object? item in dictionary.Values) + { + ScanRoundTrippedResources( + item, + new ScanContract(valueType, contract.Options), + fileSourceIsAddressable: false, + visited, + opaquePath); + } + + if (opaqueContract) + { + throw new InvalidDataException( + $"Cannot safely inspect opaque dictionary contract '{type.FullName}'."); + } + + return; + } + else if (value is IEnumerable enumerable) + { + Type elementType = ArrayTypeHelpers.GetElementType(contract.DeclaredType) + ?? ArrayTypeHelpers.GetElementType(type) + ?? typeof(object); + foreach (object? item in enumerable) + { + ScanRoundTrippedResources( + item, + new ScanContract(elementType, contract.Options), + fileSourceIsAddressable: false, + visited, + opaquePath); + } + + if (opaquePath) + { + throw new InvalidDataException( + $"Cannot safely inspect opaque collection contract '{type.FullName}'."); + } + + return; + } + + if (type.IsGenericType + && (type.GetGenericTypeDefinition() == typeof(Memory<>) + || type.GetGenericTypeDefinition() == typeof(ReadOnlyMemory<>)) + && type.GetMethod("ToArray", BindingFlags.Instance | BindingFlags.Public) + ?.Invoke(value, null) is IEnumerable memoryItems) + { + foreach (object? item in memoryItems) + { + ScanRoundTrippedResources( + item, + new ScanContract(type.GetGenericArguments()[0], contract.Options), + fileSourceIsAddressable: false, + visited, + opaquePath); + } + + return; + } + + if (type.Assembly == typeof(object).Assembly + && !MayContainExternalResource(contract.DeclaredType) + && !MayContainExternalResource(type)) + { + return; + } + + List fields = GetInstanceFields(type); + Dictionary fieldContracts + = GetFieldContracts(type, contract.Options); + + if (opaquePath) + { + ValidateOpaqueResourceAccessors(type, fields); + foreach (FieldInfo field in fields) + { + ScanContract fieldContract = fieldContracts.GetValueOrDefault( + field, + new ScanContract(field.FieldType, contract.Options)); + ScanRoundTrippedResources( + field.GetValue(value), + fieldContract, + fileSourceIsAddressable: false, + visited, + opaquePath); + } + } + else + { + foreach ((FieldInfo field, ScanContract fieldContract) in fieldContracts) + { + ScanRoundTrippedResources( + field.GetValue(value), + fieldContract, + fileSourceIsAddressable: false, + visited, + opaquePath); + } + } + } + + private static bool TryEnterRoundTripVisit( + object value, + ScanContract contract, + bool opaquePath, + Dictionary> visited) + { + if (!visited.TryGetValue(value, out HashSet? contracts)) + { + contracts = []; + visited.Add(value, contracts); + } + + return contracts.Add(new RoundTripVisitKey( + contract.DeclaredType, + contract.Options, + opaquePath)); + } + + private static bool IsOpaqueJsonContract( + Type declaredType, + Type runtimeType, + JsonSerializerOptions options) + { + return options.GetTypeInfo(declaredType).Kind == JsonTypeInfoKind.None + || (runtimeType != declaredType + && options.GetTypeInfo(runtimeType).Kind == JsonTypeInfoKind.None); + } + + private static List GetInstanceFields(Type type) + { + List fields = []; + for (Type? current = type; current is not null && current != typeof(object); current = current.BaseType) + { + fields.AddRange(current.GetFields( + BindingFlags.Instance + | BindingFlags.Public + | BindingFlags.NonPublic + | BindingFlags.DeclaredOnly) + .Where(field => !IsJsonIgnoredField(field))); + } + + return fields; + } + + private static bool IsJsonIgnoredField(FieldInfo field) + { + if (IsAlwaysJsonIgnored(field)) + { + return true; + } + + const string BackingFieldSuffix = ">k__BackingField"; + if (!field.IsDefined(typeof(CompilerGeneratedAttribute), inherit: false) + || field.Name.Length <= BackingFieldSuffix.Length + 1 + || field.Name[0] != '<' + || !field.Name.EndsWith(BackingFieldSuffix, StringComparison.Ordinal)) + { + return false; + } + + string propertyName = field.Name[1..^BackingFieldSuffix.Length]; + PropertyInfo? property = field.DeclaringType?.GetProperty( + propertyName, + BindingFlags.Instance + | BindingFlags.Public + | BindingFlags.NonPublic + | BindingFlags.DeclaredOnly); + return property is not null && IsAlwaysJsonIgnored(property); + } + + private static bool IsAlwaysJsonIgnored(MemberInfo member) + { + return member.GetCustomAttribute(inherit: true)?.Condition + == JsonIgnoreCondition.Always; + } + + private static Dictionary GetFieldContracts( + Type runtimeType, + JsonSerializerOptions options) + { + var result = new Dictionary(); + JsonTypeInfo typeInfo = options.GetTypeInfo(runtimeType); + if (typeInfo.Kind != JsonTypeInfoKind.Object) + { + return result; + } + + foreach (JsonPropertyInfo jsonProperty in typeInfo.Properties) + { + FieldInfo? field = jsonProperty.AttributeProvider switch + { + FieldInfo fieldInfo => fieldInfo, + PropertyInfo propertyInfo => TryGetTrivialPropertyBackingField(propertyInfo), + _ => null, + }; + bool explicitOpaque = jsonProperty.CustomConverter is not null + || jsonProperty.AttributeProvider + ?.GetCustomAttributes( + typeof(JsonConverterAttribute), + inherit: true) + .Length > 0; + + if (field is not null) + { + result[field] = new ScanContract( + jsonProperty.PropertyType, + options, + explicitOpaque); + } + else if (jsonProperty.Get is not null + && MayContainExternalResource(jsonProperty.PropertyType)) + { + throw new InvalidDataException( + $"Cannot safely inspect serialized resource property " + + $"'{runtimeType.FullName}.{jsonProperty.Name}' without invoking its getter."); + } + } + + return result; + } + + private static void ValidateOpaqueResourceAccessors( + Type type, + IReadOnlyCollection fields) + { + foreach (PropertyInfo property in GetOpaqueResourceProperties(type)) + { + if (IsAlwaysJsonIgnored(property) + || property.GetMethod is null + || property.GetIndexParameters().Length != 0 + || !MayContainExternalResource(property.PropertyType)) + { + continue; + } + + FieldInfo? backingField = TryGetTrivialPropertyBackingField(property); + if (backingField is null || !fields.Contains(backingField)) + { + throw new InvalidDataException( + $"Cannot safely inspect external-resource accessor " + + $"'{type.FullName}.{property.Name}' without invoking its getter."); + } + } + } + + private static IEnumerable GetOpaqueResourceProperties(Type type) + { + HashSet yielded = []; + foreach (PropertyInfo property in type.GetProperties( + BindingFlags.Instance | BindingFlags.Public)) + { + if (yielded.Add(property)) + { + yield return property; + } + } + + Type? nonPublicStop = typeof(CoreObject).IsAssignableFrom(type) + ? typeof(CoreObject) + : typeof(object); + for (Type? current = type; + current is not null && current != nonPublicStop; + current = current.BaseType) + { + foreach (PropertyInfo property in current.GetProperties( + BindingFlags.Instance + | BindingFlags.NonPublic + | BindingFlags.DeclaredOnly)) + { + if (yielded.Add(property)) + { + yield return property; + } + } + } + } + + private static FieldInfo? TryGetTrivialPropertyBackingField(PropertyInfo property) + { + MethodInfo? getter = property.GetMethod; + FieldInfo? field = property.DeclaringType?.GetField( + $"<{property.Name}>k__BackingField", + BindingFlags.Instance | BindingFlags.NonPublic); + if (getter?.IsDefined(typeof(CompilerGeneratedAttribute), inherit: false) == true + && field?.IsDefined(typeof(CompilerGeneratedAttribute), inherit: false) == true + && field.FieldType == property.PropertyType) + { + return field; + } + + byte[]? il = getter?.GetMethodBody()?.GetILAsByteArray(); + if (il is not { Length: 7 } + || il[0] != 0x02 // ldarg.0 + || il[1] != 0x7b // ldfld + || il[6] != 0x2a) // ret + { + return null; + } + + try + { + int token = BinaryPrimitives.ReadInt32LittleEndian(il.AsSpan(2, 4)); + FieldInfo? resolved = getter!.Module.ResolveField( + token, + getter.DeclaringType?.GetGenericArguments(), + getter.GetGenericArguments()); + return resolved?.FieldType == property.PropertyType ? resolved : null; + } + catch (ArgumentException) + { + return null; + } + } + + private static Type? GetDictionaryValueType(Type type) + { + return ArrayTypeHelpers.GetEntryType(type) is (_, Type valueType) + ? valueType + : null; + } + + private static bool IsRawJsonCarrier(Type type) + { + type = Nullable.GetUnderlyingType(type) ?? type; + return typeof(JsonNode).IsAssignableFrom(type) + || type == typeof(JsonElement) + || type == typeof(JsonDocument); + } + + private bool TryVisitKnownRawJsonContract( + ICoreSerializable owner, + string name, + object? value) + { + if (owner is EngineObject engineObject + && name == "Expressions" + && value is Dictionary expressions) + { + int visitedExpressions = 0; + foreach (IProperty property in engineObject.Properties) + { + if (!expressions.ContainsKey(property.Name)) + { + continue; + } + + if (property.Expression is not { } expression) + { + return false; + } + + Type expressionType = expression.GetType(); + bool isBuiltInResourceFreeExpression = expressionType.IsGenericType + && expressionType.GetGenericTypeDefinition() + is var genericDefinition + && (genericDefinition + == typeof(Beutl.Engine.Expressions.StringExpression<>) + || genericDefinition + == typeof(Beutl.Engine.Expressions.ReferenceExpression<>)); + if (!isBuiltInResourceFreeExpression) + { + VisitSystemTextJsonValue(expression, expressionType); + } + + visitedExpressions++; + } + + return visitedExpressions == expressions.Count; + } + + return owner is Beutl.Animation.KeyFrame + && name == nameof(Beutl.Animation.KeyFrame.Easing) + && value is JsonObject easing + && easing.Count == 4 + && IsJsonNumber(easing["X1"]) + && IsJsonNumber(easing["Y1"]) + && IsJsonNumber(easing["X2"]) + && IsJsonNumber(easing["Y2"]); + } + + private static bool IsJsonNumber(JsonNode? node) + { + return node is JsonValue value + && (value.TryGetValue(out float _) + || value.TryGetValue(out double _) + || value.TryGetValue(out decimal _)); + } + + private static bool IsKnownResourceFreeScalarContract( + Type type, + JsonTypeInfo typeInfo) + { + if (IsKnownResourceFreeScalarType(type) + && IsSystemTextJsonConverter(typeInfo.Converter)) + { + return true; + } + + Assembly typeAssembly = type.Assembly; + return !MayContainExternalResource(type) + && typeInfo.Converter.GetType().Assembly == typeAssembly + && (typeAssembly == typeof(Rational).Assembly + || typeAssembly == typeof(Point).Assembly); + } + + private static bool IsSystemTextJsonConverter(JsonConverter converter) + { + return converter.GetType().Assembly == typeof(JsonSerializer).Assembly; + } + + private static bool IsKnownResourceFreeScalarType(Type type) + { + type = Nullable.GetUnderlyingType(type) ?? type; + return type == typeof(string) + || type == typeof(Uri) + || type == typeof(Guid) + || type == typeof(DateTime) + || type == typeof(DateTimeOffset) + || type == typeof(TimeSpan) + || type == typeof(decimal) + || type.IsPrimitive + || type.IsEnum; + } + + private void CaptureOpaqueFileUris(JsonNode? node, Uri? baseUri) + { + switch (node) + { + case JsonValue value when value.TryGetValue(out string? text): + CaptureOpaqueFileUri(text, baseUri, allowExtensionlessRelative: true); + break; + case JsonArray array: + foreach (JsonNode? item in array) + { + CaptureOpaqueFileUris(item, baseUri); + } + + break; + case JsonObject jsonObject: + foreach ((string name, JsonNode? item) in jsonObject) + { + CaptureOpaqueFileUri(name, baseUri, allowExtensionlessRelative: false); + CaptureOpaqueFileUris(item, baseUri); + } + + break; + } + } + + private void CaptureOpaqueFileUri( + string? value, + Uri? baseUri, + bool allowExtensionlessRelative) + { + if (TryResolveOpaqueFileUri( + value, + baseUri, + allowExtensionlessRelative, + requireFilePath: false, + out Uri? uri)) + { + _unaddressableFileSources.Add(uri); + } + else if (!IsAbsoluteNonFileUri(value) && LooksLikeFilePath(value)) + { + throw new InvalidDataException( + $"Cannot resolve opaque serialized file path '{value}'."); + } + } + + private static bool TryResolveOpaqueFileUri( + string? value, + Uri? baseUri, + bool allowExtensionlessRelative, + bool requireFilePath, + [NotNullWhen(true)] out Uri? uri) + { + uri = null; + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + if (LooksLikeWindowsPath(value)) + { + string normalized = value.Replace('\\', '/') + .Replace("#", "%23", StringComparison.Ordinal) + .Replace("?", "%3F", StringComparison.Ordinal); + return Uri.TryCreate($"file:///{normalized}", UriKind.Absolute, out uri); + } + + if (Path.IsPathFullyQualified(value)) + { + string fullPath = Path.GetFullPath(value); + if (!File.Exists(fullPath) + && !Directory.Exists(fullPath) + && !LooksLikeFilePath(value) + && !requireFilePath) + { + return false; + } + + uri = CreateFileUri(fullPath); + return true; + } + + if (Uri.TryCreate(value, UriKind.Absolute, out Uri? absoluteUri)) + { + if (!absoluteUri.IsFile) + { + return false; + } + + uri = CanonicalizeFileUri(absoluteUri); + return true; + } + + if (baseUri is { IsFile: true }) + { + try + { + string? directory = Path.GetDirectoryName(baseUri.LocalPath); + if (directory is not null) + { + string rawPath = Path.GetFullPath(Path.Combine(directory, value)); + if (File.Exists(rawPath) || Directory.Exists(rawPath)) + { + uri = CreateFileUri(rawPath); + return true; + } + } + } + catch (Exception ex) when (ex is ArgumentException + or IOException + or NotSupportedException) + { + // Fall through to the URI-based check and fail closed for path-like data. + } + } + + bool looksLikeFilePath = LooksLikeFilePath(value); + string relativeReference = value + .Replace("#", "%23", StringComparison.Ordinal) + .Replace("?", "%3F", StringComparison.Ordinal); + if ((!allowExtensionlessRelative && !looksLikeFilePath) + || baseUri is null + || !Uri.TryCreate(baseUri, relativeReference, out Uri? resolved) + || !resolved.IsFile) + { + return false; + } + + if (!looksLikeFilePath + && !File.Exists(resolved.LocalPath) + && !Directory.Exists(resolved.LocalPath) + && !requireFilePath) + { + return false; + } + + uri = CanonicalizeFileUri(resolved); + return true; + } + + private static bool IsAbsoluteNonFileUri(string? value) + { + return !string.IsNullOrWhiteSpace(value) + && !LooksLikeWindowsPath(value) + && Uri.TryCreate(value, UriKind.Absolute, out Uri? uri) + && !uri.IsFile; + } + + private static Uri CanonicalizeFileUri(Uri uri) + { + return CreateFileUri(uri.LocalPath); + } + + private static Uri CreateFileUri(string path) + { + return new UriBuilder + { + Scheme = Uri.UriSchemeFile, + Host = string.Empty, + Path = Path.GetFullPath(path), + }.Uri; + } + + private static bool LooksLikeFilePath(string? value) + { + if (string.IsNullOrWhiteSpace(value) || LooksLikeNumericSerialization(value)) + { + return false; + } + + string extension = Path.GetExtension(value); + return LooksLikeWindowsPath(value) + || value.StartsWith("./", StringComparison.Ordinal) + || value.StartsWith("../", StringComparison.Ordinal) + || value.StartsWith(".\\", StringComparison.Ordinal) + || value.StartsWith("..\\", StringComparison.Ordinal) + || extension.Length > 1 && extension.Skip(1).Any(char.IsLetter); + } + + private static bool LooksLikeNumericSerialization(string value) + { + string candidate = value.Trim().Trim('<', '>', '(', ')', '[', ']'); + if (double.TryParse( + candidate, + NumberStyles.Float, + CultureInfo.InvariantCulture, + out _)) + { + return true; + } + + string[] parts = candidate.Split( + [',', '/', ';'], + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return parts.Length > 1 + && parts.All(part => double.TryParse( + part, + NumberStyles.Float, + CultureInfo.InvariantCulture, + out _)); + } + + private static bool LooksLikeWindowsPath(string value) + { + return value.Length >= 3 + && char.IsAsciiLetter(value[0]) + && value[1] == ':' + && value[2] is '/' or '\\'; + } + + private static bool MayContainExternalResource(Type type) + { + return MayContainExternalResource(type, []); + } + + private static bool MayContainExternalResource(Type type, HashSet visited) + { + type = Nullable.GetUnderlyingType(type) ?? type; + if (typeof(IFileSource).IsAssignableFrom(type) + || typeof(ICoreSerializable).IsAssignableFrom(type) + || typeof(IOptional).IsAssignableFrom(type) + || type == typeof(FontFamily) + || type == typeof(Typeface) + || type == typeof(object) + || IsRawJsonCarrier(type)) + { + return true; + } + + if (IsKnownResourceFreeScalarType(type)) + { + return false; + } + + if (type.IsArray) + { + return MayContainExternalResource(type.GetElementType()!, visited); + } + + if (typeof(IDictionary).IsAssignableFrom(type)) + { + Type? valueType = GetDictionaryValueType(type); + return valueType is null + || MayContainExternalResource(valueType, visited); + } + + if (typeof(IEnumerable).IsAssignableFrom(type)) + { + Type? elementType = ArrayTypeHelpers.GetElementType(type); + return elementType is null + || MayContainExternalResource(elementType, visited); + } + + if (type.IsGenericType + && (type.GetGenericTypeDefinition() == typeof(Memory<>) + || type.GetGenericTypeDefinition() == typeof(ReadOnlyMemory<>))) + { + return MayContainExternalResource(type.GetGenericArguments()[0], visited); + } + + if (type.IsInterface || type.IsAbstract) + { + return true; + } + + if (type.IsGenericType + && type.GetGenericArguments().Any(argument => + MayContainExternalResource(argument, visited))) + { + return true; + } + + if (!type.IsValueType && !type.IsSealed) + { + return true; + } + + if (!visited.Add(type) || type.Assembly == typeof(object).Assembly) + { + return false; + } + + try + { + return type.GetProperties( + BindingFlags.Instance + | BindingFlags.Public + | BindingFlags.NonPublic) + .Where(property => property.GetIndexParameters().Length == 0) + .Any(property => MayContainExternalResource(property.PropertyType, visited)) + || type.GetFields( + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .Any(field => MayContainExternalResource(field.FieldType, visited)); + } + finally + { + visited.Remove(type); + } + } + + private void VisitCapturedJsonValue(object? value) + { + switch (value) + { + case IFileSource fileSource: + RecordFileSource(fileSource, fileSourceIsAddressable: false); + break; + case FontFamily fontFamily: + _fontFamilies.Add(fontFamily); + break; + case Typeface typeface: + _fontFamilies.Add(typeface.FontFamily); + break; + case IOptional { HasValue: true } optional: + { + object? optionalValue = optional.ToObject().Value; + if (optionalValue is ICoreSerializable serializable) + { + // OptionalJsonConverter deliberately calls SerializeToJsonObject here, + // even when the value also implements IFileSource. + VisitCoreSerializable(serializable); + } + else if (optionalValue is not null) + { + VisitSystemTextJsonValue(optionalValue, optional.GetValueType()); + } + + break; + } + case ICoreSerializable serializable: + VisitCoreSerializable(serializable); + break; + } + } + + private void RecordFileSource(IFileSource fileSource, bool fileSourceIsAddressable) + { + Uri? uri; + try + { + uri = fileSource.Uri; + } + catch (InvalidOperationException) + { + // Some interface-level JSON contracts reconstruct an empty placeholder. + // The capture converter already observed the source that was actually written. + return; + } + + if (uri != null && fileSourceIsAddressable) + { + _addressableFileSources.Add(uri); + } + else if (uri != null) + { + _unaddressableFileSources.Add(uri); + } + } + + private bool TryEnterContract(object value, Type contractType) + { + if (value.GetType().IsValueType) + { + return true; + } + + if (!_visitedContracts.TryGetValue(value, out HashSet? contracts)) + { + contracts = []; + _visitedContracts.Add(value, contracts); + } + + return contracts.Add(contractType); + } + + private sealed class CaptureJsonConverterFactory( + SerializationGraphVisitor visitor, + JsonSerializerOptions passthroughOptions) : JsonConverterFactory + { + public override bool CanConvert(Type typeToConvert) + => typeToConvert.IsAssignableTo(typeof(IFileSource)) + || typeToConvert.IsAssignableTo(typeof(ICoreSerializable)) + || typeToConvert.IsAssignableTo(typeof(IOptional)) + || typeToConvert.IsAssignableTo(typeof(FontFamily)) + || typeToConvert == typeof(Typeface); + + public override JsonConverter CreateConverter( + Type typeToConvert, + JsonSerializerOptions options) + { + Type converterType = typeof(CaptureJsonConverter<>).MakeGenericType(typeToConvert); + return (JsonConverter)Activator.CreateInstance( + converterType, + visitor, + passthroughOptions)!; + } + } + + private sealed class CaptureJsonConverter( + SerializationGraphVisitor visitor, + JsonSerializerOptions passthroughOptions) : JsonConverter + { + public override T? Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + => JsonSerializer.Deserialize(ref reader, passthroughOptions); + + public override void Write( + Utf8JsonWriter writer, + T value, + JsonSerializerOptions options) + { + visitor.VisitCapturedJsonValue(value); + JsonSerializer.Serialize(writer, value, typeof(T), passthroughOptions); + } + } + + private static bool IsDirectFileSourceProperty( + ICoreSerializable owner, + string propertyName, + object value) + { + if (owner is EngineObject engineObject + && engineObject.Properties.FirstOrDefault(property => property.Name == propertyName) + is { CurrentValue: IFileSource currentValue } + && ReferenceEquals(currentValue, value)) + { + return true; + } + + if (owner is CoreObject coreObject + && PropertyRegistry.FindRegistered(coreObject, propertyName) is { } property + && ReferenceEquals(coreObject.GetValue(property), value)) + { + return true; + } + + return false; + } + + private static bool IsDirectFileSourceValue(object owner, object value) + { + if (owner is EngineObject engineObject + && engineObject.Properties.Any(property => ReferenceEquals(property.CurrentValue, value))) + { + return true; + } + + if (owner is CoreObject coreObject) + { + return PropertyRegistry.GetRegistered(coreObject.GetType()) + .Any(property => ReferenceEquals(coreObject.GetValue(property), value)); + } + + return false; + } + } + + private sealed class SerializationGraphContext( + SerializationGraphVisitor visitor, + ICoreSerializable owner) : IJsonSerializationContext + { + private readonly JsonObject _json = []; + private readonly Dictionary + _pendingNodes = []; + private readonly Dictionary _values = []; + + public CoreSerializationMode Mode + => CoreSerializationMode.Write | CoreSerializationMode.EmbedReferencedObjects; + + public Uri? BaseUri => (owner as CoreObject)?.Uri; + + public Type OwnerType => owner.GetType(); + + public JsonObject GetJsonObject() + { + throw new InvalidDataException( + "Cannot safely expose mutable serialized JSON during resource inspection."); + } + + public void SetJsonObject(JsonObject obj) + { + throw new InvalidDataException( + "Cannot safely inspect raw serialized JSON for external resources."); + } + + public JsonNode? GetNode(string name) + { + throw new InvalidDataException( + "Cannot safely expose mutable serialized JSON during resource inspection."); + } + + public void SetNode(string name, Type definedType, Type actualType, JsonNode? node) + { + _values.Remove(name); + _json[name] = node; + _pendingNodes[name] = (definedType, actualType); + } + + public void SetValue(string name, T? value) + { + if (value is System.Reactive.Unit) + { + _values.Remove(name); + _pendingNodes.Remove(name); + _json.Remove(name); + return; + } + + visitor.VisitSerializedValue(owner, name, value); + _pendingNodes.Remove(name); + _json.Remove(name); + _values[name] = value; + } + + public T? GetValue(string name) + { + if (_values.TryGetValue(name, out object? value)) + { + if (value is null) + { + return default; + } + + if (value is T typed) + { + return typed; + } + + throw new InvalidDataException( + $"Cannot reproduce typed serialization read for '{name}'."); + } + + if (_pendingNodes.ContainsKey(name)) + { + throw new InvalidDataException( + $"Cannot reproduce serialized node read for '{name}'."); + } + + return default; + } + + public bool Contains(string name) + { + return _values.ContainsKey(name) || _pendingNodes.ContainsKey(name); + } + + public void Populate(string name, ICoreSerializable obj) + { + visitor.Visit(obj); + } + + public void Resolve(Guid id, Action callback) + { + } + + public void Complete() + { + if (_json.Count != _pendingNodes.Count + || _json.Any(item => !_pendingNodes.ContainsKey(item.Key))) + { + throw new InvalidDataException( + "Serialized JSON was mutated outside a typed serialization contract."); + } + + foreach ((string name, (Type definedType, Type actualType)) in _pendingNodes) + { + visitor.VisitSerializedNodeValue( + owner, + name, + definedType, + actualType, + _json[name]); + } + } + } + + internal sealed record SerializationGraph( + IReadOnlyList Objects, + IReadOnlySet UnaddressableFileSources, + IReadOnlySet AddressableFileSources, + IReadOnlySet FontFamilies); } diff --git a/src/Beutl.Editor/FileSystemPathComparison.cs b/src/Beutl.Editor/FileSystemPathComparison.cs new file mode 100644 index 0000000000..8faed8d4ab --- /dev/null +++ b/src/Beutl.Editor/FileSystemPathComparison.cs @@ -0,0 +1,22 @@ +namespace Beutl.Editor; + +public static class FileSystemPathComparison +{ + // Windows and the Apple platforms are case-insensitive; every other Unix is case-sensitive. + // This is the same split System.IO.PathInternal.IsCaseSensitive makes, so anything deciding + // whether two spellings name the same directory must match it or it contradicts + // Path.GetRelativePath. Note it is a per-platform default, not a per-volume fact: a + // case-sensitive APFS volume compares case-insensitively here, exactly as System.IO does. + public static bool IsCaseInsensitive + => OperatingSystem.IsWindows() + || OperatingSystem.IsMacOS() + || OperatingSystem.IsMacCatalyst() + || OperatingSystem.IsIOS() + || OperatingSystem.IsTvOS(); + + public static StringComparison ForCurrentPlatform + => IsCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + public static StringComparer ComparerForCurrentPlatform + => IsCaseInsensitive ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; +} diff --git a/src/Beutl.Editor/ProjectPackageService.cs b/src/Beutl.Editor/ProjectPackageService.cs index 2547f5cfa8..22e509cafe 100644 --- a/src/Beutl.Editor/ProjectPackageService.cs +++ b/src/Beutl.Editor/ProjectPackageService.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using System.IO.Compression; +using Beutl.IO; using Beutl.Language; using Beutl.Logging; using Beutl.Serialization; @@ -62,6 +63,11 @@ public async Task ExportAsync( try { + ExternalResourceCollector.SerializationGraph graph + = ExternalResourceCollector.DiscoverSerializationGraph(project); + IReadOnlyList projectObjects = graph.Objects; + EnsureUniqueObjectIds(projectObjects); + // Step 1: Create a temporary directory progress?.Report((Strings.ExportingProject, 0.0)); tempDir = Path.Combine(Path.GetTempPath(), $"beutl_export_{Guid.NewGuid():N}"); @@ -72,12 +78,19 @@ public async Task ExportAsync( string tempProjectDir = Path.Combine(tempDir, Path.GetFileName(projectDir)); progress?.Report((Strings.ExportingProject, 0.1)); await CopyDirectoryAsync(projectDir, tempProjectDir, cancellationToken); + HashSet stagedStorageObjects = await MaterializeProjectStructureAsync( + projectObjects, + projectDir, + tempProjectDir, + cancellationToken); // Step 3: Open the temporary project string tempProjectFile = Path.Combine(tempProjectDir, Path.GetFileName(project.Uri.LocalPath)); Uri tempProjectUri = new(tempProjectFile); progress?.Report((Strings.ExportingProject, 0.2)); Project tempProject = CoreSerializer.RestoreFromUri(tempProjectUri); + EnsureUniqueObjectIds( + ExternalResourceCollector.DiscoverSerializationGraph(tempProject).Objects); // Step 4: Attach to the virtual root progress?.Report((Strings.ExportingProject, 0.3)); @@ -86,7 +99,29 @@ public async Task ExportAsync( // Step 5: Collect and copy external files progress?.Report((Strings.ExportingProject, 0.4)); - ExternalResourceCollector collector = ExternalResourceCollector.Collect(project, projectDir); + ExternalResourceCollector collector = ExternalResourceCollector.Collect( + graph, + projectDir, + stagedStorageObjects); + if (collector.UnaddressableFileSources.Count > 0) + { + throw new InvalidDataException( + "Project contains a file source that cannot be safely relocated."); + } + + if (collector.RelocationOwners.Any(owner => + !ReferenceEquals(project.FindById(owner.Id), owner))) + { + throw new InvalidDataException( + "Project contains a relocation owner that cannot be resolved by ID."); + } + + if (collector.FileSources.Any(source => + ExternalResourceCollector.IsInReservedProjectPath(source.OriginalUri, projectDir))) + { + throw new InvalidDataException( + "Project references a resource in a reserved structural directory."); + } fileResult = await _relocationService.RelocateFileSourcesAsync( collector.FileSources, @@ -214,7 +249,7 @@ private void CleanupTempDirectory(string? tempDir) try { progress?.Report((Strings.ImportingProject, 0.3)); - await Task.Run(() => ZipFile.ExtractToDirectory(packagePath, projectDir), cancellationToken); + await Task.Run(() => ExtractPackage(packagePath, projectDir), cancellationToken); progress?.Report((Strings.ImportingProject, 0.6)); string? projectFile = Directory.GetFiles(projectDir, "*.bep", SearchOption.TopDirectoryOnly) @@ -285,6 +320,36 @@ private static string GetUniqueDirectoryPath(string parentDirectory, string dire return path; } + private static void ExtractPackage(string packagePath, string destinationDirectory) + { + using ZipArchive archive = ZipFile.OpenRead(packagePath); + if (archive.Entries.Any(entry => ContainsGitMetadataPath(entry.FullName))) + { + throw new InvalidDataException("Package contains reserved Git metadata."); + } + + archive.ExtractToDirectory(destinationDirectory); + } + + private static bool ContainsGitMetadataPath(string entryPath) + { + string[] segments = entryPath.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries); + return segments.Any(IsGitMetadataPathSegment); + } + + private static bool IsGitMetadataPathSegment(string segment) + { + return IsPortableReservedPathSegment(segment, ".git"); + } + + private static bool IsPortableReservedPathSegment(string segment, string reservedName) + { + int streamSeparator = segment.IndexOf(':'); + string portableName = (streamSeparator >= 0 ? segment[..streamSeparator] : segment) + .TrimEnd(' ', '.'); + return string.Equals(portableName, reservedName, StringComparison.OrdinalIgnoreCase); + } + /// /// Copies a directory asynchronously. /// @@ -295,6 +360,9 @@ private static async Task CopyDirectoryAsync(string sourceDir, string destDir, C foreach (string file in Directory.GetFiles(sourceDir)) { cancellationToken.ThrowIfCancellationRequested(); + if (ShouldSkipEntry(file, isDirectory: false)) + continue; + string destFile = Path.Combine(destDir, Path.GetFileName(file)); await CopyFileAsync(file, destFile, cancellationToken); } @@ -304,8 +372,7 @@ private static async Task CopyDirectoryAsync(string sourceDir, string destDir, C cancellationToken.ThrowIfCancellationRequested(); string dirName = Path.GetFileName(subDir); - // Skip the .beutl folder (view state, etc.) - if (dirName == ".beutl") + if (ShouldSkipEntry(subDir, isDirectory: true)) continue; string destSubDir = Path.Combine(destDir, dirName); @@ -313,6 +380,127 @@ private static async Task CopyDirectoryAsync(string sourceDir, string destDir, C } } + private static async Task> MaterializeProjectStructureAsync( + IReadOnlyList projectObjects, + string projectDirectory, + string stagingDirectory, + CancellationToken cancellationToken) + { + string fullProjectDirectory = Path.GetFullPath(projectDirectory); + var stagedStorageObjects = new HashSet(ReferenceEqualityComparer.Instance); + IEnumerable storageObjects = projectObjects.Where(static obj => obj is not IFileSource); + + foreach (CoreObject obj in storageObjects) + { + cancellationToken.ThrowIfCancellationRequested(); + if (obj.Uri is not { IsFile: true } uri) + { + continue; + } + + string sourcePath = Path.GetFullPath(uri.LocalPath); + string relativePath = Path.GetRelativePath(fullProjectDirectory, sourcePath); + if (IsOutsideDirectory(relativePath)) + { + continue; + } + + if (ContainsGitMetadataPath(relativePath) + || ContainsBeutlMetadataPath(relativePath)) + { + throw new InvalidDataException( + $"Project object '{obj.Id}' uses a reserved structural path."); + } + + stagedStorageObjects.Add(obj); + string destinationPath = Path.Combine(stagingDirectory, relativePath); + if (File.Exists(destinationPath)) + { + continue; + } + + string? destinationDirectory = Path.GetDirectoryName(destinationPath); + if (destinationDirectory is null) + { + throw new InvalidDataException("Project structure path has no parent directory."); + } + + Directory.CreateDirectory(destinationDirectory); + await CopyFileAsync(sourcePath, destinationPath, cancellationToken); + } + + return stagedStorageObjects; + } + + private static void EnsureUniqueObjectIds(IReadOnlyList objects) + { + var objectsById = new Dictionary(); + foreach (CoreObject obj in objects) + { + if (objectsById.TryGetValue(obj.Id, out CoreObject? existing) + && !ReferenceEquals(existing, obj)) + { + throw new InvalidDataException($"Project contains duplicate CoreObject ID '{obj.Id}'."); + } + + objectsById[obj.Id] = obj; + } + } + + private static bool IsOutsideDirectory(string relativePath) + { + return Path.IsPathFullyQualified(relativePath) + || relativePath == ".." + || relativePath.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal) + || relativePath.StartsWith($"..{Path.AltDirectorySeparatorChar}", StringComparison.Ordinal); + } + + internal static bool ContainsBeutlMetadataPath(string entryPath) + { + string[] segments = entryPath.Split( + ['/', '\\'], + StringSplitOptions.RemoveEmptyEntries); + return segments.Any(segment => + IsPortableReservedPathSegment(segment, ".beutl")); + } + + private static bool ShouldSkipEntry(string path, bool isDirectory) + { + if (IsSymbolicLink(path, isDirectory)) + { + return true; + } + + return HasReservedName(path, ".git") + || (isDirectory && HasReservedName(path, ".beutl")); + } + + private static bool IsSymbolicLink(string path, bool isDirectory) + { + try + { + FileSystemInfo info = isDirectory + ? new DirectoryInfo(path) + : new FileInfo(path); + // LinkTarget identifies symbolic links and junctions without excluding unrelated + // reparse points such as cloud-backed file placeholders. + return info.LinkTarget is not null; + } + catch (Exception ex) + when (ex is IOException + or UnauthorizedAccessException + or NotSupportedException) + { + return true; + } + } + + private static bool HasReservedName(string path, string reservedName) + { + string name = Path.GetFileName(Path.TrimEndingDirectorySeparator(path)); + return IsPortableReservedPathSegment(name, reservedName); + } + /// /// Copies a file asynchronously. /// diff --git a/src/Beutl.Editor/ResourceRelocationService.cs b/src/Beutl.Editor/ResourceRelocationService.cs index 72d7983c65..09474f6331 100644 --- a/src/Beutl.Editor/ResourceRelocationService.cs +++ b/src/Beutl.Editor/ResourceRelocationService.cs @@ -118,10 +118,7 @@ private void UpdateUri(Project stagingProject, Guid id, string propertyName, Uri var engineProp = engineObject.Properties.FirstOrDefault(p => p.Name == propertyName); if (engineProp?.CurrentValue is IFileSource fileSource) { - var type = fileSource.GetType(); - var newInstance = (IFileSource)Activator.CreateInstance(type)!; - newInstance.ReadFrom(newUri); - engineProp.CurrentValue = newInstance; + engineProp.CurrentValue = RelocateFileSource(fileSource, newUri); return; } } @@ -129,6 +126,12 @@ private void UpdateUri(Project stagingProject, Guid id, string propertyName, Uri if (obj != null) { var property = PropertyRegistry.FindRegistered(obj, propertyName); + if (property != null && obj.GetValue(property) is IFileSource fileSource) + { + obj.SetValue(property, RelocateFileSource(fileSource, newUri)); + return; + } + if (property != null && property.PropertyType == typeof(Uri)) { obj.SetValue(property, newUri); @@ -145,6 +148,12 @@ private void UpdateUri(Project stagingProject, Guid id, string propertyName, Uri throw new InvalidOperationException("Failed to update URI: Object or property not found."); } + private static IFileSource RelocateFileSource(IFileSource source, Uri newUri) + { + source.ReadFrom(newUri); + return source; + } + /// /// Copies font files to the project's resources/fonts directory. /// diff --git a/src/Beutl.Editor/Services/DuplicateHelper.cs b/src/Beutl.Editor/Services/DuplicateHelper.cs index e7e5a02799..5ddd911e5b 100644 --- a/src/Beutl.Editor/Services/DuplicateHelper.cs +++ b/src/Beutl.Editor/Services/DuplicateHelper.cs @@ -159,7 +159,7 @@ public static void PlaceDuplicates( newElement.Start = newElement.Start - minStart + anchorStart; newElement.ZIndex = newElement.ZIndex - minZIndex + anchorZIndex; - Uri uri = RandomFileNameGenerator.GenerateUri(scene.Uri, EditorConstants.ElementFileExtension); + Uri uri = ElementFileNaming.GetUri(scene.Uri, newElement.Id); CoreSerializer.StoreToUri(newElement, uri); stagedFiles.Add(uri.LocalPath); } diff --git a/src/Beutl.Editor/Services/ElementClipboardService.cs b/src/Beutl.Editor/Services/ElementClipboardService.cs index 38162f50bd..ffdd9ad776 100644 --- a/src/Beutl.Editor/Services/ElementClipboardService.cs +++ b/src/Beutl.Editor/Services/ElementClipboardService.cs @@ -202,7 +202,7 @@ private async Task PasteSingleElementAsync(Scene scene, Tim newElement.Start = clickedFrame; newElement.ZIndex = clickedLayer; - CoreSerializer.StoreToUri(newElement, RandomFileNameGenerator.GenerateUri(scene.Uri, EditorConstants.ElementFileExtension)); + CoreSerializer.StoreToUri(newElement, ElementFileNaming.GetUri(scene.Uri, newElement.Id)); scene.AddChild(newElement); _historyManager.Commit(CommandNames.PasteElement); @@ -291,7 +291,7 @@ private async Task PasteBitmapAsync(Scene scene, TimeSpan c }; newElement.AddObject(sourceImage); - CoreSerializer.StoreToUri(newElement, RandomFileNameGenerator.GenerateUri(dir, EditorConstants.ElementFileExtension)); + CoreSerializer.StoreToUri(newElement, ElementFileNaming.GetUri(scene.Uri, newElement.Id)); scene.AddChild(newElement); _historyManager.Commit(CommandNames.PasteElement); diff --git a/src/Beutl.Editor/Services/ElementFileNaming.cs b/src/Beutl.Editor/Services/ElementFileNaming.cs new file mode 100644 index 0000000000..fb71b7cc26 --- /dev/null +++ b/src/Beutl.Editor/Services/ElementFileNaming.cs @@ -0,0 +1,25 @@ +namespace Beutl.Editor.Services; + +public static class ElementFileNaming +{ + public static Uri GetUri(Uri sceneUri, Guid elementId) + { + ArgumentNullException.ThrowIfNull(sceneUri); + if (!sceneUri.IsFile) + { + throw new ArgumentException("The scene URI must be an absolute file URI.", nameof(sceneUri)); + } + + string directory = Path.GetDirectoryName(sceneUri.LocalPath) + ?? throw new ArgumentException("The scene URI must have a directory.", nameof(sceneUri)); + string stem = elementId.ToString("N"); + string path = Path.Combine(directory, $"{stem}.{EditorConstants.ElementFileExtension}"); + + for (int index = 1; File.Exists(path); index++) + { + path = Path.Combine(directory, $"{stem}-{index}.{EditorConstants.ElementFileExtension}"); + } + + return new Uri(path); + } +} diff --git a/src/Beutl.Editor/Services/ElementStructureService.cs b/src/Beutl.Editor/Services/ElementStructureService.cs index 69ab7e299b..55286c24b6 100644 --- a/src/Beutl.Editor/Services/ElementStructureService.cs +++ b/src/Beutl.Editor/Services/ElementStructureService.cs @@ -71,7 +71,7 @@ public SplitOutcome Split(Scene scene, IReadOnlyList targets, TimeSpan ShiftLocalKeyFrames(backward, -forwardDuration); - CoreSerializer.StoreToUri(backward, RandomFileNameGenerator.GenerateUri(scene.Uri, EditorConstants.ElementFileExtension)); + CoreSerializer.StoreToUri(backward, ElementFileNaming.GetUri(scene.Uri, backward.Id)); scene.AddChild(backward); backward.NotifySplitted(true, forwardDuration, -forwardDuration); target.NotifySplitted(false, TimeSpan.Zero, -backwardDuration); diff --git a/src/Beutl.Editor/VersionControl/GitCliRunner.cs b/src/Beutl.Editor/VersionControl/GitCliRunner.cs new file mode 100644 index 0000000000..c6fef92124 --- /dev/null +++ b/src/Beutl.Editor/VersionControl/GitCliRunner.cs @@ -0,0 +1,925 @@ +using System.Diagnostics; +using System.Text; + +namespace Beutl.Editor.VersionControl; + +internal sealed record GitCommandResult( + int ExitCode, + string Stdout, + string Stderr, + bool StdoutTruncated = false); + +internal enum GitCommandExecutionKind +{ + Local, + LocalWithLfs, + Network, +} + +[Flags] +internal enum GitExecutionPolicy +{ + Unbounded = 0, + LocalTimeout = 1 << 0, + DefaultOpenSshBatchMode = 1 << 1, +} + +internal sealed record GitCommandOptions( + GitCommandExecutionKind ExecutionKind, + IReadOnlyDictionary? EnvironmentOverrides = null, + int? MaxStdoutBytes = null, + string? StandardInput = null, + bool UseLiteralPathspecs = true) +{ + public static GitCommandOptions Local { get; } = new(GitCommandExecutionKind.Local); + + public static GitCommandOptions Network { get; } = new(GitCommandExecutionKind.Network); +} + +internal sealed class GitRepositoryLockEventArgs( + RepositoryInfo repository, + GitOperationException exception) : EventArgs +{ + public RepositoryInfo Repository { get; } = repository; + + public GitOperationException Exception { get; } = exception; +} + +internal interface IGitCliRunner +{ + bool HasActiveProcess { get; } + + Task RunAsync( + RepositoryInfo repository, + IReadOnlyList arguments, + GitCommandOptions options, + CancellationToken cancellationToken, + IProgress? stderrProgress = null); + + RepositoryLockInfo? GetRecoverableRepositoryLock(RepositoryInfo repository); + + bool RemoveRecoverableRepositoryLock( + RepositoryInfo repository, + RepositoryLockInfo lockInfo); +} + +internal sealed class GitCliRunner : IGitCliRunner +{ + private const string DefaultSshCommand = "ssh -oBatchMode=yes"; + private static readonly string[] s_repositoryLocalEnvironmentVariables = + [ + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_AUTHOR_EMAIL", + "GIT_AUTHOR_NAME", + "GIT_CEILING_DIRECTORIES", + "GIT_COMMITTER_EMAIL", + "GIT_COMMITTER_NAME", + "GIT_CONFIG", + "GIT_CONFIG_PARAMETERS", + "GIT_CONFIG_COUNT", + "GIT_OBJECT_DIRECTORY", + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_IMPLICIT_WORK_TREE", + "GIT_GRAFT_FILE", + "GIT_INDEX_FILE", + "GIT_NO_REPLACE_OBJECTS", + "GIT_REPLACE_REF_BASE", + "GIT_PREFIX", + "GIT_SHALLOW_FILE", + "GIT_COMMON_DIR", + ]; + private static readonly TimeSpan s_cleanupGracePeriod = TimeSpan.FromSeconds(1); + private static readonly TimeSpan s_defaultLocalTimeout = TimeSpan.FromSeconds(30); + internal static readonly TimeSpan StaleLockAge = TimeSpan.FromMinutes(10); + private readonly string _gitPath; + private readonly TimeSpan _localTimeout; + private readonly IReadOnlyDictionary? _environmentOverrides; + private readonly TimeProvider _timeProvider; + private readonly Func _readAllText; + private readonly Action _deleteFile; + private int _activeProcesses; + + internal GitCliRunner(string gitPath) + : this( + gitPath, + s_defaultLocalTimeout, + environmentOverrides: null, + timeProvider: null) + { + } + + internal GitCliRunner( + string gitPath, + TimeSpan localTimeout, + IReadOnlyDictionary? environmentOverrides, + TimeProvider? timeProvider = null, + Func? readAllText = null, + Action? deleteFile = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(gitPath); + if (localTimeout <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(localTimeout)); + } + + _gitPath = gitPath; + _localTimeout = localTimeout; + _environmentOverrides = environmentOverrides; + _timeProvider = timeProvider ?? TimeProvider.System; + _readAllText = readAllText ?? File.ReadAllText; + _deleteFile = deleteFile ?? File.Delete; + } + + public event EventHandler? RepositoryLockFailed; + + public bool HasActiveProcess => Volatile.Read(ref _activeProcesses) > 0; + + public async Task RunAsync( + RepositoryInfo repository, + IReadOnlyList arguments, + GitCommandOptions options, + CancellationToken cancellationToken = default, + IProgress? stderrProgress = null) + { + ArgumentNullException.ThrowIfNull(repository); + ArgumentNullException.ThrowIfNull(arguments); + ArgumentNullException.ThrowIfNull(options); + if (options.MaxStdoutBytes is < 0) + { + throw new ArgumentOutOfRangeException(nameof(options)); + } + + GitExecutionPolicy executionPolicy = await ResolveExecutionPolicyAsync( + repository, + options, + cancellationToken).ConfigureAwait(false); + ProcessStartInfo startInfo = CreateStartInfo( + repository, + arguments, + executionPolicy, + options.EnvironmentOverrides, + options.UseLiteralPathspecs); + return await RunProcessAsync( + repository, + startInfo, + executionPolicy, + cancellationToken, + stderrProgress, + options.MaxStdoutBytes, + options.StandardInput, + throwOnFailure: true).ConfigureAwait(false); + } + + internal async Task CreateStartInfoAsync( + RepositoryInfo repository, + IReadOnlyList arguments, + GitCommandOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + ArgumentNullException.ThrowIfNull(arguments); + ArgumentNullException.ThrowIfNull(options); + if (options.MaxStdoutBytes is < 0) + { + throw new ArgumentOutOfRangeException(nameof(options)); + } + + GitExecutionPolicy executionPolicy = await ResolveExecutionPolicyAsync( + repository, + options, + cancellationToken).ConfigureAwait(false); + return CreateStartInfo( + repository, + arguments, + executionPolicy, + options.EnvironmentOverrides, + options.UseLiteralPathspecs); + } + + private async Task RunProcessAsync( + RepositoryInfo repository, + ProcessStartInfo startInfo, + GitExecutionPolicy executionPolicy, + CancellationToken cancellationToken, + IProgress? stderrProgress, + int? maxStdoutBytes, + string? standardInput, + bool throwOnFailure) + { + using var process = new Process { StartInfo = startInfo }; + Interlocked.Increment(ref _activeProcesses); + try + { + try + { + process.Start(); + } + catch (System.ComponentModel.Win32Exception ex) + { + throw new GitOperationException(-1, ex.Message); + } + + Task<(string Output, bool Truncated)> stdoutTask = ReadStandardOutputAsync( + process.StandardOutput.BaseStream, + maxStdoutBytes); + Task stderrTask = stderrProgress is null + ? process.StandardError.ReadToEndAsync() + : ReadStandardErrorAsync(process.StandardError, stderrProgress); + using var timeoutCts = executionPolicy.HasFlag(GitExecutionPolicy.LocalTimeout) + ? new CancellationTokenSource(_localTimeout) + : null; + using var linkedCts = timeoutCts is null + ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken) + : CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + Task stdinTask = WriteStandardInputAsync( + process.StandardInput, + standardInput, + linkedCts.Token); + Task processExitTask = process.WaitForExitAsync(CancellationToken.None); + Task completion = Task.WhenAll( + processExitTask, + stdinTask, + stdoutTask, + stderrTask); + + try + { + await completion.WaitAsync(linkedCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (linkedCts.IsCancellationRequested) + { + TryKillProcessTree(process); + TryCloseRedirectedStreams(process); + Task cleanup = Task.WhenAll( + ObserveCleanupTaskAsync(completion), + ObserveCleanupTaskAsync(processExitTask), + ObserveCleanupTaskAsync(stdinTask), + ObserveCleanupTaskAsync(stdoutTask), + ObserveCleanupTaskAsync(stderrTask)); + await WaitForCleanupGracePeriodAsync(cleanup).ConfigureAwait(false); + if (!cancellationToken.IsCancellationRequested && timeoutCts?.IsCancellationRequested == true) + { + throw new TimeoutException($"Git did not finish within {_localTimeout}."); + } + + cancellationToken.ThrowIfCancellationRequested(); + throw; + } + + (string stdout, bool stdoutTruncated) = await stdoutTask.ConfigureAwait(false); + string stderr = GitDiagnosticSanitizer.RedactCredentials( + await stderrTask.ConfigureAwait(false)); + if (throwOnFailure && process.ExitCode != 0) + { + var exception = new GitOperationException(process.ExitCode, stderr); + if (exception.IsRepositoryLockFailure) + { + RepositoryLockFailed?.Invoke( + this, + new GitRepositoryLockEventArgs(repository, exception)); + } + + throw exception; + } + + return new GitCommandResult( + process.ExitCode, + stdout, + stderr, + stdoutTruncated); + } + finally + { + Interlocked.Decrement(ref _activeProcesses); + } + } + + private static async Task WaitForCleanupGracePeriodAsync(Task cleanup) + { + try + { + await cleanup.WaitAsync(s_cleanupGracePeriod).ConfigureAwait(false); + } + catch (TimeoutException) + { + } + } + + private static async Task ObserveCleanupTaskAsync(Task task) + { + try + { + await task.ConfigureAwait(false); + } + catch (Exception) + { + } + } + + public static IReadOnlyList SplitNullSeparated(string output) + { + ArgumentNullException.ThrowIfNull(output); + return output.Split('\0', StringSplitOptions.RemoveEmptyEntries); + } + + public RepositoryLockInfo? GetRecoverableRepositoryLock(RepositoryInfo repository) + { + ArgumentNullException.ThrowIfNull(repository); + if (HasActiveProcess) + { + return null; + } + + try + { + foreach (string lockPath in GetRepositoryLockPaths(repository)) + { + if (!File.Exists(lockPath)) + { + continue; + } + + var lastWriteTime = new DateTimeOffset( + File.GetLastWriteTimeUtc(lockPath), + TimeSpan.Zero); + if (_timeProvider.GetUtcNow() - lastWriteTime > StaleLockAge) + { + return new RepositoryLockInfo(lockPath, lastWriteTime); + } + } + + return null; + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or ArgumentException + or NotSupportedException) + { + return null; + } + } + + public bool RemoveRecoverableRepositoryLock( + RepositoryInfo repository, + RepositoryLockInfo lockInfo) + { + ArgumentNullException.ThrowIfNull(repository); + ArgumentNullException.ThrowIfNull(lockInfo); + RepositoryLockInfo? current = GetRecoverableRepositoryLock(repository); + if (current is null + || !string.Equals( + current.LockPath, + Path.GetFullPath(lockInfo.LockPath), + PathComparison) + || current.LastWriteTimeUtc != lockInfo.LastWriteTimeUtc) + { + return false; + } + + try + { + _deleteFile(current.LockPath); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return false; + } + } + + internal ProcessStartInfo CreateStartInfo( + RepositoryInfo repository, + IReadOnlyList arguments, + GitExecutionPolicy executionPolicy, + IReadOnlyDictionary? environmentOverrides = null, + bool useLiteralPathspecs = true) + { + var startInfo = new ProcessStartInfo(_gitPath) + { + WorkingDirectory = repository.RepoRoot, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + foreach (string argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + foreach (string name in s_repositoryLocalEnvironmentVariables) + { + startInfo.Environment.Remove(name); + } + + ApplyEnvironmentOverrides(startInfo, _environmentOverrides); + ApplyEnvironmentOverrides(startInfo, environmentOverrides); + + startInfo.Environment["GIT_TERMINAL_PROMPT"] = "0"; + startInfo.Environment["GIT_OPTIONAL_LOCKS"] = "0"; + startInfo.Environment["GIT_LITERAL_PATHSPECS"] = useLiteralPathspecs ? "1" : "0"; + startInfo.Environment["LC_ALL"] = "C"; + if (executionPolicy.HasFlag(GitExecutionPolicy.DefaultOpenSshBatchMode) + && !HasConfiguredSshCommandEnvironment(startInfo) + && IsDefaultOpenSshVariant(GetSshVariantEnvironment(startInfo))) + { + startInfo.Environment["GIT_SSH_COMMAND"] = DefaultSshCommand; + } + + return startInfo; + } + + private async Task ResolveExecutionPolicyAsync( + RepositoryInfo repository, + GitCommandOptions options, + CancellationToken cancellationToken) + { + if (options.ExecutionKind == GitCommandExecutionKind.Local) + { + return GitExecutionPolicy.LocalTimeout; + } + + bool localWithLfs = options.ExecutionKind == GitCommandExecutionKind.LocalWithLfs; + if (!localWithLfs && options.ExecutionKind != GitCommandExecutionKind.Network) + { + throw new ArgumentOutOfRangeException(nameof(options)); + } + + ProcessStartInfo environmentProbe = CreateStartInfo( + repository, + [], + GitExecutionPolicy.Unbounded, + options.EnvironmentOverrides, + options.UseLiteralPathspecs); + if (HasConfiguredSshCommandEnvironment(environmentProbe)) + { + return GitExecutionPolicy.Unbounded; + } + + ProcessStartInfo configProbe = CreateStartInfo( + repository, + ["config", "--null", "--get-regexp", "^(core\\.sshcommand|ssh\\.variant)$"], + GitExecutionPolicy.LocalTimeout, + options.EnvironmentOverrides, + options.UseLiteralPathspecs); + GitCommandResult configResult = await RunProcessAsync( + repository, + configProbe, + GitExecutionPolicy.LocalTimeout, + cancellationToken, + stderrProgress: null, + maxStdoutBytes: null, + standardInput: null, + throwOnFailure: false).ConfigureAwait(false); + + if (configResult.ExitCode == 1) + { + return IsDefaultOpenSshVariant(GetSshVariantEnvironment(environmentProbe)) + ? GitExecutionPolicy.DefaultOpenSshBatchMode + : GitExecutionPolicy.Unbounded; + } + + if (configResult.ExitCode != 0) + { + return GitExecutionPolicy.Unbounded; + } + + bool foundConfiguration = false; + bool hasConfiguredSshCommand = false; + string? configuredVariant = null; + foreach (string record in SplitNullSeparated(configResult.Stdout)) + { + int separator = record.IndexOf('\n'); + if (separator < 0) + { + continue; + } + + string name = record[..separator]; + string value = record[(separator + 1)..]; + if (string.Equals(name, "core.sshcommand", StringComparison.OrdinalIgnoreCase)) + { + foundConfiguration = true; + hasConfiguredSshCommand = true; + } + else if (string.Equals(name, "ssh.variant", StringComparison.OrdinalIgnoreCase)) + { + foundConfiguration = true; + configuredVariant = value; + } + } + + if (!foundConfiguration || hasConfiguredSshCommand) + { + return GitExecutionPolicy.Unbounded; + } + + string? effectiveVariant = GetSshVariantEnvironment(environmentProbe) ?? configuredVariant; + return IsDefaultOpenSshVariant(effectiveVariant) + ? GitExecutionPolicy.DefaultOpenSshBatchMode + : GitExecutionPolicy.Unbounded; + } + + private static void ApplyEnvironmentOverrides( + ProcessStartInfo startInfo, + IReadOnlyDictionary? overrides) + { + if (overrides is null) + { + return; + } + + foreach ((string key, string? value) in overrides) + { + if (value is null) + { + startInfo.Environment.Remove(key); + } + else + { + startInfo.Environment[key] = value; + } + } + } + + private static bool HasConfiguredSshCommandEnvironment(ProcessStartInfo startInfo) + => startInfo.Environment.ContainsKey("GIT_SSH_COMMAND") + || startInfo.Environment.ContainsKey("GIT_SSH"); + + private static string? GetSshVariantEnvironment(ProcessStartInfo startInfo) + => startInfo.Environment.TryGetValue("GIT_SSH_VARIANT", out string? variant) + ? variant + : null; + + private static bool IsDefaultOpenSshVariant(string? variant) + => variant is null + || string.Equals(variant.Trim(), "ssh", StringComparison.OrdinalIgnoreCase); + + private static void TryKillProcessTree(Process process) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch (Exception ex) when (ex is InvalidOperationException + or System.ComponentModel.Win32Exception + or NotSupportedException + or AggregateException) + { + } + } + + private static void TryCloseRedirectedStreams(Process process) + { + TryCloseStream(() => process.StandardInput.BaseStream); + TryCloseStream(() => process.StandardOutput.BaseStream); + TryCloseStream(() => process.StandardError.BaseStream); + } + + private static void TryCloseStream(Func getStream) + { + try + { + getStream().Dispose(); + } + catch (Exception) + { + } + } + + private static async Task WriteStandardInputAsync( + TextWriter writer, + string? input, + CancellationToken cancellationToken) + { + try + { + if (input is not null) + { + await writer.WriteAsync(input.AsMemory(), cancellationToken).ConfigureAwait(false); + await writer.FlushAsync(cancellationToken).ConfigureAwait(false); + } + } + finally + { + writer.Close(); + } + } + + internal const int MaxRetainedStandardErrorLength = 64 * 1024; + + internal const int MaxProgressRecordLength = 4 * 1024; + + internal static async Task ReadStandardErrorAsync( + TextReader reader, + IProgress progress) + { + var output = new System.Text.StringBuilder(); + var progressLine = new System.Text.StringBuilder(); + var buffer = new char[256]; + int count; + while ((count = await reader.ReadAsync(buffer).ConfigureAwait(false)) > 0) + { + output.Append(buffer, 0, count); + // Sideband progress streams for the whole operation, so only the tail is retained: + // Git prints the failure it is classified by last, and a noisy remote must not be able + // to grow this buffer for as long as it keeps talking. + if (output.Length > MaxRetainedStandardErrorLength) + { + output.Remove(0, output.Length - MaxRetainedStandardErrorLength); + } + for (int i = 0; i < count; i++) + { + char value = buffer[i]; + if (value is '\r' or '\n') + { + if (progressLine.Length > 0) + { + progress.Report(GitDiagnosticSanitizer.RedactCredentials( + progressLine.ToString())); + progressLine.Clear(); + } + } + else + { + progressLine.Append(value); + // A record that never reaches a delimiter would otherwise be buffered whole and + // handed to the caller as one huge string; report what has accumulated so far + // and keep draining. + if (progressLine.Length >= MaxProgressRecordLength) + { + progress.Report(GitDiagnosticSanitizer.RedactCredentials( + progressLine.ToString())); + progressLine.Clear(); + } + } + } + } + + if (progressLine.Length > 0) + { + progress.Report(GitDiagnosticSanitizer.RedactCredentials( + progressLine.ToString())); + } + + return GitDiagnosticSanitizer.RedactCredentials(output.ToString()); + } + + internal static async Task<(string Output, bool Truncated)> ReadStandardOutputAsync( + Stream stream, + int? maxBytes) + { + ArgumentNullException.ThrowIfNull(stream); + if (maxBytes is < 0) + { + throw new ArgumentOutOfRangeException(nameof(maxBytes)); + } + + if (maxBytes is null) + { + using var reader = new StreamReader( + stream, + Encoding.UTF8, + detectEncodingFromByteOrderMarks: true, + leaveOpen: true); + return (await reader.ReadToEndAsync().ConfigureAwait(false), false); + } + + int limit = maxBytes.Value; + var captured = new byte[limit]; + var buffer = new byte[8192]; + int capturedCount = 0; + bool truncated = false; + int count; + while ((count = await stream.ReadAsync(buffer).ConfigureAwait(false)) > 0) + { + int copyCount = Math.Min(count, limit - capturedCount); + if (copyCount > 0) + { + buffer.AsSpan(0, copyCount).CopyTo(captured.AsSpan(capturedCount)); + capturedCount += copyCount; + } + + truncated |= copyCount < count; + } + + int completeByteCount = GetCompleteUtf8PrefixLength( + captured.AsSpan(0, capturedCount)); + return ( + Encoding.UTF8.GetString(captured, 0, completeByteCount), + truncated); + } + + private static int GetCompleteUtf8PrefixLength(ReadOnlySpan bytes) + { + if (bytes.IsEmpty) + { + return 0; + } + + int sequenceStart = bytes.Length - 1; + while (sequenceStart > 0 && (bytes[sequenceStart] & 0xC0) == 0x80) + { + sequenceStart--; + } + + int sequenceLength = bytes[sequenceStart] switch + { + < 0x80 => 1, + >= 0xC2 and <= 0xDF => 2, + >= 0xE0 and <= 0xEF => 3, + >= 0xF0 and <= 0xF4 => 4, + _ => 1, + }; + return bytes.Length - sequenceStart < sequenceLength + ? sequenceStart + : bytes.Length; + } + + private IReadOnlyList GetRepositoryLockPaths(RepositoryInfo repository) + { + string gitDirectory = GetGitDirectory(repository); + List lockPaths = + [ + Path.Combine(gitDirectory, "index.lock"), + Path.Combine(gitDirectory, "HEAD.lock"), + ]; + try + { + string commonDirectory = GetCommonDirectory(gitDirectory); + lockPaths.Add(Path.Combine(commonDirectory, "config.lock")); + string? branchLockPath = GetCurrentBranchLockPath(gitDirectory); + if (branchLockPath is not null) + { + lockPaths.Add(branchLockPath); + } + + lockPaths.AddRange(GetRefLockPaths(gitDirectory)); + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or ArgumentException + or NotSupportedException) + { + } + + return lockPaths; + } + + private IReadOnlyList GetRefLockPaths(string gitDirectory) + { + string commonDirectory = GetCommonDirectory(gitDirectory); + string refsDirectory = Path.GetFullPath(Path.Combine(commonDirectory, "refs")); + if (!Directory.Exists(refsDirectory)) + { + return []; + } + + string refsPrefix = Path.TrimEndingDirectorySeparator(refsDirectory) + + Path.DirectorySeparatorChar; + var lockPaths = new List(); + foreach (string candidate in Directory.EnumerateFiles( + refsDirectory, + "*.lock", + SearchOption.AllDirectories)) + { + string fullPath = Path.GetFullPath(candidate); + if (!fullPath.StartsWith(refsPrefix, PathComparison)) + { + continue; + } + + if (ContainsReparsePoint( + commonDirectory, + Path.GetDirectoryName(fullPath)!)) + { + continue; + } + + lockPaths.Add(fullPath); + } + + return lockPaths; + } + + private string? GetCurrentBranchLockPath(string gitDirectory) + { + const string refPrefix = "ref: refs/heads/"; + string head = _readAllText(Path.Combine(gitDirectory, "HEAD")).Trim(); + if (!head.StartsWith(refPrefix, StringComparison.Ordinal)) + { + return null; + } + + string branchPath = head[refPrefix.Length..]; + if (string.IsNullOrWhiteSpace(branchPath) + || Path.IsPathFullyQualified(branchPath) + || branchPath + .Split(['/', '\\']) + .Any(static segment => segment is "" or "." or "..")) + { + return null; + } + + string commonDirectory = GetCommonDirectory(gitDirectory); + string headsDirectory = Path.GetFullPath( + Path.Combine(commonDirectory, "refs", "heads")); + string refPath = Path.GetFullPath(Path.Combine( + headsDirectory, + branchPath.Replace('/', Path.DirectorySeparatorChar))); + string headsPrefix = Path.TrimEndingDirectorySeparator(headsDirectory) + + Path.DirectorySeparatorChar; + if (!refPath.StartsWith(headsPrefix, PathComparison)) + { + return null; + } + + if (ContainsReparsePoint( + commonDirectory, + Path.GetDirectoryName(refPath)!)) + { + return null; + } + + return refPath + ".lock"; + } + + private static bool ContainsReparsePoint(string root, string path) + { + string current = Path.GetFullPath(path); + string boundary = Path.GetFullPath(root); + while (true) + { + if (Directory.Exists(current) + && (File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) + { + return true; + } + + if (string.Equals(current, boundary, PathComparison)) + { + return false; + } + + string? parent = Path.GetDirectoryName(current); + if (parent is null || string.Equals(parent, current, PathComparison)) + { + return true; + } + + current = parent; + } + } + + private string GetCommonDirectory(string gitDirectory) + { + string commonDirectoryPath = Path.Combine(gitDirectory, "commondir"); + if (!File.Exists(commonDirectoryPath)) + { + return gitDirectory; + } + + string commonDirectory = _readAllText(commonDirectoryPath).Trim(); + if (!Path.IsPathFullyQualified(commonDirectory)) + { + commonDirectory = Path.Combine(gitDirectory, commonDirectory); + } + + return Path.GetFullPath(commonDirectory); + } + + private string GetGitDirectory(RepositoryInfo repository) + { + string dotGitPath = Path.Combine(repository.RepoRoot, ".git"); + if (Directory.Exists(dotGitPath)) + { + return Path.GetFullPath(dotGitPath); + } + + if (File.Exists(dotGitPath)) + { + const string prefix = "gitdir:"; + string contents = _readAllText(dotGitPath).Trim(); + if (contents.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + string gitDirectory = contents[prefix.Length..].Trim(); + if (!Path.IsPathFullyQualified(gitDirectory)) + { + gitDirectory = Path.Combine(repository.RepoRoot, gitDirectory); + } + + return Path.GetFullPath(gitDirectory); + } + } + + return Path.GetFullPath(dotGitPath); + } + + private static StringComparison PathComparison + => FileSystemPathComparison.ForCurrentPlatform; +} diff --git a/src/Beutl.Editor/VersionControl/GitCliVersionControlService.cs b/src/Beutl.Editor/VersionControl/GitCliVersionControlService.cs new file mode 100644 index 0000000000..821c4ab967 --- /dev/null +++ b/src/Beutl.Editor/VersionControl/GitCliVersionControlService.cs @@ -0,0 +1,9319 @@ +using System.Collections.Concurrent; +using System.Text; +using System.Text.Json; +using Beutl.Language; +using Beutl.Logging; +using Beutl.Serialization; +using Microsoft.Extensions.Logging; + +namespace Beutl.Editor.VersionControl; + +internal sealed class GitCliVersionControlService : + IProjectVersionControlBackend +{ + private const int PendingPullRecoveryFormatVersion = 1; + private const int MaxPendingRecoveryListBytes = 1024 * 1024; + private const int MaxPendingRecoveryDescriptorBytes = 64 * 1024; + // A repository can restrict which LFS paths are hydrated (lfs.fetchinclude / lfs.fetchexclude). + // A transition has to reopen the project on its real media, so the prefetch and the checkout it + // feeds both clear those filters: an excluded pointer is copied through unchanged, which would + // leave pointer text in the work tree where the media belongs. + private static readonly string[] s_lfsPathFilterOverrides = + [ + "-c", + "lfs.fetchinclude=", + "-c", + "lfs.fetchexclude=", + ]; + + private static readonly JsonSerializerOptions s_recoveryJsonOptions = + new(JsonSerializerOptions.Strict); + + private sealed record PendingPullRecoveryData( + int Version, + string Id, + string CheckpointRef, + string CheckpointCommit, + string BranchRef, + string BaseCommit, + string TargetCommit, + string ProjectFile, + DateTimeOffset CreatedAt); + + private sealed record LfsAttributeQueryResult( + HashSet CoveredPaths, + bool IsComplete); + + private sealed record WorktreeStateFingerprint(string Tree, string IndexEntries); + + private enum TreeTransitionOutcome + { + AppliedTarget, + RestoredCurrent, + OwnershipLost, + RecoveryFailed, + } + + private enum PullRelation + { + Equal, + LocalBehind, + LocalAhead, + Diverged, + } + + private sealed record PullFetchTarget( + IReadOnlyList Arguments, + string UpstreamRef); + + private sealed record TreeTransitionResult( + TreeTransitionOutcome Outcome, + Exception? Error = null, + CheckedOutBranchTip? ActualTip = null); + + private sealed record TreeTransitionIndexPlan( + string? PrepareCommit = null, + string? FinalCommit = null, + string? RestoreCommit = null, + string Pathspec = "."); + + private sealed class HeadOwnershipLease : IDisposable + { + private readonly Action? _releaseFailureSink; + private FileStream? _stream; + + private HeadOwnershipLease( + string lockPath, + FileStream stream, + Action? releaseFailureSink) + { + LockPath = lockPath; + _stream = stream; + _releaseFailureSink = releaseFailureSink; + } + + public string LockPath { get; } + + public static HeadOwnershipLease Acquire( + string headPath, + string expectedRefName, + Action? releaseFailureSink) + { + string lockPath = headPath + ".lock"; + FileStream stream; + try + { + stream = new FileStream( + lockPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 1, + FileOptions.WriteThrough); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + throw new GitOperationException( + 128, + $"Unable to acquire the worktree HEAD lock '{lockPath}': {ex.Message}"); + } + + var lease = new HeadOwnershipLease(lockPath, stream, releaseFailureSink); + try + { + string expected = $"ref: {expectedRefName}\n"; + string actual = File.ReadAllText(headPath, new UTF8Encoding(false)); + if (!string.Equals(actual, expected, StringComparison.Ordinal)) + { + throw new ProjectCheckpointStateChangedException(); + } + + return lease; + } + catch + { + lease.Dispose(); + throw; + } + } + + public void Dispose() + { + FileStream? stream = Interlocked.Exchange(ref _stream, null); + if (stream is null) + { + return; + } + + try + { + stream.Dispose(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _releaseFailureSink?.Invoke(ex); + } + + try + { + File.Delete(LockPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _releaseFailureSink?.Invoke(ex); + } + } + } + + internal const int MaxDiffBytes = 1024 * 1024; + internal const string DiffTruncationMarker = "\n--- Diff truncated at 1 MB ---\n"; + private const string OriginRefPrefix = "refs/remotes/origin/"; + private const string LfsQuotaNoticeConfigKeyPrefix = "beutl.lfsQuotaNoticeShown-"; + private const string LargeMediaNoticeConfigKeyPrefix = "beutl.largeMediaNoticeShown-"; + private const string MissingIdentityNoticeConfigKeyPrefix = "beutl.missingIdentityNoticeShown-"; + private const string PullSafetyCommitMessage = "beutl: safety snapshot before pull"; + private const string ManagedLfsBeginMarker = "# BEGIN BEUTL MANAGED LFS"; + private const string ManagedLfsEndMarker = "# END BEUTL MANAGED LFS"; + private const int MaxHygieneWriteAttempts = 3; + private const int MaxIgnoredRequiredPathOutputBytes = 256 * 1024; + private const int MaxLfsAttributeOutputBytes = 256 * 1024; + + private static readonly string[] s_gitIgnoreLines = + [ + "**/.beutl/", + "*.tmp", + ]; + + private static readonly string[] s_textAttributeLines = + [ + "*.[bB][eE][pP] text eol=lf", + "*.[sS][cC][eE][nN][eE] text eol=lf", + "*.[bB][eE][lL][mM] text eol=lf", + ".gitignore text eol=lf", + ".gitattributes text eol=lf", + ]; + + // Stable union of the existing policy, Engine built-in decoders, the FFmpeg and + // MF/AVF decoders, and SharedFilePickerOptions.OpenImage. Do not derive this from + // DecoderRegistry: repository attributes must not vary with platform or extension load state. + private static readonly string[] s_supportedMediaExtensions = + [ + ".mp4", + ".mov", + ".mkv", + ".avi", + ".wmv", + ".flv", + ".webm", + ".wav", + ".mp3", + ".flac", + ".aac", + ".m4a", + ".ogg", + ".opus", + ".wma", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".bmp", + ".webp", + ".tiff", + ".tif", + // Engine built-in decoder additions. + ".wave", + ".apng", + // FFmpeg decoder additions. + ".264", + ".mpeg", + ".ts", + ".mts", + ".m2ts", + // Media Foundation and AVFoundation decoder additions. + ".sami", + ".smi", + ".m4v", + ".adts", + ".asf", + ".3gp", + ".3gp2", + ".3gpp", + // SharedFilePickerOptions.OpenImage additions. + ".ico", + ".wbmp", + ".pkm", + ".ktx", + ".astc", + ".dng", + ".heif", + ".avif", + ]; + + private static readonly string[] s_lfsAttributeLines = + s_supportedMediaExtensions + .Select(static extension => + $"**/*{CreateCaseInsensitiveGlob(extension)} " + + "filter=lfs diff=lfs merge=lfs -text") + .ToArray(); + + private static readonly HashSet s_mediaExtensions = new( + s_supportedMediaExtensions, + StringComparer.OrdinalIgnoreCase); + + internal static bool IsSupportedMediaPath(string path) + { + ArgumentNullException.ThrowIfNull(path); + return s_mediaExtensions.Contains(Path.GetExtension(path)); + } + + private static readonly HashSet s_projectFileExtensions = new( + [".bep", ".scene", ".belm"], + StringComparer.OrdinalIgnoreCase); + + private static readonly string[] s_ignoredRequiredProjectPathspecSuffixes = + [ + "**/*.[bB][eE][pP]", + "**/*.[sS][cC][eE][nN][eE]", + "**/*.[bB][eE][lL][mM]", + "**/[rR][eE][sS][oO][uU][rR][cC][eE][sS]/**", + ".gitignore", + ".gitattributes", + ]; + + private const string TemporaryFilePathspecSuffix = "**/*.[tT][mM][pP]"; + + private static readonly string[] s_ignoredOptionalProjectPathspecSuffixes = + [ + "**/.[bB][eE][uU][tT][lL]/**", + TemporaryFilePathspecSuffix, + ]; + + private IReadOnlyList CreateSnapshotExcludePathspecs(RepositoryInfo repository) + { + return CreateSnapshotExcludePathspecs( + repository, + excludeTemporaryFiles: !_snapshotsRequireTemporaryFiles); + } + + // `.tmp` is Beutl's own scratch convention, but an extension may persist a sidecar the project + // references under such a name. Excluding it would leave every snapshot without a file the + // project needs to reopen, so the blanket exclusion is dropped for that project. + internal static IReadOnlyList CreateSnapshotExcludePathspecs( + RepositoryInfo repository, + bool excludeTemporaryFiles) + { + string prefix = repository.Pathspec == "." + ? string.Empty + : EscapeGitGlobPath(repository.Pathspec) + "/"; + IEnumerable suffixes = excludeTemporaryFiles + ? s_ignoredOptionalProjectPathspecSuffixes + : s_ignoredOptionalProjectPathspecSuffixes.Where( + static suffix => suffix != TemporaryFilePathspecSuffix); + return suffixes + .Select(suffix => $":(top,exclude,glob){prefix}{suffix}") + .ToArray(); + } + + internal static bool RequiresTemporaryFileSnapshots(IReadOnlySet serializedPaths) + { + ArgumentNullException.ThrowIfNull(serializedPaths); + return serializedPaths.Any( + static path => path.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase)); + } + + private static string CreateSnapshotBasePathspec(RepositoryInfo repository) + { + return repository.Pathspec == "." + ? "." + : $":(top,literal){repository.Pathspec}"; + } + + private static string CreateSnapshotCommitPathspec(RepositoryInfo repository) + { + string prefix = repository.Pathspec == "." + ? string.Empty + : EscapeGitGlobPath(repository.Pathspec) + "/"; + return $":(top,glob){prefix}**"; + } + + private static readonly string[] s_repositoryOperationRefs = + [ + "MERGE_HEAD", + "CHERRY_PICK_HEAD", + "REVERT_HEAD", + "rebase-merge", + "rebase-apply", + "sequencer", + ]; + + private static string CreateCaseInsensitiveGlob(string value) + { + var builder = new StringBuilder(value.Length * 4); + foreach (char character in value) + { + if (character is >= 'a' and <= 'z') + { + builder.Append('[') + .Append(character) + .Append(char.ToUpperInvariant(character)) + .Append(']'); + } + else + { + builder.Append(character); + } + } + + return builder.ToString(); + } + + private readonly GitInstallationLocator _installationLocator; + private readonly Func _runnerFactory; + private readonly Func _isWorktreeMutationAllowed; + private readonly string? _projectFile; + private volatile bool _snapshotsRequireTemporaryFiles; + private readonly Func? _policyNoticeSink; + private readonly Func? _beforeHygieneFileReplace; + private readonly Func? _beforeHygieneFileCommit; + private readonly Action _statusNotificationScheduler; + private readonly ILogger _logger; + private readonly bool _createWatcherWhenRepositoryAvailable; + private readonly SemaphoreSlim _operationGate = new(1, 1); + private readonly object _lifetimeSync = new(); + private readonly object _runtimeSync = new(); + private readonly ConcurrentQueue _statusNotifications = new(); + private RepositoryWatcher? _watcher; + private GitAvailability? _cachedAvailability; + private IGitCliRunner? _runner; + private Task? _retirementTask; + private int _configurationRevision; + private int _lifetimeState; + private int _resourcesDisposed; + private int _statusNotificationDrainScheduled; + + public GitCliVersionControlService( + GitInstallationLocator installationLocator, + RepositoryInfo? repository = null) + : this( + installationLocator, + repository, + repository is null ? null : new RepositoryWatcher(repository), + static gitPath => new GitCliRunner(gitPath), + createWatcherWhenRepositoryAvailable: true, + isWorktreeMutationAllowed: static () => true, + projectFile: null, + policyNoticeSink: null, + beforeHygieneFileReplace: null, + beforeHygieneFileCommit: null, + statusNotificationScheduler: null, + logger: null) + { + } + + internal GitCliVersionControlService( + GitInstallationLocator installationLocator, + RepositoryInfo? repository, + Func isWorktreeMutationAllowed, + Func? policyNoticeSink = null, + string? projectFile = null) + : this( + installationLocator, + repository, + repository is null ? null : new RepositoryWatcher(repository), + static gitPath => new GitCliRunner(gitPath), + createWatcherWhenRepositoryAvailable: true, + isWorktreeMutationAllowed: isWorktreeMutationAllowed, + projectFile: projectFile, + policyNoticeSink: policyNoticeSink, + beforeHygieneFileReplace: null, + beforeHygieneFileCommit: null, + statusNotificationScheduler: null, + logger: null) + { + } + + internal GitCliVersionControlService( + GitInstallationLocator installationLocator, + RepositoryInfo? repository, + RepositoryWatcher? watcher, + Func runnerFactory, + ILogger? logger = null, + Func? beforeHygieneFileReplace = null, + Func? beforeHygieneFileCommit = null, + Func? policyNoticeSink = null, + Action? statusNotificationScheduler = null, + string? projectFile = null) + : this( + installationLocator, + repository, + watcher, + runnerFactory, + createWatcherWhenRepositoryAvailable: false, + isWorktreeMutationAllowed: static () => true, + projectFile: projectFile, + policyNoticeSink, + beforeHygieneFileReplace: beforeHygieneFileReplace, + beforeHygieneFileCommit: beforeHygieneFileCommit, + statusNotificationScheduler: statusNotificationScheduler, + logger: logger) + { + } + + private GitCliVersionControlService( + GitInstallationLocator installationLocator, + RepositoryInfo? repository, + RepositoryWatcher? watcher, + Func runnerFactory, + bool createWatcherWhenRepositoryAvailable, + Func isWorktreeMutationAllowed, + string? projectFile, + Func? policyNoticeSink, + Func? beforeHygieneFileReplace, + Func? beforeHygieneFileCommit, + Action? statusNotificationScheduler, + ILogger? logger) + { + _installationLocator = installationLocator + ?? throw new ArgumentNullException(nameof(installationLocator)); + if (watcher is not null && repository is null) + { + throw new ArgumentException( + "A watcher can only be supplied for an associated repository.", + nameof(watcher)); + } + + Repository = repository; + _watcher = watcher; + _runnerFactory = runnerFactory ?? throw new ArgumentNullException(nameof(runnerFactory)); + _isWorktreeMutationAllowed = isWorktreeMutationAllowed + ?? throw new ArgumentNullException( + nameof(isWorktreeMutationAllowed)); + _projectFile = projectFile is null ? null : Path.GetFullPath(projectFile); + _policyNoticeSink = policyNoticeSink; + _beforeHygieneFileReplace = beforeHygieneFileReplace; + _beforeHygieneFileCommit = beforeHygieneFileCommit; + _statusNotificationScheduler = statusNotificationScheduler ?? ScheduleStatusNotificationDrain; + _logger = logger ?? Log.CreateLogger(); + _createWatcherWhenRepositoryAvailable = createWatcherWhenRepositoryAvailable; + if (_watcher is not null) + { + _watcher.Changed += OnRepositoryChanged; + } + + _installationLocator.Config.ConfigurationChanged += OnVersionControlConfigChanged; + } + + public RepositoryInfo? Repository { get; private set; } + + public RepositoryLockInfo? RecoverableLock { get; private set; } + + public event EventHandler? StatusChanged; + + public event EventHandler? RecoverableLockAvailable; + + public Task GetAvailabilityAsync(CancellationToken cancellationToken) + { + ThrowIfDisposed(); + return RunSerializedAsync( + async () => (await GetGitRuntimeCoreAsync(cancellationToken).ConfigureAwait(false)).Availability, + cancellationToken); + } + + public Task DiscoverRepositoryAsync( + string projectRoot, + CancellationToken cancellationToken) + { + ThrowIfDisposed(); + ArgumentException.ThrowIfNullOrWhiteSpace(projectRoot); + return RunSerializedAsync( + async () => + { + IGitCliRunner runner = await GetInstalledRunnerCoreAsync(cancellationToken) + .ConfigureAwait(false); + return await DiscoverRepositoryCoreAsync(projectRoot, runner, cancellationToken) + .ConfigureAwait(false); + }, + cancellationToken); + } + + public Task InitializeAsync(InitOptions options, CancellationToken cancellationToken) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(options); + return RunSerializedAsync( + () => InitializeCoreAsync(options, cancellationToken), + cancellationToken); + } + + public Task EnsureRepositoryHygieneAsync(CancellationToken cancellationToken) + { + ThrowIfDisposed(); + return RunSerializedAsync( + async () => + { + RepositoryInfo repository = GetRepository(); + (GitAvailability availability, IGitCliRunner? runner) + = await GetGitRuntimeCoreAsync(cancellationToken).ConfigureAwait(false); + if (availability.State != GitAvailabilityState.Installed || runner is null) + { + throw new InvalidOperationException("Git is not available."); + } + + await EnsureRepositoryHygienePreflightCoreAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + bool useLfs = _installationLocator.Config.UseLfsWhenAvailable + && availability.LfsInstalled; + await EnsureRepositoryHygieneCoreAsync( + repository, + runner, + useLfs, + cancellationToken) + .ConfigureAwait(false); + }, + cancellationToken); + } + + public Task HasVersionTrackingOptInAsync( + RepositoryInfo repository, + CancellationToken cancellationToken) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(repository); + return RunSerializedAsync( + () => HasVersionTrackingOptInCoreAsync(repository, cancellationToken), + cancellationToken); + } + + public Task CommitAllAsync( + string message, + SnapshotKind kind, + CancellationToken cancellationToken) + { + ThrowIfDisposed(); + ArgumentException.ThrowIfNullOrWhiteSpace(message); + return RunSerializedAsync( + () => CommitAllCoreAsync(message, kind, cancellationToken), + cancellationToken); + } + + public Task GetCheckedOutBranchTipAsync(CancellationToken cancellationToken) + { + ThrowIfDisposed(); + return RunSerializedAsync( + () => GetCheckedOutBranchTipCoreAsync(cancellationToken), + cancellationToken); + } + + public Task PreflightPullAsync( + CheckedOutBranchTip expectedCurrent, + CancellationToken cancellationToken) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(expectedCurrent); + return RunSerializedAsync( + () => PreflightPullCoreAsync(expectedCurrent, cancellationToken), + cancellationToken); + } + + public Task CreateProjectCheckpointAsync( + string message, + CancellationToken cancellationToken) + { + ThrowIfDisposed(); + ArgumentException.ThrowIfNullOrWhiteSpace(message); + return RunSerializedAsync( + () => CreateProjectCheckpointCoreAsync(message, cancellationToken), + cancellationToken); + } + + public Task PersistPendingPullRecoveryAsync( + ProjectCheckpoint checkpoint, + CheckedOutBranchTip targetTip, + string projectFile, + CancellationToken cancellationToken) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(checkpoint); + ArgumentNullException.ThrowIfNull(targetTip); + ArgumentException.ThrowIfNullOrWhiteSpace(projectFile); + return RunSerializedAsync( + () => PersistPendingPullRecoveryCoreAsync( + checkpoint, + targetTip, + projectFile, + cancellationToken), + cancellationToken); + } + + public Task> GetPendingPullRecoveriesAsync( + CancellationToken cancellationToken) + { + ThrowIfDisposed(); + return RunSerializedAsync( + () => GetPendingPullRecoveriesCoreAsync(cancellationToken), + cancellationToken); + } + + public Task RecoverPendingPullRecoveryAsync( + PendingPullRecovery recovery, + CancellationToken cancellationToken) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(recovery); + return RunSerializedAsync( + () => RecoverPendingPullRecoveryCoreAsync(recovery, cancellationToken), + cancellationToken); + } + + public Task CompletePendingPullRecoveryAsync( + PendingPullRecovery recovery, + CancellationToken cancellationToken) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(recovery); + return RunSerializedAsync( + () => CompletePendingPullRecoveryCoreAsync(recovery, cancellationToken), + cancellationToken); + } + + public Task RestoreProjectCheckpointAsync( + ProjectCheckpoint checkpoint, + CancellationToken cancellationToken) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(checkpoint); + return RunSerializedAsync( + () => RestoreProjectCheckpointCoreAsync(checkpoint, cancellationToken), + cancellationToken); + } + + public Task CommitProjectTreeAsync( + CheckedOutBranchTip expectedCurrent, + string sourceCommit, + string message, + SnapshotKind kind, + CancellationToken cancellationToken) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(expectedCurrent); + GitRevisionValidator.ValidateCommitId(sourceCommit, nameof(sourceCommit)); + ArgumentException.ThrowIfNullOrWhiteSpace(message); + return RunSerializedAsync( + () => CommitProjectTreeCoreAsync( + expectedCurrent, + sourceCommit, + message, + kind, + cancellationToken), + cancellationToken); + } + + public Task RevisionContainsProjectFileAsync( + string sha, + string projectFile, + CancellationToken cancellationToken) + { + ThrowIfDisposed(); + GitRevisionValidator.ValidateCommitId(sha, nameof(sha)); + ArgumentException.ThrowIfNullOrWhiteSpace(projectFile); + return RunSerializedAsync( + () => RevisionContainsProjectFileCoreAsync(sha, projectFile, cancellationToken), + cancellationToken); + } + + private async Task RevisionContainsProjectFileCoreAsync( + string sha, + string projectFile, + CancellationToken cancellationToken) + { + RepositoryInfo repository = GetRepository(); + IGitCliRunner runner = await GetInstalledRunnerCoreAsync(cancellationToken) + .ConfigureAwait(false); + string relativeProjectFile = GetRecoveryProjectFile(repository, projectFile); + try + { + await runner.RunAsync( + repository, + ["cat-file", "-e", $"{sha}:{relativeProjectFile}"], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + return true; + } + catch (GitOperationException ex) when (ex.ExitCode == 128) + { + return false; + } + } + + public Task TryRollbackBranchTipAsync( + CheckedOutBranchTip expectedCurrent, + CheckedOutBranchTip target, + CancellationToken cancellationToken) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(expectedCurrent); + ArgumentNullException.ThrowIfNull(target); + return RunSerializedAsync( + () => TryRollbackBranchTipCoreAsync(expectedCurrent, target, cancellationToken), + cancellationToken); + } + + public Task DeleteProjectCheckpointAsync( + ProjectCheckpoint checkpoint, + CancellationToken cancellationToken) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(checkpoint); + return RunSerializedAsync( + () => DeleteProjectCheckpointCoreAsync(checkpoint, cancellationToken), + cancellationToken); + } + + public Task GetStatusAsync(CancellationToken cancellationToken) + { + ThrowIfDisposed(); + return RunSerializedAsync( + () => GetStatusCoreAsync(cancellationToken), + cancellationToken); + } + + public Task> GetHistoryAsync( + int skip, + int take, + CancellationToken cancellationToken) + { + ThrowIfDisposed(); + ArgumentOutOfRangeException.ThrowIfNegative(skip); + ArgumentOutOfRangeException.ThrowIfLessThan(take, 1); + return RunSerializedAsync( + () => GetHistoryCoreAsync(skip, take, cancellationToken), + cancellationToken); + } + + public Task> GetCommitFilesAsync( + string sha, + CancellationToken cancellationToken) + { + ThrowIfDisposed(); + GitRevisionValidator.ValidateCommitId(sha, nameof(sha)); + return RunSerializedAsync( + () => GetCommitFilesCoreAsync(sha, cancellationToken), + cancellationToken); + } + + public Task GetDiffAsync( + string sha, + string? path, + CancellationToken cancellationToken) + { + ThrowIfDisposed(); + GitRevisionValidator.ValidateCommitId(sha, nameof(sha)); + return RunSerializedAsync( + () => GetDiffCoreAsync(sha, path, cancellationToken), + cancellationToken); + } + + public Task> GetBranchesAsync( + CancellationToken cancellationToken) + { + ThrowIfDisposed(); + return RunSerializedAsync( + () => GetBranchesCoreAsync(cancellationToken), + cancellationToken); + } + + public Task CreateBranchAsync( + string name, + string startPoint, + CancellationToken cancellationToken) + { + ThrowIfDisposed(); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + GitRevisionValidator.ValidateCommitId(startPoint, nameof(startPoint)); + return RunSerializedAsync( + () => CreateBranchCoreAsync(name, startPoint, cancellationToken), + cancellationToken); + } + + public Task SwitchBranchAsync( + string name, + CancellationToken cancellationToken) + { + ThrowIfDisposed(); + ValidateSwitchBranchName(name); + + return RunSerializedAsync( + () => SwitchBranchCoreAsync(name, cancellationToken), + cancellationToken); + } + + public Task> GetRemotesAsync( + CancellationToken cancellationToken) + { + ThrowIfDisposed(); + return RunSerializedAsync( + () => GetRemotesCoreAsync(cancellationToken), + cancellationToken); + } + + public Task SetRemoteAsync( + string url, + CancellationToken cancellationToken) + { + ThrowIfDisposed(); + ArgumentException.ThrowIfNullOrWhiteSpace(url); + ValidateRemoteUrl(url); + return RunSerializedAsync( + () => SetRemoteCoreAsync(url, cancellationToken), + cancellationToken); + } + + public Task PushAsync( + IProgress? progress, + CancellationToken cancellationToken) + { + ThrowIfDisposed(); + return RunSerializedAsync( + () => PushCoreAsync(progress, cancellationToken), + cancellationToken); + } + + public Task PullFastForwardAsync( + CheckedOutBranchTip expectedCurrent, + ProjectCheckpoint? checkpoint, + string projectFile, + CancellationToken cancellationToken) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(expectedCurrent); + ArgumentException.ThrowIfNullOrWhiteSpace(projectFile); + return RunSerializedAsync( + () => PullFastForwardCoreAsync( + expectedCurrent, + checkpoint, + projectFile, + cancellationToken), + cancellationToken); + } + + public Task GetIdentityAsync(CancellationToken cancellationToken) + { + ThrowIfDisposed(); + return RunSerializedAsync( + async () => + { + RepositoryInfo repository = GetRepository(); + IGitCliRunner runner = await GetInstalledRunnerCoreAsync(cancellationToken).ConfigureAwait(false); + return await GetIdentityCoreAsync(repository, runner, cancellationToken).ConfigureAwait(false); + }, + cancellationToken); + } + + public Task SetLocalIdentityAsync( + GitIdentity identity, + CancellationToken cancellationToken) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(identity); + ArgumentException.ThrowIfNullOrWhiteSpace(identity.Name); + ArgumentException.ThrowIfNullOrWhiteSpace(identity.Email); + return RunSerializedAsync( + async () => + { + RepositoryInfo repository = GetRepository(); + await EnsureNotConflictedCoreAsync(cancellationToken).ConfigureAwait(false); + IGitCliRunner runner = await GetInstalledRunnerCoreAsync(cancellationToken).ConfigureAwait(false); + await SetLocalIdentityCoreAsync( + repository, + runner, + identity, + cancellationToken) + .ConfigureAwait(false); + }, + cancellationToken); + } + + public Task RemoveRecoverableLockAsync(CancellationToken cancellationToken) + { + ThrowIfDisposed(); + return RunSerializedAsync( + async () => + { + RepositoryInfo repository = GetRepository(); + IGitCliRunner runner = await GetInstalledRunnerCoreAsync(cancellationToken) + .ConfigureAwait(false); + RepositoryLockInfo? lockInfo = RecoverableLock; + if (lockInfo is null) + { + return false; + } + + bool removed = runner.RemoveRecoverableRepositoryLock(repository, lockInfo); + if (removed) + { + RecoverableLock = null; + } + + return removed; + }, + cancellationToken); + } + + public void Dispose() + { + Task retirement = RetireAsync(finalSnapshot: null); + if (!retirement.IsCompletedSuccessfully) + { + _ = ObserveRetirementAsync(retirement); + } + } + + Task IProjectVersionControlBackend.ExecuteExclusiveAsync( + Func> operation, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(operation); + ThrowIfDisposed(); + return ExecuteExclusiveCoreAsync(operation, cancellationToken); + } + + public Task RetireAsync(ProjectVersionControlFinalSnapshot? finalSnapshot) + { + lock (_lifetimeSync) + { + if (_retirementTask is not null) + { + return _retirementTask; + } + + if ((ServiceLifetimeState)_lifetimeState == ServiceLifetimeState.Retired) + { + return Task.CompletedTask; + } + + _lifetimeState = (int)ServiceLifetimeState.Retiring; + _retirementTask = RetireCoreAsync(finalSnapshot); + return _retirementTask; + } + } + + private async Task ExecuteExclusiveCoreAsync( + Func> operation, + CancellationToken cancellationToken) + { + await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ThrowIfDisposed(); + return await operation(new Transaction(this)).ConfigureAwait(false); + } + catch (GitOperationException ex) + { + CaptureRecoverableLock(ex); + throw; + } + finally + { + _operationGate.Release(); + } + } + + private async Task RetireCoreAsync(ProjectVersionControlFinalSnapshot? finalSnapshot) + { + await Task.Yield(); + await _operationGate.WaitAsync().ConfigureAwait(false); + try + { + if (finalSnapshot is not null && Repository is not null) + { + await CommitAllCoreAsync( + finalSnapshot.Message, + finalSnapshot.Kind, + CancellationToken.None) + .ConfigureAwait(false); + } + } + catch (GitOperationException ex) + { + CaptureRecoverableLock(ex); + throw; + } + finally + { + DisposeResources(); + Volatile.Write(ref _lifetimeState, (int)ServiceLifetimeState.Retired); + _operationGate.Release(); + } + } + + private void DisposeResources() + { + if (Interlocked.Exchange(ref _resourcesDisposed, 1) != 0) + { + return; + } + + RepositoryWatcher? watcher; + lock (_lifetimeSync) + { + watcher = _watcher; + _watcher = null; + if (watcher is not null) + { + watcher.Changed -= OnRepositoryChanged; + } + } + + watcher?.Dispose(); + _installationLocator.Config.ConfigurationChanged -= OnVersionControlConfigChanged; + } + + private async Task ObserveRetirementAsync(Task retirement) + { + try + { + await retirement.ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to retire the project version-control service."); + } + } + + internal static WorkspaceStatus ParseStatus(string output) + { + string? branch = null; + int ahead = 0; + int behind = 0; + bool hasConflicts = false; + var changes = new List(); + IReadOnlyList records = GitCliRunner.SplitNullSeparated(output); + + for (int index = 0; index < records.Count; index++) + { + string record = records[index]; + if (record.StartsWith("# branch.head ", StringComparison.Ordinal)) + { + string head = record["# branch.head ".Length..]; + branch = head == "(detached)" ? null : head; + } + else if (record.StartsWith("# branch.ab ", StringComparison.Ordinal)) + { + string[] values = record["# branch.ab ".Length..].Split(' ', StringSplitOptions.RemoveEmptyEntries); + foreach (string value in values) + { + if (value.Length < 2) + { + continue; + } + + if (value[0] == '+' + && int.TryParse(value.AsSpan(1), out int parsedAhead)) + { + ahead = parsedAhead; + } + else if (value[0] == '-' + && int.TryParse(value.AsSpan(1), out int parsedBehind)) + { + behind = parsedBehind; + } + } + } + else if (record.StartsWith("1 ", StringComparison.Ordinal)) + { + string statusCode = GetField(record, 1); + string path = GetTailAfterSpaces(record, 8); + changes.Add(new FileChange(path, MapStatus(statusCode))); + hasConflicts |= statusCode.Contains('U'); + } + else if (record.StartsWith("2 ", StringComparison.Ordinal)) + { + string statusCode = GetField(record, 1); + string path = GetTailAfterSpaces(record, 9); + string? oldPath = ++index < records.Count ? records[index] : null; + changes.Add(new FileChange(path, FileChangeStatus.Renamed, oldPath)); + hasConflicts |= statusCode.Contains('U'); + } + else if (record.StartsWith("u ", StringComparison.Ordinal)) + { + string path = GetTailAfterSpaces(record, 10); + changes.Add(new FileChange(path, FileChangeStatus.Modified)); + hasConflicts = true; + } + else if (record.StartsWith("? ", StringComparison.Ordinal)) + { + changes.Add(new FileChange(record[2..], FileChangeStatus.Added)); + } + } + + return new WorkspaceStatus(branch, ahead, behind, changes, hasConflicts); + } + + internal static IReadOnlyList ParseHistory(string output) + { + string[] fields = output.Split('\0'); + var commits = new List(fields.Length / 6); + int index = 0; + while (index + 5 < fields.Length) + { + if (!DateTimeOffset.TryParse( + fields[index + 3], + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.RoundtripKind, + out DateTimeOffset authorDate)) + { + break; + } + + commits.Add(new CommitInfo( + fields[index], + fields[index + 1], + fields[index + 4], + fields[index + 2], + authorDate, + ParseSnapshotKind(fields[index + 5]))); + index += 6; + while (index < fields.Length && fields[index].Length == 0) + { + index++; + } + } + + return commits; + } + + internal static IReadOnlyList ParseCommitFiles(string output) + { + IReadOnlyList fields = GitCliRunner.SplitNullSeparated(output); + var changes = new List(); + for (int index = 0; index < fields.Count;) + { + string status = fields[index++].Trim(); + if (status.Length == 0 || index >= fields.Count) + { + break; + } + + char statusCode = status[0]; + if (statusCode is 'R' or 'C') + { + if (index + 1 >= fields.Count) + { + break; + } + + string oldPath = fields[index++]; + string path = fields[index++]; + changes.Add(new FileChange(path, FileChangeStatus.Renamed, oldPath)); + } + else + { + string path = fields[index++]; + changes.Add(new FileChange(path, MapNameStatus(statusCode))); + } + } + + return changes; + } + + internal static IReadOnlyList ParseBranches(string output) + { + var branches = new List(); + foreach (string record in output + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + string[] fields = record.Split('\0'); + if (fields.Length < 3 || string.IsNullOrWhiteSpace(fields[0])) + { + continue; + } + + string upstream = fields[2].Trim(); + branches.Add(new BranchInfo( + fields[0], + fields[1].Trim() == "*", + string.IsNullOrEmpty(upstream) ? null : upstream)); + } + + return branches; + } + + private static string GetField(string record, int fieldIndex) + { + string[] fields = record.Split(' ', fieldIndex + 2, StringSplitOptions.None); + return fields.Length > fieldIndex ? fields[fieldIndex] : string.Empty; + } + + private static string GetTailAfterSpaces(string record, int spaces) + { + int position = -1; + for (int index = 0; index < spaces; index++) + { + position = record.IndexOf(' ', position + 1); + if (position < 0) + { + return string.Empty; + } + } + + return record[(position + 1)..]; + } + + private static FileChangeStatus MapStatus(string statusCode) + { + if (statusCode.Contains('R') || statusCode.Contains('C')) + { + return FileChangeStatus.Renamed; + } + + if (statusCode.Contains('D')) + { + return FileChangeStatus.Deleted; + } + + if (statusCode.Contains('A') || statusCode == "??") + { + return FileChangeStatus.Added; + } + + return FileChangeStatus.Modified; + } + + private async Task GetCheckedOutBranchTipCoreAsync(CancellationToken cancellationToken) + { + RepositoryInfo repository = GetRepository(); + IGitCliRunner runner = await GetInstalledRunnerCoreAsync(cancellationToken).ConfigureAwait(false); + return await GetCheckedOutBranchTipCoreAsync(repository, runner, cancellationToken).ConfigureAwait(false); + } + + private static async Task GetCheckedOutBranchTipCoreAsync( + RepositoryInfo repository, + IGitCliRunner runner, + CancellationToken cancellationToken) + { + string refName = await GetAttachedBranchRefCoreAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + GitCommandResult commit = await runner.RunAsync( + repository, + ["rev-parse", "--verify", $"{refName}^{{commit}}"], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + return new CheckedOutBranchTip(refName, commit.Stdout.Trim()); + } + + private static async Task GetAttachedBranchRefCoreAsync( + RepositoryInfo repository, + IGitCliRunner runner, + CancellationToken cancellationToken) + { + GitCommandResult symbolicRef; + try + { + symbolicRef = await runner.RunAsync( + repository, + ["symbolic-ref", "--quiet", "HEAD"], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + } + catch (GitOperationException ex) when (ex.ExitCode == 1) + { + throw new DetachedHeadNotSupportedException(); + } + + string refName = symbolicRef.Stdout.Trim(); + if (!refName.StartsWith("refs/heads/", StringComparison.Ordinal)) + { + throw new DetachedHeadNotSupportedException(); + } + + return refName; + } + + private async Task CreateProjectCheckpointCoreAsync( + string message, + CancellationToken cancellationToken) + { + await EnsureNotConflictedCoreAsync(cancellationToken).ConfigureAwait(false); + RepositoryInfo repository = GetRepository(); + ValidateProjectSnapshotLayout(repository.ProjectRoot); + IGitCliRunner runner = await GetInstalledRunnerCoreAsync(cancellationToken).ConfigureAwait(false); + CheckedOutBranchTip baseHead = await GetCheckedOutBranchTipCoreAsync(repository, runner, cancellationToken) + .ConfigureAwait(false); + if (!await IsProjectIndexCleanAsync(repository, runner, cancellationToken) + .ConfigureAwait(false)) + { + throw new ProjectCheckpointStagedChangesException(); + } + + GitIdentity? identity = await GetIdentityCoreAsync(repository, runner, cancellationToken) + .ConfigureAwait(false); + if (identity is null) + { + await RaiseMissingIdentityNoticeIfNeededAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + throw new GitIdentityRequiredException(); + } + + string temporaryIndex = Path.Combine( + Path.GetTempPath(), + $"beutl-git-index-{Guid.NewGuid():N}"); + var indexOptions = new GitCommandOptions( + GitCommandExecutionKind.Local, + new Dictionary + { + ["GIT_INDEX_FILE"] = temporaryIndex, + }); + + try + { + await runner.RunAsync( + repository, + ["read-tree", baseHead.Commit], + indexOptions, + cancellationToken).ConfigureAwait(false); + var addArguments = new List + { + "add", + "-A", + "--", + CreateSnapshotBasePathspec(repository), + }; + addArguments.AddRange(CreateSnapshotExcludePathspecs(repository)); + await runner.RunAsync( + repository, + addArguments, + indexOptions with + { + ExecutionKind = GitCommandExecutionKind.LocalWithLfs, + UseLiteralPathspecs = false, + }, + cancellationToken).ConfigureAwait(false); + GitCommandResult tree = await runner.RunAsync( + repository, + ["write-tree"], + indexOptions, + cancellationToken).ConfigureAwait(false); + + cancellationToken.ThrowIfCancellationRequested(); + GitCommandResult commit = await runner.RunAsync( + repository, + [ + "commit-tree", + tree.Stdout.Trim(), + "-p", + baseHead.Commit, + "-m", + message.Trim(), + "-m", + "Beutl-Snapshot: safety", + ], + indexOptions, + CancellationToken.None).ConfigureAwait(false); + string checkpointCommit = commit.Stdout.Trim(); + string checkpointRef = GetCheckpointRefPrefix(repository) + + Guid.NewGuid().ToString("N"); + var checkpoint = new ProjectCheckpoint(checkpointRef, checkpointCommit, baseHead); + try + { + await runner.RunAsync( + repository, + [ + "update-ref", + "--create-reflog", + "-m", + "beutl safety checkpoint", + checkpointRef, + checkpointCommit, + string.Empty, + ], + GitCommandOptions.Local, + CancellationToken.None).ConfigureAwait(false); + return checkpoint; + } + catch (Exception publicationException) + { + string? observedCommit; + try + { + observedCommit = await TryResolveCommitAsync( + repository, + runner, + checkpointRef, + CancellationToken.None) + .ConfigureAwait(false); + } + catch (Exception observationException) + { + throw new AggregateException( + "The safety checkpoint ref publication failed and its durable result could not be observed.", + publicationException, + observationException); + } + + if (string.Equals( + observedCommit, + checkpointCommit, + StringComparison.OrdinalIgnoreCase)) + { + return checkpoint; + } + + if (observedCommit is null) + { + throw; + } + + throw new ProjectCheckpointChangedException(checkpointRef); + } + } + finally + { + TryDeleteTemporaryIndex(temporaryIndex); + } + } + + private async Task PersistPendingPullRecoveryCoreAsync( + ProjectCheckpoint checkpoint, + CheckedOutBranchTip targetTip, + string projectFile, + CancellationToken cancellationToken) + { + RepositoryInfo repository = GetRepository(); + IGitCliRunner runner = await GetInstalledRunnerCoreAsync(cancellationToken) + .ConfigureAwait(false); + await ValidateCheckpointAsync(repository, runner, checkpoint, cancellationToken) + .ConfigureAwait(false); + ValidateAttachedBranchTip(targetTip, nameof(targetTip)); + if (!string.Equals( + targetTip.RefName, + checkpoint.BaseTip.RefName, + StringComparison.Ordinal)) + { + throw new ArgumentException( + "The recovery target must identify the checkpoint's local branch.", + nameof(targetTip)); + } + + string? resolvedTarget = await TryResolveCommitAsync( + repository, + runner, + targetTip.Commit, + cancellationToken) + .ConfigureAwait(false); + if (!string.Equals( + resolvedTarget, + targetTip.Commit, + StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException( + "The recovery target must resolve to an existing commit.", + nameof(targetTip)); + } + + string relativeProjectFile = GetRecoveryProjectFile(repository, projectFile); + string absoluteProjectFile = GetLexicalRecoveryProjectFile( + repository, + relativeProjectFile); + ValidateRecoveryProjectFilePhysicalContainment(repository, absoluteProjectFile); + string id = Guid.NewGuid().ToString("N"); + string descriptorRef = GetPendingRecoveryRefPrefix(repository) + id; + DateTimeOffset createdAt = DateTimeOffset.UtcNow; + var data = new PendingPullRecoveryData( + PendingPullRecoveryFormatVersion, + id, + checkpoint.RefName, + checkpoint.Commit, + checkpoint.BaseTip.RefName, + checkpoint.BaseTip.Commit, + targetTip.Commit, + relativeProjectFile, + createdAt); + string json = JsonSerializer.Serialize(data, s_recoveryJsonOptions); + GitCommandResult descriptorObjectResult = await runner.RunAsync( + repository, + ["hash-object", "-w", "--stdin"], + new GitCommandOptions( + GitCommandExecutionKind.Local, + StandardInput: json), + cancellationToken).ConfigureAwait(false); + string descriptorObject = descriptorObjectResult.Stdout.Trim(); + GitRevisionValidator.ValidateCommitId(descriptorObject, nameof(descriptorObject)); + + cancellationToken.ThrowIfCancellationRequested(); + try + { + await runner.RunAsync( + repository, + [ + "update-ref", + "--create-reflog", + "-m", + "beutl pending pull recovery", + descriptorRef, + descriptorObject, + string.Empty, + ], + GitCommandOptions.Local, + CancellationToken.None).ConfigureAwait(false); + } + catch (Exception publicationException) + { + string? observedObject; + try + { + observedObject = await TryResolveObjectAsync( + repository, + runner, + descriptorRef, + CancellationToken.None) + .ConfigureAwait(false); + } + catch (Exception observationException) + { + throw new AggregateException( + "The pending pull recovery publication failed and its durable result could not be observed.", + publicationException, + observationException); + } + + if (!string.Equals( + observedObject, + descriptorObject, + StringComparison.OrdinalIgnoreCase)) + { + if (observedObject is null) + { + throw; + } + + throw new PendingPullRecoveryChangedException(descriptorRef); + } + } + + return new PendingPullRecovery( + id, + descriptorRef, + descriptorObject, + checkpoint, + targetTip, + absoluteProjectFile, + createdAt); + } + + private async Task> GetPendingPullRecoveriesCoreAsync( + CancellationToken cancellationToken) + { + RepositoryInfo repository = GetRepository(); + IGitCliRunner runner = await GetInstalledRunnerCoreAsync(cancellationToken) + .ConfigureAwait(false); + GitCommandResult refs = await runner.RunAsync( + repository, + [ + "for-each-ref", + "--sort=refname", + "--format=%(refname)%00%(objectname)", + GetPendingRecoveryRefPrefix(repository), + ], + new GitCommandOptions( + GitCommandExecutionKind.Local, + MaxStdoutBytes: MaxPendingRecoveryListBytes), + cancellationToken).ConfigureAwait(false); + if (refs.StdoutTruncated) + { + throw new InvalidOperationException( + "The pending pull recovery list exceeded the safe output limit."); + } + + var result = new List(); + foreach (string rawLine in refs.Stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + cancellationToken.ThrowIfCancellationRequested(); + string line = rawLine.TrimEnd('\r'); + string[] fields = line.Split('\0'); + if (fields.Length != 2) + { + _logger.LogWarning( + "Ignored a malformed pending pull recovery ref record in {RepositoryRoot}.", + repository.RepoRoot); + continue; + } + + try + { + PendingPullRecovery? recovery = await ReadPendingPullRecoveryAsync( + repository, + runner, + fields[0], + fields[1], + cancellationToken) + .ConfigureAwait(false); + if (recovery is not null) + { + result.Add(recovery); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Ignored invalid pending pull recovery descriptor {RecoveryRef}.", + fields[0]); + } + } + + return result + .OrderBy(static recovery => recovery.CreatedAt) + .ThenBy(static recovery => recovery.Id, StringComparer.Ordinal) + .ToArray(); + } + + private async Task ReadPendingPullRecoveryAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string descriptorRef, + string descriptorObject, + CancellationToken cancellationToken) + { + string prefix = GetPendingRecoveryRefPrefix(repository); + if (!descriptorRef.StartsWith(prefix, StringComparison.Ordinal)) + { + return null; + } + + string id = descriptorRef[prefix.Length..]; + if (!Guid.TryParseExact(id, "N", out _) + || id.Contains('/', StringComparison.Ordinal)) + { + return null; + } + + GitRevisionValidator.ValidateCommitId(descriptorObject, nameof(descriptorObject)); + GitCommandResult descriptor = await runner.RunAsync( + repository, + ["cat-file", "blob", descriptorObject], + new GitCommandOptions( + GitCommandExecutionKind.Local, + MaxStdoutBytes: MaxPendingRecoveryDescriptorBytes), + cancellationToken).ConfigureAwait(false); + if (descriptor.StdoutTruncated) + { + return null; + } + + PendingPullRecoveryData? data = JsonSerializer.Deserialize( + descriptor.Stdout, + s_recoveryJsonOptions); + if (data is null + || data.Version != PendingPullRecoveryFormatVersion + || !string.Equals(data.Id, id, StringComparison.Ordinal)) + { + return null; + } + + var checkpoint = new ProjectCheckpoint( + data.CheckpointRef, + data.CheckpointCommit, + new CheckedOutBranchTip(data.BranchRef, data.BaseCommit)); + var targetTip = new CheckedOutBranchTip(data.BranchRef, data.TargetCommit); + ValidateCheckpointRef(repository, checkpoint); + ValidateAttachedBranchTip(targetTip, nameof(data.TargetCommit)); + if (!string.Equals( + checkpoint.BaseTip.RefName, + targetTip.RefName, + StringComparison.Ordinal) + || data.CreatedAt == default) + { + return null; + } + + string projectFile = GetLexicalRecoveryProjectFile(repository, data.ProjectFile); + await ValidateCheckpointAsync(repository, runner, checkpoint, cancellationToken) + .ConfigureAwait(false); + + return new PendingPullRecovery( + id, + descriptorRef, + descriptorObject, + checkpoint, + targetTip, + projectFile, + data.CreatedAt); + } + + private async Task RecoverPendingPullRecoveryCoreAsync( + PendingPullRecovery recovery, + CancellationToken cancellationToken) + { + EnsureWorktreeMutationAllowed(); + RepositoryInfo repository = GetRepository(); + IGitCliRunner runner = await GetInstalledRunnerCoreAsync(cancellationToken) + .ConfigureAwait(false); + await ValidatePendingPullRecoveryAsync( + repository, + runner, + recovery, + cancellationToken) + .ConfigureAwait(false); + CheckedOutBranchTip actualTip = await GetCheckedOutBranchTipCoreAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + if (EqualsBranchTip(actualTip, recovery.TargetTip)) + { + WorktreeStateFingerprint actualState = await CaptureWorktreeStateAsync( + repository, + runner, + recovery.TargetTip.Commit, + ".", + cancellationToken) + .ConfigureAwait(false); + string targetTree = await ResolveTreeAsync( + repository, + runner, + recovery.TargetTip.Commit, + cancellationToken) + .ConfigureAwait(false); + if (!string.Equals( + actualState.Tree, + targetTree, + StringComparison.OrdinalIgnoreCase) + || !await IsWholeRepositoryCleanAsync(repository, runner, cancellationToken) + .ConfigureAwait(false)) + { + throw await CreatePreservedRecoveryExceptionAsync( + repository, + runner, + recovery, + new InvalidOperationException( + "The pulled branch tip is present, but its worktree state cannot be verified.")) + .ConfigureAwait(false); + } + + TreeTransitionResult rollback; + try + { + rollback = await ApplyTreeTransitionAsync( + repository, + runner, + recovery.TargetTip, + recovery.Checkpoint.BaseTip, + recovery.TargetTip.Commit, + recovery.Checkpoint.BaseTip.Commit, + "beutl roll back pending pull target", + indexPlan: null, + CancellationToken.None) + .ConfigureAwait(false); + } + catch (Exception ex) + { + throw await CreatePreservedRecoveryExceptionAsync( + repository, + runner, + recovery, + ex) + .ConfigureAwait(false); + } + + if (rollback.Outcome != TreeTransitionOutcome.AppliedTarget) + { + throw await CreatePreservedRecoveryExceptionAsync( + repository, + runner, + recovery, + rollback.Error + ?? new InvalidOperationException( + "The pulled branch could not be rolled back safely.")) + .ConfigureAwait(false); + } + + try + { + await RestoreProjectCheckpointCoreAsync( + recovery.Checkpoint, + CancellationToken.None, + validatePreparedTarget: () => + ValidateRecoveryProjectFilePhysicalContainment( + repository, + recovery.ProjectFile)) + .ConfigureAwait(false); + ValidateRecoveryProjectFilePhysicalContainment( + repository, + recovery.ProjectFile); + return PendingPullRecoveryOutcome.RestoredOriginal; + } + catch (Exception ex) + { + throw await CreatePreservedRecoveryExceptionAsync( + repository, + runner, + recovery, + ex) + .ConfigureAwait(false); + } + } + + if (!EqualsBranchTip(actualTip, recovery.Checkpoint.BaseTip)) + { + string recoveryBranchName; + try + { + recoveryBranchName = await PreserveCheckpointOnRecoveryBranchAsync( + repository, + runner, + recovery, + CancellationToken.None) + .ConfigureAwait(false); + } + catch (Exception ex) + { + throw await CreateCheckpointPreservationExceptionAsync( + repository, + runner, + recovery, + ex) + .ConfigureAwait(false); + } + + try + { + if (!await TryReapplyCheckpointToExternallyOwnedTipAsync( + repository, + runner, + recovery, + actualTip, + CancellationToken.None) + .ConfigureAwait(false)) + { + throw new PendingPullRecoveryPreservedException(recoveryBranchName); + } + + ValidateRecoveryProjectFilePhysicalContainment( + repository, + recovery.ProjectFile); + return PendingPullRecoveryOutcome.ReappliedCheckpoint; + } + catch (PendingPullRecoveryPreservedException) + { + throw; + } + catch (Exception ex) + { + throw new PendingPullRecoveryPreservedException(recoveryBranchName, ex); + } + } + + try + { + await RestoreProjectCheckpointCoreAsync( + recovery.Checkpoint, + CancellationToken.None, + validatePreparedTarget: () => + ValidateRecoveryProjectFilePhysicalContainment( + repository, + recovery.ProjectFile)) + .ConfigureAwait(false); + CheckedOutBranchTip recoveredTip = await GetCheckedOutBranchTipCoreAsync( + repository, + runner, + CancellationToken.None) + .ConfigureAwait(false); + if (!EqualsBranchTip(recoveredTip, recovery.Checkpoint.BaseTip)) + { + throw new InvalidOperationException( + "The repository branch changed while the pending pull recovery was restored."); + } + + ValidateRecoveryProjectFilePhysicalContainment( + repository, + recovery.ProjectFile); + return PendingPullRecoveryOutcome.RestoredOriginal; + } + catch (Exception ex) + { + throw await CreatePreservedRecoveryExceptionAsync( + repository, + runner, + recovery, + ex) + .ConfigureAwait(false); + } + } + + private static async Task + CreatePreservedRecoveryExceptionAsync( + RepositoryInfo repository, + IGitCliRunner runner, + PendingPullRecovery recovery, + Exception failure) + { + try + { + string recoveryBranchName = await PreserveCheckpointOnRecoveryBranchAsync( + repository, + runner, + recovery, + CancellationToken.None) + .ConfigureAwait(false); + return new PendingPullRecoveryPreservedException( + recoveryBranchName, + failure); + } + catch (Exception preservationFailure) + { + return await CreateCheckpointPreservationExceptionAsync( + repository, + runner, + recovery, + new AggregateException( + "The pending pull recovery failed and its durable recovery branch could not be published.", + failure, + preservationFailure)) + .ConfigureAwait(false); + } + } + + private static async Task CreateCheckpointPreservationExceptionAsync( + RepositoryInfo repository, + IGitCliRunner runner, + PendingPullRecovery recovery, + Exception failure) + { + try + { + string? checkpointCommit = await TryResolveCommitAsync( + repository, + runner, + recovery.Checkpoint.RefName, + CancellationToken.None) + .ConfigureAwait(false); + if (string.Equals( + checkpointCommit, + recovery.Checkpoint.Commit, + StringComparison.OrdinalIgnoreCase)) + { + return new PendingPullRecoveryPreservedException( + recovery.Checkpoint.RefName, + failure); + } + + return new AggregateException( + "The pending pull recovery failed and its checkpoint reference no longer identifies the expected commit.", + failure); + } + catch (Exception verificationFailure) + { + return new AggregateException( + "The pending pull recovery failed and its checkpoint reference could not be verified.", + failure, + verificationFailure); + } + } + + private static async Task PreserveCheckpointOnRecoveryBranchAsync( + RepositoryInfo repository, + IGitCliRunner runner, + PendingPullRecovery recovery, + CancellationToken cancellationToken) + { + string branchName = recovery.RecoveryBranchName; + string branchRef = $"refs/heads/{branchName}"; + string? existing = await TryResolveCommitAsync( + repository, + runner, + branchRef, + cancellationToken) + .ConfigureAwait(false); + if (string.Equals( + existing, + recovery.Checkpoint.Commit, + StringComparison.OrdinalIgnoreCase)) + { + return branchName; + } + + if (existing is not null) + { + throw new InvalidOperationException( + $"The recovery branch '{branchName}' already identifies another commit."); + } + + try + { + await runner.RunAsync( + repository, + [ + "update-ref", + "--create-reflog", + "-m", + "beutl preserve pending pull checkpoint", + branchRef, + recovery.Checkpoint.Commit, + string.Empty, + ], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + } + catch (Exception publicationException) + { + existing = await TryResolveCommitAsync( + repository, + runner, + branchRef, + CancellationToken.None) + .ConfigureAwait(false); + if (!string.Equals( + existing, + recovery.Checkpoint.Commit, + StringComparison.OrdinalIgnoreCase)) + { + throw new AggregateException( + $"The recovery branch '{branchName}' could not be published safely.", + publicationException); + } + } + + return branchName; + } + + private async Task TryReapplyCheckpointToExternallyOwnedTipAsync( + RepositoryInfo repository, + IGitCliRunner runner, + PendingPullRecovery recovery, + CheckedOutBranchTip actualTip, + CancellationToken cancellationToken) + { + if (!string.Equals( + actualTip.RefName, + recovery.Checkpoint.BaseTip.RefName, + StringComparison.Ordinal)) + { + return false; + } + + // Validate before any temporary commit or checkout can replace a symlinked project path. + ValidateRecoveryProjectFilePhysicalContainment( + repository, + recovery.ProjectFile); + + string desiredTree = await BuildProjectTreeAsync( + repository, + runner, + actualTip.Commit, + recovery.Checkpoint.Commit, + cancellationToken) + .ConfigureAwait(false); + WorktreeStateFingerprint actualState = await CaptureWorktreeStateAsync( + repository, + runner, + actualTip.Commit, + repository.Pathspec, + cancellationToken) + .ConfigureAwait(false); + string actualTree = await ResolveTreeAsync( + repository, + runner, + actualTip.Commit, + cancellationToken) + .ConfigureAwait(false); + bool indexAtActual = await IsIndexAtCommitAsync( + repository, + runner, + actualTip.Commit, + repository.Pathspec, + cancellationToken) + .ConfigureAwait(false); + + if (string.Equals(actualState.Tree, desiredTree, StringComparison.OrdinalIgnoreCase)) + { + bool indexAtBase = indexAtActual || await IsIndexAtCommitAsync( + repository, + runner, + recovery.Checkpoint.BaseTip.Commit, + repository.Pathspec, + cancellationToken) + .ConfigureAwait(false); + if (!indexAtBase) + { + return false; + } + + CheckedOutBranchTip beforeReset = await GetCheckedOutBranchTipCoreAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + if (!EqualsBranchTip(beforeReset, actualTip)) + { + return false; + } + + string originalIndexCommit = indexAtActual + ? actualTip.Commit + : recovery.Checkpoint.BaseTip.Commit; + try + { + if (!indexAtActual) + { + await ResetIndexAsync( + repository, + runner, + actualTip.Commit, + repository.Pathspec) + .ConfigureAwait(false); + } + + CheckedOutBranchTip verifiedTip = await GetCheckedOutBranchTipCoreAsync( + repository, + runner, + CancellationToken.None) + .ConfigureAwait(false); + WorktreeStateFingerprint verifiedState = await CaptureWorktreeStateAsync( + repository, + runner, + actualTip.Commit, + repository.Pathspec, + CancellationToken.None) + .ConfigureAwait(false); + if (!EqualsBranchTip(verifiedTip, actualTip) + || !string.Equals( + verifiedState.Tree, + desiredTree, + StringComparison.OrdinalIgnoreCase) + || !await IsIndexAtCommitAsync( + repository, + runner, + actualTip.Commit, + repository.Pathspec, + CancellationToken.None) + .ConfigureAwait(false)) + { + throw new ProjectCheckpointStateChangedException(); + } + + return true; + } + catch (Exception ex) + { + Exception failure = ex; + if (!indexAtActual) + { + try + { + await ResetIndexAsync( + repository, + runner, + originalIndexCommit, + repository.Pathspec) + .ConfigureAwait(false); + } + catch (Exception restoreException) + { + failure = new AggregateException( + "The checkpoint index reapply failed and the prior index could not be restored.", + ex, + restoreException); + } + } + + throw failure; + } + } + + if (!string.Equals(actualState.Tree, actualTree, StringComparison.OrdinalIgnoreCase) + || !indexAtActual) + { + return false; + } + + string targetCommit = await CreateTreeCommitAsync( + repository, + runner, + desiredTree, + actualTip.Commit, + "beutl temporary pending pull recovery", + cancellationToken) + .ConfigureAwait(false); + + TreeTransitionResult transition = await ApplyTreeTransitionAsync( + repository, + runner, + actualTip, + actualTip, + actualTip.Commit, + targetCommit, + "beutl reapply pending pull checkpoint", + new TreeTransitionIndexPlan( + FinalCommit: actualTip.Commit, + RestoreCommit: actualTip.Commit, + Pathspec: repository.Pathspec), + cancellationToken, + validatePreparedTarget: () => + ValidateRecoveryProjectFilePhysicalContainment( + repository, + recovery.ProjectFile)) + .ConfigureAwait(false); + return transition.Outcome switch + { + TreeTransitionOutcome.AppliedTarget => true, + TreeTransitionOutcome.OwnershipLost => false, + _ => throw new InvalidOperationException( + "The pending pull checkpoint could not be reapplied safely.", + transition.Error), + }; + } + + private static async Task CreateTreeCommitAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string tree, + string parentCommit, + string message, + CancellationToken cancellationToken) + { + GitCommandResult result = await runner.RunAsync( + repository, + ["commit-tree", tree, "-p", parentCommit, "-m", message], + new GitCommandOptions( + GitCommandExecutionKind.Local, + EnvironmentOverrides: new Dictionary + { + ["GIT_AUTHOR_NAME"] = "Beutl Recovery", + ["GIT_AUTHOR_EMAIL"] = "beutl-recovery@localhost", + ["GIT_COMMITTER_NAME"] = "Beutl Recovery", + ["GIT_COMMITTER_EMAIL"] = "beutl-recovery@localhost", + }), + cancellationToken) + .ConfigureAwait(false); + string commit = result.Stdout.Trim(); + if (commit.Length == 0) + { + throw new InvalidOperationException("Git did not return the temporary recovery commit."); + } + + await runner.RunAsync( + repository, + ["cat-file", "-e", commit + "^{commit}"], + GitCommandOptions.Local, + cancellationToken) + .ConfigureAwait(false); + + return commit; + } + + private async Task CompletePendingPullRecoveryCoreAsync( + PendingPullRecovery recovery, + CancellationToken cancellationToken) + { + RepositoryInfo repository = GetRepository(); + IGitCliRunner runner = await GetInstalledRunnerCoreAsync(cancellationToken) + .ConfigureAwait(false); + await ValidatePendingPullRecoveryAsync( + repository, + runner, + recovery, + cancellationToken) + .ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + string commands = string.Join( + '\n', + "start", + $"delete {recovery.DescriptorRef} {recovery.DescriptorObject}", + $"delete {recovery.Checkpoint.RefName} {recovery.Checkpoint.Commit}", + "prepare", + "commit", + string.Empty); + try + { + await runner.RunAsync( + repository, + ["update-ref", "--stdin"], + new GitCommandOptions( + GitCommandExecutionKind.Local, + StandardInput: commands), + CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + string? remainingDescriptor = await TryResolveObjectAsync( + repository, + runner, + recovery.DescriptorRef, + CancellationToken.None) + .ConfigureAwait(false); + string? remainingCheckpoint = await TryResolveCommitAsync( + repository, + runner, + recovery.Checkpoint.RefName, + CancellationToken.None) + .ConfigureAwait(false); + if (remainingDescriptor is null && remainingCheckpoint is null) + { + return; + } + + throw new PendingPullRecoveryChangedException(recovery.DescriptorRef, ex); + } + } + + private static async Task ValidatePendingPullRecoveryAsync( + RepositoryInfo repository, + IGitCliRunner runner, + PendingPullRecovery recovery, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(recovery); + string expectedRef = GetPendingRecoveryRefPrefix(repository) + recovery.Id; + if (!Guid.TryParseExact(recovery.Id, "N", out _) + || !string.Equals(recovery.DescriptorRef, expectedRef, StringComparison.Ordinal)) + { + throw new ArgumentException( + "The pending pull recovery does not belong to this project.", + nameof(recovery)); + } + + GitRevisionValidator.ValidateCommitId( + recovery.DescriptorObject, + nameof(recovery)); + ValidateCheckpointRef(repository, recovery.Checkpoint); + ValidateAttachedBranchTip(recovery.TargetTip, nameof(recovery)); + if (!string.Equals( + recovery.Checkpoint.BaseTip.RefName, + recovery.TargetTip.RefName, + StringComparison.Ordinal) + || recovery.CreatedAt == default) + { + throw new ArgumentException( + "The pending pull recovery descriptor is inconsistent.", + nameof(recovery)); + } + + _ = ValidateStoredRecoveryProjectFile(repository, recovery.ProjectFile); + string? currentObject = await TryResolveObjectAsync( + repository, + runner, + recovery.DescriptorRef, + cancellationToken) + .ConfigureAwait(false); + if (!string.Equals( + currentObject, + recovery.DescriptorObject, + StringComparison.OrdinalIgnoreCase)) + { + throw new PendingPullRecoveryChangedException(recovery.DescriptorRef); + } + + await ValidateCheckpointAsync(repository, runner, recovery.Checkpoint, cancellationToken) + .ConfigureAwait(false); + } + + private static string GetRecoveryProjectFile( + RepositoryInfo repository, + string projectFile) + { + string projectRoot = RepositoryPathComparer.ResolveCanonicalPath(repository.ProjectRoot); + string fullPath = RepositoryPathComparer.ResolveCanonicalPath(projectFile); + string canonicalRelativePath = Path.GetRelativePath(projectRoot, fullPath); + ValidateRecoveryProjectFileContainment(canonicalRelativePath); + + return GetRecoveryProjectFileLexically( + repository, + projectFile, + canonicalRelativePath); + } + + private static string GetRecoveryProjectFileLexically( + RepositoryInfo repository, + string projectFile, + string? canonicalRelativePath = null) + { + + string lexicalProjectFile = Path.GetFullPath(projectFile); + string? lexicalRoot = Path.GetDirectoryName(lexicalProjectFile); + while (lexicalRoot is not null) + { + if (RepositoryPathComparer.AreEquivalent(lexicalRoot, repository.ProjectRoot)) + { + string lexicalRelativePath = Path.GetRelativePath( + lexicalRoot, + lexicalProjectFile); + ValidateRecoveryProjectFile(lexicalRelativePath); + return NormalizeGitPath(lexicalRelativePath); + } + + lexicalRoot = Path.GetDirectoryName(lexicalRoot); + } + + canonicalRelativePath ??= Path.GetRelativePath( + Path.GetFullPath(repository.ProjectRoot), + lexicalProjectFile); + ValidateRecoveryProjectFile(canonicalRelativePath); + return NormalizeGitPath(canonicalRelativePath); + } + + private static string GetLexicalRecoveryProjectFile( + RepositoryInfo repository, + string relativeProjectFile) + { + ValidateRecoveryProjectFile(relativeProjectFile); + return Path.GetFullPath(Path.Combine( + repository.ProjectRoot, + relativeProjectFile.Replace('/', Path.DirectorySeparatorChar))); + } + + private static string ValidateStoredRecoveryProjectFile( + RepositoryInfo repository, + string projectFile) + { + string relativeProjectFile = Path.GetRelativePath( + Path.GetFullPath(repository.ProjectRoot), + Path.GetFullPath(projectFile)); + ValidateRecoveryProjectFile(relativeProjectFile); + return NormalizeGitPath(relativeProjectFile); + } + + private static void ValidateRecoveryProjectFilePhysicalContainment( + RepositoryInfo repository, + string projectFile) + { + if (!RepositoryPathComparer.IsContainedWithin(repository.ProjectRoot, projectFile)) + { + throw new ArgumentException( + $"The pending pull recovery project file '{projectFile}' must remain inside the project root.", + nameof(projectFile)); + } + } + + private static void ValidateRecoveryProjectFile(string relativePath) + { + ValidateRecoveryProjectFileContainment(relativePath); + if (!string.Equals( + Path.GetExtension(relativePath), + ".bep", + StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException( + $"The pending pull recovery project file '{relativePath}' must use the .bep extension.", + nameof(relativePath)); + } + } + + private static void ValidateRecoveryProjectFileContainment(string relativePath) + { + if (string.IsNullOrWhiteSpace(relativePath) + || relativePath == ".." + || relativePath.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal) + || relativePath.StartsWith("../", StringComparison.Ordinal) + || Path.IsPathRooted(relativePath) + || relativePath + .Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries) + .Any(static component => component is "." or "..")) + { + throw new ArgumentException( + $"The pending pull recovery project file '{relativePath}' must remain inside the project root.", + nameof(relativePath)); + } + } + + private async Task RestoreProjectCheckpointCoreAsync( + ProjectCheckpoint checkpoint, + CancellationToken cancellationToken, + Action? validatePreparedTarget = null) + { + EnsureWorktreeMutationAllowed(); + RepositoryInfo repository = GetRepository(); + IGitCliRunner runner = await GetInstalledRunnerCoreAsync(cancellationToken).ConfigureAwait(false); + await ValidateCheckpointAsync(repository, runner, checkpoint, cancellationToken) + .ConfigureAwait(false); + CheckedOutBranchTip currentHead = await GetCheckedOutBranchTipCoreAsync(repository, runner, cancellationToken) + .ConfigureAwait(false); + if (!EqualsBranchTip(currentHead, checkpoint.BaseTip)) + { + throw new InvalidOperationException( + "The project checkpoint can only be restored directly at its original head."); + } + + WorktreeStateFingerprint currentState = await CaptureWorktreeStateAsync( + repository, + runner, + checkpoint.BaseTip.Commit, + repository.Pathspec, + cancellationToken) + .ConfigureAwait(false); + string checkpointTree = await ResolveTreeAsync( + repository, + runner, + checkpoint.Commit, + cancellationToken) + .ConfigureAwait(false); + if (string.Equals(currentState.Tree, checkpointTree, StringComparison.OrdinalIgnoreCase) + && await IsProjectIndexCleanAsync(repository, runner, cancellationToken) + .ConfigureAwait(false)) + { + validatePreparedTarget?.Invoke(); + return; + } + + if (!await IsProjectCleanAsync(repository, runner, cancellationToken).ConfigureAwait(false)) + { + throw new InvalidOperationException( + "The project must be clean before restoring a project checkpoint."); + } + + string baseTree = await ResolveTreeAsync( + repository, + runner, + checkpoint.BaseTip.Commit, + cancellationToken) + .ConfigureAwait(false); + if (!string.Equals(currentState.Tree, baseTree, StringComparison.OrdinalIgnoreCase)) + { + throw new ProjectCheckpointStateChangedException(); + } + + cancellationToken.ThrowIfCancellationRequested(); + CheckedOutBranchTip ownershipTip = await GetCheckedOutBranchTipCoreAsync( + repository, + runner, + CancellationToken.None) + .ConfigureAwait(false); + WorktreeStateFingerprint ownershipState = await CaptureWorktreeStateAsync( + repository, + runner, + checkpoint.BaseTip.Commit, + repository.Pathspec, + CancellationToken.None) + .ConfigureAwait(false); + if (!EqualsBranchTip(ownershipTip, checkpoint.BaseTip) + || ownershipState != currentState) + { + throw new InvalidOperationException( + "The project changed before its checkpoint could be restored."); + } + + TreeTransitionResult transitionResult = await ApplyTreeTransitionAsync( + repository, + runner, + checkpoint.BaseTip, + checkpoint.BaseTip, + checkpoint.BaseTip.Commit, + checkpoint.Commit, + "beutl restore project checkpoint", + new TreeTransitionIndexPlan( + FinalCommit: checkpoint.BaseTip.Commit, + RestoreCommit: checkpoint.BaseTip.Commit, + Pathspec: repository.Pathspec), + CancellationToken.None, + validatePreparedTarget).ConfigureAwait(false); + EnsureTreeTransitionApplied( + transitionResult, + "The project checkpoint could not be restored safely."); + await TryQueueStatusChangedCoreAsync().ConfigureAwait(false); + } + + private async Task CommitProjectTreeCoreAsync( + CheckedOutBranchTip expectedCurrent, + string sourceCommit, + string message, + SnapshotKind kind, + CancellationToken cancellationToken) + { + GitRevisionValidator.ValidateCommitId(sourceCommit, nameof(sourceCommit)); + await EnsureNotConflictedCoreAsync(cancellationToken).ConfigureAwait(false); + EnsureWorktreeMutationAllowed(); + ValidateAttachedBranchTip(expectedCurrent, nameof(expectedCurrent)); + RepositoryInfo repository = GetRepository(); + IGitCliRunner runner = await GetInstalledRunnerCoreAsync(cancellationToken).ConfigureAwait(false); + await EnsureNoExternalRepositoryOperationAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + CheckedOutBranchTip currentTip = await GetCheckedOutBranchTipCoreAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + if (!EqualsBranchTip(currentTip, expectedCurrent)) + { + throw new InvalidOperationException( + "The checked-out branch changed before the project tree transition started."); + } + + string? resolvedSource = await TryResolveCommitAsync( + repository, + runner, + sourceCommit, + cancellationToken) + .ConfigureAwait(false); + if (resolvedSource is null) + { + throw new ArgumentException( + "The project tree source must resolve to a commit.", + nameof(sourceCommit)); + } + + if (!await IsProjectCleanAsync(repository, runner, cancellationToken).ConfigureAwait(false)) + { + throw new InvalidOperationException( + "The project must be clean before committing a project tree transition."); + } + + GitIdentity? identity = await GetIdentityCoreAsync(repository, runner, cancellationToken) + .ConfigureAwait(false); + if (identity is null) + { + if (kind != SnapshotKind.Manual) + { + await RaiseMissingIdentityNoticeIfNeededAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + return new CommitResult.SkippedNoIdentity(); + } + + throw new GitIdentityRequiredException(); + } + + WorktreeStateFingerprint expectedState = await CaptureWorktreeStateAsync( + repository, + runner, + expectedCurrent.Commit, + repository.Pathspec, + cancellationToken) + .ConfigureAwait(false); + string expectedTree = await ResolveTreeAsync( + repository, + runner, + expectedCurrent.Commit, + cancellationToken) + .ConfigureAwait(false); + if (!string.Equals(expectedState.Tree, expectedTree, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "The project index or worktree changed before the project tree transition started."); + } + + string desiredTree = await BuildProjectTreeAsync( + repository, + runner, + expectedCurrent.Commit, + resolvedSource, + cancellationToken) + .ConfigureAwait(false); + if (string.Equals(desiredTree, expectedTree, StringComparison.OrdinalIgnoreCase)) + { + return new CommitResult.NoChanges(); + } + + await EnsureNoExternalRepositoryOperationAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + GitCommandResult commit = await runner.RunAsync( + repository, + [ + "commit-tree", + desiredTree, + "-p", + expectedCurrent.Commit, + "-m", + message.Trim(), + "-m", + $"Beutl-Snapshot: {kind.ToString().ToLowerInvariant()}", + ], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + var committedTip = new CheckedOutBranchTip( + expectedCurrent.RefName, + commit.Stdout.Trim()); + + cancellationToken.ThrowIfCancellationRequested(); + CheckedOutBranchTip ownershipTip = await GetCheckedOutBranchTipCoreAsync( + repository, + runner, + CancellationToken.None) + .ConfigureAwait(false); + WorktreeStateFingerprint ownershipState = await CaptureWorktreeStateAsync( + repository, + runner, + expectedCurrent.Commit, + repository.Pathspec, + CancellationToken.None) + .ConfigureAwait(false); + if (!EqualsBranchTip(ownershipTip, expectedCurrent) + || ownershipState != expectedState + || !await IsProjectCleanAsync(repository, runner, CancellationToken.None) + .ConfigureAwait(false)) + { + throw new ProjectCheckpointStateChangedException(); + } + + await EnsureNoExternalRepositoryOperationAsync( + repository, + runner, + CancellationToken.None) + .ConfigureAwait(false); + TreeTransitionResult applyResult = await ApplyTreeTransitionAsync( + repository, + runner, + expectedCurrent, + committedTip, + expectedCurrent.Commit, + committedTip.Commit, + $"commit: {message.Trim()}", + new TreeTransitionIndexPlan(Pathspec: repository.Pathspec), + CancellationToken.None).ConfigureAwait(false); + EnsureTreeTransitionApplied( + applyResult, + "The project tree transition could not be applied safely."); + await TryQueueStatusChangedCoreAsync().ConfigureAwait(false); + return new CommitResult.Committed(new CommitRevision.Known(committedTip.Commit)); + } + + private async Task TryRollbackBranchTipCoreAsync( + CheckedOutBranchTip expectedCurrent, + CheckedOutBranchTip target, + CancellationToken cancellationToken) + { + EnsureWorktreeMutationAllowed(); + ValidateBranchTipForRollback(expectedCurrent, target); + RepositoryInfo repository = GetRepository(); + IGitCliRunner runner = await GetInstalledRunnerCoreAsync(cancellationToken).ConfigureAwait(false); + CheckedOutBranchTip? actualHead = await TryGetCheckedOutBranchTipAsync(repository, runner, cancellationToken) + .ConfigureAwait(false); + if (actualHead is null + || !string.Equals(actualHead.RefName, expectedCurrent.RefName, StringComparison.Ordinal) + || !string.Equals(actualHead.Commit, expectedCurrent.Commit, StringComparison.OrdinalIgnoreCase)) + { + return new BranchTipRollbackResult.RefChanged(actualHead?.Commit); + } + + if (!await IsAncestorAsync( + repository, + runner, + target.Commit, + expectedCurrent.Commit, + cancellationToken).ConfigureAwait(false)) + { + throw new ArgumentException( + "The rollback target must be an ancestor of the expected current head.", + nameof(target)); + } + + if (!await IsWholeRepositoryCleanAsync(repository, runner, cancellationToken) + .ConfigureAwait(false)) + { + return new BranchTipRollbackResult.UnsafeRepositoryState(); + } + + WorktreeStateFingerprint expectedWorktree = await CaptureWorktreeStateAsync( + repository, + runner, + expectedCurrent.Commit, + ".", + cancellationToken) + .ConfigureAwait(false); + string expectedTree = await ResolveTreeAsync( + repository, + runner, + expectedCurrent.Commit, + cancellationToken) + .ConfigureAwait(false); + if (!string.Equals(expectedWorktree.Tree, expectedTree, StringComparison.OrdinalIgnoreCase) + || !await IsWholeRepositoryCleanAsync(repository, runner, cancellationToken) + .ConfigureAwait(false)) + { + return new BranchTipRollbackResult.UnsafeRepositoryState(); + } + + cancellationToken.ThrowIfCancellationRequested(); + TreeTransitionResult rollbackResult = await ApplyTreeTransitionAsync( + repository, + runner, + expectedCurrent, + target, + expectedCurrent.Commit, + target.Commit, + "beutl rollback fast-forward pull", + indexPlan: null, + CancellationToken.None).ConfigureAwait(false); + if (rollbackResult.Outcome == TreeTransitionOutcome.OwnershipLost) + { + if (rollbackResult.Error is VersionControlConflictedException) + { + return new BranchTipRollbackResult.UnsafeRepositoryState(); + } + + return new BranchTipRollbackResult.RefChanged( + rollbackResult.ActualTip?.Commit); + } + + if (rollbackResult.Outcome != TreeTransitionOutcome.AppliedTarget) + { + if (rollbackResult.Outcome == TreeTransitionOutcome.RecoveryFailed + && rollbackResult.Error is not null) + { + throw rollbackResult.Error; + } + + return new BranchTipRollbackResult.UnsafeRepositoryState(); + } + + await TryQueueStatusChangedCoreAsync().ConfigureAwait(false); + return new BranchTipRollbackResult.RolledBack(); + } + + private async Task DeleteProjectCheckpointCoreAsync( + ProjectCheckpoint checkpoint, + CancellationToken cancellationToken) + { + RepositoryInfo repository = GetRepository(); + IGitCliRunner runner = await GetInstalledRunnerCoreAsync(cancellationToken).ConfigureAwait(false); + ValidateCheckpointRef(repository, checkpoint); + string? currentCommit = await TryResolveCommitAsync( + repository, + runner, + checkpoint.RefName, + cancellationToken) + .ConfigureAwait(false); + if (currentCommit is null) + { + return false; + } + + if (!string.Equals(currentCommit, checkpoint.Commit, StringComparison.OrdinalIgnoreCase)) + { + throw new ProjectCheckpointChangedException(checkpoint.RefName); + } + + cancellationToken.ThrowIfCancellationRequested(); + await runner.RunAsync( + repository, + ["update-ref", "-d", checkpoint.RefName, checkpoint.Commit], + GitCommandOptions.Local, + CancellationToken.None).ConfigureAwait(false); + return true; + } + + private static async Task ValidateCheckpointAsync( + RepositoryInfo repository, + IGitCliRunner runner, + ProjectCheckpoint checkpoint, + CancellationToken cancellationToken) + { + ValidateCheckpointRef(repository, checkpoint); + string? currentCommit = await TryResolveCommitAsync( + repository, + runner, + checkpoint.RefName, + cancellationToken) + .ConfigureAwait(false); + string? parentCommit = await TryResolveCommitAsync( + repository, + runner, + $"{checkpoint.Commit}^1", + cancellationToken) + .ConfigureAwait(false); + if (!string.Equals(currentCommit, checkpoint.Commit, StringComparison.OrdinalIgnoreCase) + || !string.Equals( + parentCommit, + checkpoint.BaseTip.Commit, + StringComparison.OrdinalIgnoreCase)) + { + throw new ProjectCheckpointChangedException(checkpoint.RefName); + } + } + + private static void ValidateCheckpointRef( + RepositoryInfo repository, + ProjectCheckpoint checkpoint) + { + ArgumentNullException.ThrowIfNull(checkpoint); + ArgumentException.ThrowIfNullOrWhiteSpace(checkpoint.RefName); + ArgumentException.ThrowIfNullOrWhiteSpace(checkpoint.Commit); + ArgumentNullException.ThrowIfNull(checkpoint.BaseTip); + string prefix = GetCheckpointRefPrefix(repository); + if (!checkpoint.RefName.StartsWith(prefix, StringComparison.Ordinal) + || !Guid.TryParseExact(checkpoint.RefName[prefix.Length..], "N", out _)) + { + throw new ArgumentException( + "The checkpoint does not belong to this project.", + nameof(checkpoint)); + } + + GitRevisionValidator.ValidateCommitId(checkpoint.Commit, nameof(checkpoint)); + ValidateAttachedBranchTip(checkpoint.BaseTip, nameof(checkpoint)); + } + + private static void ValidateBranchTipForRollback( + CheckedOutBranchTip expectedCurrent, + CheckedOutBranchTip target) + { + ValidateAttachedBranchTip(expectedCurrent, nameof(expectedCurrent)); + ValidateAttachedBranchTip(target, nameof(target)); + if (!string.Equals(expectedCurrent.RefName, target.RefName, StringComparison.Ordinal)) + { + throw new ArgumentException( + "The rollback heads must identify the same local branch.", + nameof(target)); + } + } + + private static void ValidateAttachedBranchTip(CheckedOutBranchTip tip, string paramName) + { + ArgumentNullException.ThrowIfNull(tip, paramName); + ArgumentException.ThrowIfNullOrWhiteSpace(tip.RefName, paramName); + ArgumentException.ThrowIfNullOrWhiteSpace(tip.Commit, paramName); + if (!IsValidLocalBranchRef(tip.RefName)) + { + throw new ArgumentException("An attached local branch tip is required.", paramName); + } + + GitRevisionValidator.ValidateCommitId(tip.Commit, paramName); + } + + private static bool IsValidLocalBranchRef(string refName) + { + const string Prefix = "refs/heads/"; + if (!refName.StartsWith(Prefix, StringComparison.Ordinal) + || refName.Length == Prefix.Length + || refName.EndsWith("/", StringComparison.Ordinal) + || refName.EndsWith(".", StringComparison.Ordinal) + || refName.Contains("//", StringComparison.Ordinal) + || refName.Contains("..", StringComparison.Ordinal) + || refName.Contains("@{", StringComparison.Ordinal) + || refName.Any(static character => character <= ' ' + || character == '\u007f' + || character is '~' or '^' or ':' or '?' or '*' or '[' or '\\')) + { + return false; + } + + foreach (string component in refName.Split('/')) + { + if (component.Length == 0 + || component.StartsWith(".", StringComparison.Ordinal) + || component.EndsWith(".lock", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + + return true; + } + + private static string GetBranchShortName(string refName) + { + const string Prefix = "refs/heads/"; + if (!refName.StartsWith(Prefix, StringComparison.Ordinal)) + { + throw new ArgumentException( + "An attached local branch ref is required.", + nameof(refName)); + } + + return refName[Prefix.Length..]; + } + + private static async Task TryGetCheckedOutBranchTipAsync( + RepositoryInfo repository, + IGitCliRunner runner, + CancellationToken cancellationToken) + { + try + { + return await GetCheckedOutBranchTipCoreAsync(repository, runner, cancellationToken).ConfigureAwait(false); + } + catch (DetachedHeadNotSupportedException) + { + return null; + } + } + + private static async Task TryResolveCommitAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string revision, + CancellationToken cancellationToken) + { + try + { + GitCommandResult result = await runner.RunAsync( + repository, + ["rev-parse", "--verify", "--quiet", $"{revision}^{{commit}}"], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + string commit = result.Stdout.Trim(); + return string.IsNullOrEmpty(commit) ? null : commit; + } + catch (GitOperationException ex) when (ex.ExitCode is 1 or 128) + { + return null; + } + } + + private static async Task TryResolveObjectAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string revision, + CancellationToken cancellationToken) + { + try + { + GitCommandResult result = await runner.RunAsync( + repository, + ["rev-parse", "--verify", "--quiet", revision], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + string objectId = result.Stdout.Trim(); + return string.IsNullOrEmpty(objectId) ? null : objectId; + } + catch (GitOperationException ex) when (ex.ExitCode is 1 or 128) + { + return null; + } + } + + private static async Task ResolveTreeAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string revision, + CancellationToken cancellationToken) + { + GitCommandResult result = await runner.RunAsync( + repository, + ["rev-parse", "--verify", $"{revision}^{{tree}}"], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + return result.Stdout.Trim(); + } + + private static async Task CaptureWorktreeStateAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string baseCommit, + string pathspec, + CancellationToken cancellationToken) + { + string temporaryIndex = Path.Combine( + Path.GetTempPath(), + $"beutl-git-index-{Guid.NewGuid():N}"); + var indexOptions = new GitCommandOptions( + GitCommandExecutionKind.Local, + new Dictionary + { + ["GIT_INDEX_FILE"] = temporaryIndex, + }); + + try + { + await runner.RunAsync( + repository, + ["read-tree", baseCommit], + indexOptions, + cancellationToken).ConfigureAwait(false); + await runner.RunAsync( + repository, + ["add", "-A", "--", pathspec], + indexOptions with { ExecutionKind = GitCommandExecutionKind.LocalWithLfs }, + cancellationToken).ConfigureAwait(false); + GitCommandResult tree = await runner.RunAsync( + repository, + ["write-tree"], + indexOptions, + cancellationToken).ConfigureAwait(false); + GitCommandResult indexEntries = await runner.RunAsync( + repository, + ["ls-files", "--stage", "-z", "--", pathspec], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + return new WorktreeStateFingerprint(tree.Stdout.Trim(), indexEntries.Stdout); + } + finally + { + TryDeleteTemporaryIndex(temporaryIndex); + } + } + + private static async Task BuildProjectTreeAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string baseCommit, + string sourceCommit, + CancellationToken cancellationToken) + { + string temporaryIndex = Path.Combine( + Path.GetTempPath(), + $"beutl-git-index-{Guid.NewGuid():N}"); + var indexOptions = new GitCommandOptions( + GitCommandExecutionKind.Local, + new Dictionary + { + ["GIT_INDEX_FILE"] = temporaryIndex, + }); + + try + { + await runner.RunAsync( + repository, + ["read-tree", baseCommit], + indexOptions, + cancellationToken).ConfigureAwait(false); + await runner.RunAsync( + repository, + [ + "restore", + $"--source={sourceCommit}", + "--staged", + "--", + repository.Pathspec, + ], + indexOptions, + cancellationToken).ConfigureAwait(false); + GitCommandResult tree = await runner.RunAsync( + repository, + ["write-tree"], + indexOptions, + cancellationToken).ConfigureAwait(false); + return tree.Stdout.Trim(); + } + finally + { + TryDeleteTemporaryIndex(temporaryIndex); + } + } + + private static async Task BuildMergedTreeAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string mergeBase, + string currentCommit, + string incomingCommit, + CancellationToken cancellationToken) + { + string temporaryIndex = Path.Combine( + Path.GetTempPath(), + $"beutl-git-index-{Guid.NewGuid():N}"); + var indexOptions = new GitCommandOptions( + GitCommandExecutionKind.Local, + new Dictionary + { + ["GIT_INDEX_FILE"] = temporaryIndex, + }); + + try + { + await runner.RunAsync( + repository, + ["read-tree", "-m", mergeBase, currentCommit, incomingCommit], + indexOptions, + cancellationToken).ConfigureAwait(false); + GitCommandResult tree = await runner.RunAsync( + repository, + ["write-tree"], + indexOptions, + cancellationToken).ConfigureAwait(false); + return tree.Stdout.Trim(); + } + finally + { + TryDeleteTemporaryIndex(temporaryIndex); + } + } + + private static async Task IsWholeRepositoryCleanAsync( + RepositoryInfo repository, + IGitCliRunner runner, + CancellationToken cancellationToken) + { + GitCommandResult result = await runner.RunAsync( + repository, + ["status", "--porcelain=v1", "--untracked-files=all", "-z"], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + return result.Stdout.Length == 0; + } + + private static async Task FindIgnoredIncomingPathAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string currentCommit, + string incomingCommit, + CancellationToken cancellationToken) + { + GitCommandResult changed = await runner.RunAsync( + repository, + [ + "diff", + "--name-only", + "--diff-filter=ACR", + "-z", + currentCommit, + incomingCommit, + "--", + ".", + ], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + IReadOnlyList changedPaths = GitCliRunner.SplitNullSeparated(changed.Stdout); + string repositoryRoot = Path.GetFullPath(repository.RepoRoot); + string repositoryPrefix = Path.TrimEndingDirectorySeparator(repositoryRoot) + + Path.DirectorySeparatorChar; + string[] existingPaths = changedPaths + .Where(path => + { + string fullPath; + try + { + fullPath = Path.GetFullPath(Path.Combine(repositoryRoot, path)); + } + catch (Exception ex) when (ex is ArgumentException + or NotSupportedException + or PathTooLongException) + { + return false; + } + + return fullPath.StartsWith(repositoryPrefix, PathComparison) + && (File.Exists(fullPath) || Directory.Exists(fullPath)); + }) + .ToArray(); + if (existingPaths.Length == 0) + { + return null; + } + + string input = string.Join('\0', existingPaths) + '\0'; + try + { + GitCommandResult ignored = await runner.RunAsync( + repository, + ["check-ignore", "--stdin", "-z"], + new GitCommandOptions( + GitCommandExecutionKind.Local, + StandardInput: input, + UseLiteralPathspecs: false), + cancellationToken).ConfigureAwait(false); + return GitCliRunner.SplitNullSeparated(ignored.Stdout).FirstOrDefault(); + } + catch (GitOperationException ex) when (ex.ExitCode == 1) + { + return null; + } + } + + private static async Task IsProjectCleanAsync( + RepositoryInfo repository, + IGitCliRunner runner, + CancellationToken cancellationToken) + { + GitCommandResult result = await runner.RunAsync( + repository, + [ + "status", + "--porcelain=v1", + "--untracked-files=all", + "-z", + "--", + repository.Pathspec, + ], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + return result.Stdout.Length == 0; + } + + private static async Task IsProjectIndexCleanAsync( + RepositoryInfo repository, + IGitCliRunner runner, + CancellationToken cancellationToken) + { + try + { + await runner.RunAsync( + repository, + ["diff", "--cached", "--quiet", "--", repository.Pathspec], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + return true; + } + catch (GitOperationException ex) when (ex.ExitCode == 1) + { + return false; + } + } + + private static async Task IsWholeIndexCleanAsync( + RepositoryInfo repository, + IGitCliRunner runner, + CancellationToken cancellationToken) + { + try + { + await runner.RunAsync( + repository, + ["diff", "--cached", "--quiet", "--", "."], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + return true; + } + catch (GitOperationException ex) when (ex.ExitCode == 1) + { + return false; + } + } + + private static async Task IsAncestorAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string ancestor, + string descendant, + CancellationToken cancellationToken) + { + try + { + await runner.RunAsync( + repository, + ["merge-base", "--is-ancestor", ancestor, descendant], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + return true; + } + catch (GitOperationException ex) when (ex.ExitCode == 1) + { + return false; + } + } + + private static async Task GetPullRelationAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string localCommit, + string upstreamCommit, + CancellationToken cancellationToken) + { + if (string.Equals(localCommit, upstreamCommit, StringComparison.OrdinalIgnoreCase)) + { + return PullRelation.Equal; + } + + if (await IsAncestorAsync( + repository, + runner, + localCommit, + upstreamCommit, + cancellationToken).ConfigureAwait(false)) + { + return PullRelation.LocalBehind; + } + + return await IsAncestorAsync( + repository, + runner, + upstreamCommit, + localCommit, + cancellationToken).ConfigureAwait(false) + ? PullRelation.LocalAhead + : PullRelation.Diverged; + } + + private async Task ApplyTreeTransitionAsync( + RepositoryInfo repository, + IGitCliRunner runner, + CheckedOutBranchTip currentHead, + CheckedOutBranchTip targetHead, + string currentTreeCommit, + string targetTreeCommit, + string reflogMessage, + TreeTransitionIndexPlan? indexPlan, + CancellationToken cancellationToken, + Action? validatePreparedTarget = null) + { + if (!string.Equals(currentHead.RefName, targetHead.RefName, StringComparison.Ordinal)) + { + throw new ArgumentException("A tree transition must remain on the same local branch."); + } + + string headPath = await ResolveGitPathAsync( + repository, + runner, + "HEAD", + cancellationToken) + .ConfigureAwait(false); + string indexPath = await ResolveGitPathAsync( + repository, + runner, + "index", + cancellationToken) + .ConfigureAwait(false); + string refUpdateWorktreePath = Path.Combine( + Path.GetTempPath(), + $"beutl-git-ref-update-{Guid.NewGuid():N}"); + try + { + await runner.RunAsync( + repository, + [ + "worktree", + "add", + "--detach", + "--no-checkout", + refUpdateWorktreePath, + currentTreeCommit, + ], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + await RemoveRefUpdateWorktreeBestEffortAsync( + repository, + runner, + refUpdateWorktreePath) + .ConfigureAwait(false); + return new TreeTransitionResult( + TreeTransitionOutcome.RestoredCurrent, + ex, + currentHead); + } + + var refUpdateRepository = new RepositoryInfo( + refUpdateWorktreePath, + refUpdateWorktreePath); + var transitionCheckoutOptions = new GitCommandOptions( + GitCommandExecutionKind.LocalWithLfs, + new Dictionary + { + ["GIT_WORK_TREE"] = repository.RepoRoot, + ["GIT_INDEX_FILE"] = indexPath, + }); + bool mutationStarted = false; + try + { + using HeadOwnershipLease lease = HeadOwnershipLease.Acquire( + headPath, + currentHead.RefName, + ex => LogWarningBestEffort( + ex, + "Failed to release the protected Git HEAD lock.")); + CheckedOutBranchTip actualHead = await GetCheckedOutBranchTipCoreAsync( + repository, + runner, + CancellationToken.None) + .ConfigureAwait(false); + if (!EqualsBranchTip(actualHead, currentHead)) + { + return new TreeTransitionResult( + TreeTransitionOutcome.OwnershipLost, + ActualTip: actualHead); + } + + await EnsureNoExternalRepositoryOperationAsync( + repository, + runner, + CancellationToken.None) + .ConfigureAwait(false); + + WorktreeStateFingerprint originalState = await CaptureWorktreeStateAsync( + repository, + runner, + currentTreeCommit, + indexPlan?.Pathspec ?? ".", + CancellationToken.None) + .ConfigureAwait(false); + string currentTree = await ResolveTreeAsync( + repository, + runner, + currentTreeCommit, + CancellationToken.None) + .ConfigureAwait(false); + if (!string.Equals(originalState.Tree, currentTree, StringComparison.OrdinalIgnoreCase)) + { + return new TreeTransitionResult(TreeTransitionOutcome.OwnershipLost); + } + + WorktreeStateFingerprint preparedState = originalState; + bool worktreeMutationAttempted = false; + bool targetPrepared = false; + try + { + if (indexPlan?.PrepareCommit is { } prepareCommit) + { + await EnsureNoExternalRepositoryOperationAsync( + repository, + runner, + CancellationToken.None) + .ConfigureAwait(false); + mutationStarted = true; + await ResetIndexAsync( + repository, + runner, + prepareCommit, + indexPlan.Pathspec) + .ConfigureAwait(false); + preparedState = await CaptureWorktreeStateAsync( + repository, + runner, + currentTreeCommit, + indexPlan.Pathspec, + CancellationToken.None) + .ConfigureAwait(false); + } + + string? ignoredCollision = await FindIgnoredIncomingPathAsync( + repository, + runner, + currentTreeCommit, + targetTreeCommit, + CancellationToken.None) + .ConfigureAwait(false); + if (ignoredCollision is not null) + { + throw new InvalidOperationException( + $"The tree transition would overwrite the ignored path '{ignoredCollision}'."); + } + + await EnsureNoExternalRepositoryOperationAsync( + repository, + runner, + CancellationToken.None) + .ConfigureAwait(false); + mutationStarted = true; + worktreeMutationAttempted = true; + await runner.RunAsync( + refUpdateRepository, + [ + .. s_lfsPathFilterOverrides, + "-c", + "core.hooksPath=/dev/null", + "checkout", + "--detach", + "--no-overwrite-ignore", + targetTreeCommit, + ], + transitionCheckoutOptions, + CancellationToken.None).ConfigureAwait(false); + + if (indexPlan?.FinalCommit is { } finalCommit) + { + await EnsureNoExternalRepositoryOperationAsync( + repository, + runner, + CancellationToken.None) + .ConfigureAwait(false); + await ResetIndexAsync( + repository, + runner, + finalCommit, + indexPlan.Pathspec) + .ConfigureAwait(false); + } + + WorktreeStateFingerprint targetState = await CaptureWorktreeStateAsync( + repository, + runner, + targetTreeCommit, + indexPlan?.Pathspec ?? ".", + CancellationToken.None) + .ConfigureAwait(false); + string targetTree = await ResolveTreeAsync( + repository, + runner, + targetTreeCommit, + CancellationToken.None) + .ConfigureAwait(false); + string expectedIndexCommit = indexPlan?.FinalCommit ?? targetTreeCommit; + if (!string.Equals(targetState.Tree, targetTree, StringComparison.OrdinalIgnoreCase) + || !await IsIndexAtCommitAsync( + repository, + runner, + expectedIndexCommit, + indexPlan?.Pathspec ?? ".", + CancellationToken.None) + .ConfigureAwait(false)) + { + throw new ProjectCheckpointStateChangedException(); + } + + validatePreparedTarget?.Invoke(); + targetPrepared = true; + + string? branchCommit = await TryResolveCommitAsync( + repository, + runner, + currentHead.RefName, + CancellationToken.None) + .ConfigureAwait(false); + if (!string.Equals( + branchCommit, + currentHead.Commit, + StringComparison.OrdinalIgnoreCase)) + { + return new TreeTransitionResult( + TreeTransitionOutcome.OwnershipLost, + ActualTip: await TryGetCheckedOutBranchTipAsync( + repository, + runner, + CancellationToken.None) + .ConfigureAwait(false)); + } + + await EnsureNoExternalRepositoryOperationAsync( + repository, + runner, + CancellationToken.None) + .ConfigureAwait(false); + await runner.RunAsync( + refUpdateRepository, + [ + "update-ref", + "-m", + reflogMessage, + currentHead.RefName, + targetHead.Commit, + currentHead.Commit, + ], + GitCommandOptions.Local, + CancellationToken.None).ConfigureAwait(false); + return new TreeTransitionResult(TreeTransitionOutcome.AppliedTarget); + } + catch (Exception transitionException) + { + string? branchCommit = await TryResolveCommitAsync( + repository, + runner, + currentHead.RefName, + CancellationToken.None) + .ConfigureAwait(false); + if (string.Equals( + branchCommit, + targetHead.Commit, + StringComparison.OrdinalIgnoreCase) + && targetPrepared) + { + return new TreeTransitionResult(TreeTransitionOutcome.AppliedTarget); + } + + if (!string.Equals( + branchCommit, + currentHead.Commit, + StringComparison.OrdinalIgnoreCase)) + { + return new TreeTransitionResult( + TreeTransitionOutcome.OwnershipLost, + transitionException, + await TryGetCheckedOutBranchTipAsync( + repository, + runner, + CancellationToken.None) + .ConfigureAwait(false)); + } + + try + { + await EnsureNoExternalRepositoryOperationAsync( + repository, + runner, + CancellationToken.None) + .ConfigureAwait(false); + } + catch (VersionControlConflictedException externalOperationException) + { + return new TreeTransitionResult( + TreeTransitionOutcome.OwnershipLost, + externalOperationException, + currentHead); + } + catch (Exception recoveryGuardException) + { + return new TreeTransitionResult( + TreeTransitionOutcome.RecoveryFailed, + new AggregateException( + "The tree transition failed and rollback safety could not be established.", + transitionException, + recoveryGuardException), + currentHead); + } + + try + { + WorktreeStateFingerprint failedState = await CaptureWorktreeStateAsync( + repository, + runner, + currentTreeCommit, + indexPlan?.Pathspec ?? ".", + CancellationToken.None) + .ConfigureAwait(false); + string targetTree = await ResolveTreeAsync( + repository, + runner, + targetTreeCommit, + CancellationToken.None) + .ConfigureAwait(false); + bool worktreeOwned = string.Equals( + failedState.Tree, + originalState.Tree, + StringComparison.OrdinalIgnoreCase) + || string.Equals( + failedState.Tree, + targetTree, + StringComparison.OrdinalIgnoreCase); + bool indexOwned = string.Equals( + failedState.IndexEntries, + originalState.IndexEntries, + StringComparison.Ordinal) + || string.Equals( + failedState.IndexEntries, + preparedState.IndexEntries, + StringComparison.Ordinal) + || await IsIndexAtCommitAsync( + repository, + runner, + targetTreeCommit, + indexPlan?.Pathspec ?? ".", + CancellationToken.None) + .ConfigureAwait(false) + || (indexPlan?.PrepareCommit is { } expectedPrepareCommit + && await IsIndexAtCommitAsync( + repository, + runner, + expectedPrepareCommit, + indexPlan.Pathspec, + CancellationToken.None) + .ConfigureAwait(false)) + || (indexPlan?.FinalCommit is { } expectedFinalCommit + && await IsIndexAtCommitAsync( + repository, + runner, + expectedFinalCommit, + indexPlan.Pathspec, + CancellationToken.None) + .ConfigureAwait(false)); + if (!indexOwned) + { + return new TreeTransitionResult( + TreeTransitionOutcome.OwnershipLost, + transitionException, + currentHead); + } + + if (!worktreeOwned) + { + string refusedRestoreCommit = indexPlan?.RestoreCommit ?? currentTreeCommit; + await EnsureNoExternalRepositoryOperationAsync( + repository, + runner, + CancellationToken.None) + .ConfigureAwait(false); + await ResetIndexAsync( + repository, + runner, + refusedRestoreCommit, + indexPlan?.Pathspec ?? ".") + .ConfigureAwait(false); + WorktreeStateFingerprint refusedState = await CaptureWorktreeStateAsync( + repository, + runner, + currentTreeCommit, + indexPlan?.Pathspec ?? ".", + CancellationToken.None) + .ConfigureAwait(false); + if (!string.Equals( + refusedState.IndexEntries, + originalState.IndexEntries, + StringComparison.Ordinal)) + { + return new TreeTransitionResult( + TreeTransitionOutcome.RecoveryFailed, + new AggregateException( + "The checkout was refused and the original index could not be restored.", + transitionException), + currentHead); + } + + CheckedOutBranchTip? refusedTip = await TryGetCheckedOutBranchTipAsync( + repository, + runner, + CancellationToken.None) + .ConfigureAwait(false); + return new TreeTransitionResult( + TreeTransitionOutcome.OwnershipLost, + transitionException, + refusedTip); + } + + if (worktreeMutationAttempted + && string.Equals( + failedState.Tree, + targetTree, + StringComparison.OrdinalIgnoreCase)) + { + string? transitionHead = await TryResolveCommitAsync( + refUpdateRepository, + runner, + "HEAD", + CancellationToken.None) + .ConfigureAwait(false); + if (string.Equals( + transitionHead, + currentTreeCommit, + StringComparison.OrdinalIgnoreCase)) + { + try + { + await runner.RunAsync( + refUpdateRepository, + [ + "update-ref", + "--no-deref", + "-m", + "beutl align temporary transition head for recovery", + "HEAD", + targetTreeCommit, + currentTreeCommit, + ], + GitCommandOptions.Local, + CancellationToken.None).ConfigureAwait(false); + } + catch (Exception alignmentException) + { + transitionHead = await TryResolveCommitAsync( + refUpdateRepository, + runner, + "HEAD", + CancellationToken.None) + .ConfigureAwait(false); + if (string.Equals( + transitionHead, + targetTreeCommit, + StringComparison.OrdinalIgnoreCase)) + { + // The update reached Git even though the runner lost its response. + } + else if (string.Equals( + transitionHead, + currentTreeCommit, + StringComparison.OrdinalIgnoreCase)) + { + return new TreeTransitionResult( + TreeTransitionOutcome.RecoveryFailed, + new AggregateException( + "The temporary transition head could not be aligned for recovery.", + transitionException, + alignmentException), + currentHead); + } + else + { + return new TreeTransitionResult( + TreeTransitionOutcome.OwnershipLost, + new AggregateException( + "The temporary transition head changed while recovery was being prepared.", + transitionException, + alignmentException), + currentHead); + } + } + } + else if (!string.Equals( + transitionHead, + targetTreeCommit, + StringComparison.OrdinalIgnoreCase)) + { + return new TreeTransitionResult( + TreeTransitionOutcome.OwnershipLost, + transitionException, + currentHead); + } + + await EnsureNoExternalRepositoryOperationAsync( + repository, + runner, + CancellationToken.None) + .ConfigureAwait(false); + await ResetIndexAsync( + repository, + runner, + targetTreeCommit, + indexPlan?.Pathspec ?? ".") + .ConfigureAwait(false); + await EnsureNoExternalRepositoryOperationAsync( + repository, + runner, + CancellationToken.None) + .ConfigureAwait(false); + await runner.RunAsync( + refUpdateRepository, + [ + .. s_lfsPathFilterOverrides, + "-c", + "core.hooksPath=/dev/null", + "checkout", + "--detach", + "--no-overwrite-ignore", + currentTreeCommit, + ], + transitionCheckoutOptions, + CancellationToken.None).ConfigureAwait(false); + } + + if (indexPlan?.RestoreCommit is { } restoreCommit) + { + await EnsureNoExternalRepositoryOperationAsync( + repository, + runner, + CancellationToken.None) + .ConfigureAwait(false); + await ResetIndexAsync( + repository, + runner, + restoreCommit, + indexPlan.Pathspec) + .ConfigureAwait(false); + } + else if (!string.Equals( + failedState.IndexEntries, + originalState.IndexEntries, + StringComparison.Ordinal)) + { + await EnsureNoExternalRepositoryOperationAsync( + repository, + runner, + CancellationToken.None) + .ConfigureAwait(false); + await ResetIndexAsync( + repository, + runner, + currentTreeCommit, + indexPlan?.Pathspec ?? ".") + .ConfigureAwait(false); + } + + WorktreeStateFingerprint recoveredState = await CaptureWorktreeStateAsync( + repository, + runner, + currentTreeCommit, + indexPlan?.Pathspec ?? ".", + CancellationToken.None) + .ConfigureAwait(false); + string expectedRestoreCommit = indexPlan?.RestoreCommit ?? currentTreeCommit; + if (!string.Equals( + recoveredState.Tree, + currentTree, + StringComparison.OrdinalIgnoreCase) + || !await IsIndexAtCommitAsync( + repository, + runner, + expectedRestoreCommit, + indexPlan?.Pathspec ?? ".", + CancellationToken.None) + .ConfigureAwait(false)) + { + return new TreeTransitionResult( + TreeTransitionOutcome.RecoveryFailed, + new AggregateException( + "The tree transition failed and the original tree could not be verified.", + transitionException), + currentHead); + } + + CheckedOutBranchTip? recoveredTip = await TryGetCheckedOutBranchTipAsync( + repository, + runner, + CancellationToken.None) + .ConfigureAwait(false); + if (recoveredTip is null || !EqualsBranchTip(recoveredTip, currentHead)) + { + return new TreeTransitionResult( + TreeTransitionOutcome.OwnershipLost, + transitionException, + recoveredTip); + } + + return new TreeTransitionResult( + TreeTransitionOutcome.RestoredCurrent, + transitionException, + currentHead); + } + catch (VersionControlConflictedException recoveryException) + { + return new TreeTransitionResult( + TreeTransitionOutcome.OwnershipLost, + recoveryException, + currentHead); + } + catch (Exception recoveryException) + { + return new TreeTransitionResult( + TreeTransitionOutcome.RecoveryFailed, + new AggregateException( + "The tree transition failed and its current state could not be restored.", + transitionException, + recoveryException), + currentHead); + } + } + } + catch (ProjectCheckpointStateChangedException ex) + { + return new TreeTransitionResult( + TreeTransitionOutcome.OwnershipLost, + ex, + await TryGetCheckedOutBranchTipAsync( + repository, + runner, + CancellationToken.None) + .ConfigureAwait(false)); + } + catch (Exception ex) + { + return new TreeTransitionResult( + mutationStarted + ? TreeTransitionOutcome.RecoveryFailed + : TreeTransitionOutcome.RestoredCurrent, + ex, + currentHead); + } + finally + { + await RemoveRefUpdateWorktreeBestEffortAsync( + repository, + runner, + refUpdateWorktreePath) + .ConfigureAwait(false); + } + } + + private async Task RemoveRefUpdateWorktreeBestEffortAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string worktreePath) + { + Exception? cleanupFailure = null; + try + { + await runner.RunAsync( + repository, + ["worktree", "remove", "--force", worktreePath], + GitCommandOptions.Local, + CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + cleanupFailure = ex; + } + + try + { + if (Directory.Exists(worktreePath)) + { + Directory.Delete(worktreePath, recursive: true); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + cleanupFailure = cleanupFailure is null + ? ex + : new AggregateException(cleanupFailure, ex); + } + + if (cleanupFailure is not null) + { + LogWarningBestEffort( + cleanupFailure, + "Failed to remove a temporary detached Git worktree used for a ref update."); + } + } + + private static async Task ResolveGitPathAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string gitPath, + CancellationToken cancellationToken) + { + GitCommandResult result = await runner.RunAsync( + repository, + ["rev-parse", "--git-path", gitPath], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + string path = result.Stdout.TrimEnd('\r', '\n'); + return Path.GetFullPath( + Path.IsPathFullyQualified(path) + ? path + : Path.Combine(repository.RepoRoot, path)); + } + + private static void EnsureTreeTransitionApplied( + TreeTransitionResult result, + string message) + { + if (result.Outcome == TreeTransitionOutcome.AppliedTarget) + { + return; + } + + if (result.Error is GitOperationException operationException) + { + throw operationException; + } + + throw new InvalidOperationException( + $"{message} Outcome: {result.Outcome}.", + result.Error); + } + + private static Task ResetIndexAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string commit, + string pathspec) + { + return string.Equals(pathspec, ".", StringComparison.Ordinal) + ? runner.RunAsync( + repository, + ["read-tree", "--reset", commit], + GitCommandOptions.Local, + CancellationToken.None) + : runner.RunAsync( + repository, + ["restore", $"--source={commit}", "--staged", "--", pathspec], + GitCommandOptions.Local, + CancellationToken.None); + } + + private static async Task IsIndexAtCommitAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string commit, + string pathspec, + CancellationToken cancellationToken) + { + try + { + await runner.RunAsync( + repository, + ["diff", "--cached", "--quiet", commit, "--", pathspec], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + return true; + } + catch (GitOperationException ex) when (ex.ExitCode == 1) + { + return false; + } + } + + private static string GetCheckpointRefPrefix(RepositoryInfo repository) + { + return $"refs/beutl/safety/{GetConfigKeyHash(repository.Pathspec)}/"; + } + + private static string GetPendingRecoveryRefPrefix(RepositoryInfo repository) + { + return $"refs/beutl/recovery/{GetConfigKeyHash(repository.Pathspec)}/"; + } + + private static bool EqualsBranchTip(CheckedOutBranchTip left, CheckedOutBranchTip right) + { + return string.Equals(left.RefName, right.RefName, StringComparison.Ordinal) + && string.Equals(left.Commit, right.Commit, StringComparison.OrdinalIgnoreCase); + } + + private static void TryDeleteTemporaryIndex(string path) + { + try + { + File.Delete(path); + File.Delete($"{path}.lock"); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + } + } + + private async Task GetStatusCoreAsync(CancellationToken cancellationToken) + { + RepositoryInfo repository = GetRepository(); + IGitCliRunner runner = await GetInstalledRunnerCoreAsync(cancellationToken).ConfigureAwait(false); + WorkspaceStatus status = await GetStatusCoreAsync( + repository, + runner, + cancellationToken, + extraPathspecs: null) + .ConfigureAwait(false); + if (status.Branch is null + || (await GetRemotesCoreAsync(cancellationToken).ConfigureAwait(false)).Count == 0) + { + return status; + } + + // Counts stay against origin even when the branch tracks a different remote, but the origin + // branch is whichever one this branch actually tracks: synthesizing it from the local name + // answers for an unrelated branch whenever the two names differ. + string? upstream = await TryGetUpstreamRefAsync(repository, runner, cancellationToken) + .ConfigureAwait(false); + string originBranchRef = + upstream is not null && upstream.StartsWith(OriginRefPrefix, StringComparison.Ordinal) + ? upstream + : $"{OriginRefPrefix}{status.Branch}"; + if (!await RefExistsAsync(repository, runner, originBranchRef, cancellationToken) + .ConfigureAwait(false)) + { + return status with { Ahead = 0, Behind = 0 }; + } + + GitCommandResult counts = await runner.RunAsync( + repository, + ["rev-list", "--left-right", "--count", $"HEAD...{originBranchRef}"], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + (int ahead, int behind) = ParseAheadBehindCounts(counts.Stdout); + return status with { Ahead = ahead, Behind = behind }; + } + + private static async Task TryGetUpstreamRefAsync( + RepositoryInfo repository, + IGitCliRunner runner, + CancellationToken cancellationToken) + { + try + { + GitCommandResult result = await runner.RunAsync( + repository, + ["rev-parse", "--symbolic-full-name", "@{upstream}"], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + string upstream = result.Stdout.Trim(); + return upstream.Length == 0 ? null : upstream; + } + catch (GitOperationException) + { + // No upstream configured, which git reports as a failure rather than empty output. + return null; + } + } + + // Verified rather than matched: for-each-ref treats its operand as a pattern, so asking for + // refs/remotes/origin/foo also succeeds when only refs/remotes/origin/foo/bar exists. + private static async Task RefExistsAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string refName, + CancellationToken cancellationToken) + { + try + { + await runner.RunAsync( + repository, + ["show-ref", "--verify", "--quiet", refName], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + return true; + } + catch (GitOperationException) + { + return false; + } + } + + private async Task GetSnapshotStatusCoreAsync( + CancellationToken cancellationToken) + { + RepositoryInfo repository = GetRepository(); + IGitCliRunner runner = await GetInstalledRunnerCoreAsync(cancellationToken).ConfigureAwait(false); + return await GetStatusCoreAsync( + repository, + runner, + cancellationToken, + CreateSnapshotExcludePathspecs(repository)) + .ConfigureAwait(false); + } + + private static async Task GetStatusCoreAsync( + RepositoryInfo repository, + IGitCliRunner runner, + CancellationToken cancellationToken, + IReadOnlyList? extraPathspecs = null) + { + string projectPathspec = extraPathspecs is null + ? repository.Pathspec + : CreateSnapshotBasePathspec(repository); + var arguments = new List + { + "status", + "--porcelain=v2", + "--branch", + "--untracked-files=all", + "-z", + "--", + projectPathspec, + }; + if (extraPathspecs is not null) + { + arguments.AddRange(extraPathspecs); + } + + GitCommandResult result = await runner.RunAsync( + repository, + arguments, + new GitCommandOptions( + GitCommandExecutionKind.Local, + UseLiteralPathspecs: extraPathspecs is null), + cancellationToken).ConfigureAwait(false); + WorkspaceStatus status = ParseStatus(result.Stdout); + if (!repository.IsNestedInForeignRepo || status.HasConflicts) + { + return status; + } + + GitCommandResult unmerged = await runner.RunAsync( + repository, + ["ls-files", "--unmerged"], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + return string.IsNullOrWhiteSpace(unmerged.Stdout) + ? status + : status with { HasConflicts = true }; + } + + private async Task> GetHistoryCoreAsync( + int skip, + int take, + CancellationToken cancellationToken) + { + RepositoryInfo repository = GetRepository(); + IGitCliRunner runner = await GetInstalledRunnerCoreAsync(cancellationToken).ConfigureAwait(false); + GitCommandResult result = await runner.RunAsync( + repository, + [ + "log", + "--no-show-signature", + "--format=%H%x00%h%x00%an%x00%aI%x00%s%x00%(trailers:key=Beutl-Snapshot,valueonly)%x00", + "-z", + $"--skip={skip}", + "-n", + take.ToString(System.Globalization.CultureInfo.InvariantCulture), + "--", + repository.Pathspec, + ], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + return ParseHistory(result.Stdout); + } + + private async Task> GetCommitFilesCoreAsync( + string sha, + CancellationToken cancellationToken) + { + RepositoryInfo repository = GetRepository(); + IGitCliRunner runner = await GetInstalledRunnerCoreAsync(cancellationToken).ConfigureAwait(false); + GitCommandResult result = await runner.RunAsync( + repository, + [ + "show", + "--no-show-signature", + "--first-parent", + "--name-status", + "--format=", + "-z", + sha, + "--", + repository.Pathspec, + ], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + return ParseCommitFiles(result.Stdout); + } + + private async Task GetDiffCoreAsync( + string sha, + string? path, + CancellationToken cancellationToken) + { + RepositoryInfo repository = GetRepository(); + IGitCliRunner runner = await GetInstalledRunnerCoreAsync(cancellationToken).ConfigureAwait(false); + string pathspec = path is null + ? repository.Pathspec + : ValidateDiffPath(repository, path); + GitCommandResult result = await runner.RunAsync( + repository, + [ + "show", + "--no-show-signature", + "--first-parent", + "--no-color", + "--format=", + "--no-ext-diff", + "--unified=3", + sha, + "--", + pathspec, + ], + GitCommandOptions.Local with { MaxStdoutBytes = MaxDiffBytes }, + cancellationToken).ConfigureAwait(false); + return result.StdoutTruncated + ? string.Concat(result.Stdout, DiffTruncationMarker) + : result.Stdout; + } + + private async Task> GetBranchesCoreAsync( + CancellationToken cancellationToken) + { + RepositoryInfo repository = GetRepository(); + IGitCliRunner runner = await GetInstalledRunnerCoreAsync(cancellationToken).ConfigureAwait(false); + GitCommandResult result = await runner.RunAsync( + repository, + [ + "for-each-ref", + "--format=%(refname:lstrip=2)%00%(HEAD)%00%(upstream:lstrip=2)", + "refs/heads", + ], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + return ParseBranches(result.Stdout); + } + + private async Task CanCreateBranchCoreAsync( + string name, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(name)) + { + return false; + } + + RepositoryInfo repository = GetRepository(); + IGitCliRunner runner = await GetInstalledRunnerCoreAsync(cancellationToken).ConfigureAwait(false); + try + { + GitCommandResult result = await runner.RunAsync( + repository, + ["check-ref-format", "--branch", name], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + string validatedName = RemoveSingleTrailingLineEnding(result.Stdout); + if (!string.Equals(validatedName, name, StringComparison.Ordinal)) + { + return false; + } + + IReadOnlyList branches = await GetBranchesCoreAsync(cancellationToken) + .ConfigureAwait(false); + StringComparison branchNameComparison = await UsesCaseInsensitiveFilesRefStorageAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false) + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + if (branches.Any(branch => BranchNamesConflict( + branch.Name, + name, + branchNameComparison))) + { + return false; + } + + return !await HasLooseBranchPathCollisionAsync( + repository, + runner, + name, + cancellationToken) + .ConfigureAwait(false); + } + catch (GitOperationException) + { + return false; + } + } + + private static string RemoveSingleTrailingLineEnding(string value) + { + if (value.EndsWith("\r\n", StringComparison.Ordinal)) + { + return value[..^2]; + } + + return value.EndsWith('\n') ? value[..^1] : value; + } + + private async Task CreateBranchCoreAsync( + string name, + string startPoint, + CancellationToken cancellationToken) + { + GitRevisionValidator.ValidateCommitId(startPoint, nameof(startPoint)); + if (!await CanCreateBranchCoreAsync(name, cancellationToken).ConfigureAwait(false)) + { + throw new ArgumentException( + "The branch must be a valid, unused local branch name.", + nameof(name)); + } + + await EnsureNotConflictedCoreAsync(cancellationToken).ConfigureAwait(false); + EnsureWorktreeMutationAllowed(); + RepositoryInfo repository = GetRepository(); + IGitCliRunner runner = await GetInstalledRunnerCoreAsync(cancellationToken).ConfigureAwait(false); + await runner.RunAsync( + repository, + [.. s_lfsPathFilterOverrides, "switch", "--no-overwrite-ignore", "-c", name, startPoint], + new GitCommandOptions(GitCommandExecutionKind.LocalWithLfs), + cancellationToken).ConfigureAwait(false); + await TryQueueStatusChangedCoreAsync().ConfigureAwait(false); + } + + public Task> GetTrackedReservedPathsAsync( + CancellationToken cancellationToken) + { + ThrowIfDisposed(); + return RunSerializedAsync( + async () => + { + RepositoryInfo repository = GetRepository(); + IGitCliRunner runner = await GetInstalledRunnerCoreAsync(cancellationToken) + .ConfigureAwait(false); + return await GetTrackedReservedPathsCoreAsync(repository, runner, cancellationToken) + .ConfigureAwait(false); + }, + cancellationToken); + } + + public Task UntrackReservedPathsAsync( + IReadOnlyList reservedPaths, + CancellationToken cancellationToken) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(reservedPaths); + if (reservedPaths.Count == 0) + { + return Task.CompletedTask; + } + + return RunSerializedAsync( + async () => + { + RepositoryInfo repository = GetRepository(); + IGitCliRunner runner = await GetInstalledRunnerCoreAsync(cancellationToken) + .ConfigureAwait(false); + await TryUntrackReservedPathsCoreAsync( + repository, + runner, + reservedPaths, + cancellationToken) + .ConfigureAwait(false); + await TryQueueStatusChangedCoreAsync().ConfigureAwait(false); + return true; + }, + cancellationToken); + } + + private static bool IsReservedProjectPath(string repositoryRelativePath) + { + if (repositoryRelativePath.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + foreach (string segment in repositoryRelativePath.Split('/')) + { + if (string.Equals(segment, ".beutl", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static async Task> GetTrackedReservedPathsCoreAsync( + RepositoryInfo repository, + IGitCliRunner runner, + CancellationToken cancellationToken) + { + GitCommandResult listed = await runner.RunAsync( + repository, + ["ls-files", "-z", "--", CreateSnapshotBasePathspec(repository)], + new GitCommandOptions(GitCommandExecutionKind.Local) { UseLiteralPathspecs = false }, + cancellationToken).ConfigureAwait(false); + return listed.Stdout + .Split('\0', StringSplitOptions.RemoveEmptyEntries) + .Where(IsReservedProjectPath) + .ToArray(); + } + + // .gitignore never untracks what is already tracked, and snapshot status excludes these paths - + // so a project that is clean to Beutl still leaves the repository dirty for the pull + // precondition, with no way out from inside the app. Drop them from the index (the files stay on + // disk) and record that in its own commit: the initialization commit is pathspec-limited with + // the very excludes that hide these paths, so it would leave the deletion staged forever. + private async Task TryUntrackReservedPathsCoreAsync( + RepositoryInfo repository, + IGitCliRunner runner, + IReadOnlyList reservedPaths, + CancellationToken cancellationToken) + { + string? indexTree = null; + try + { + GitCommandResult index = await runner.RunAsync( + repository, + ["write-tree"], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + indexTree = index.Stdout.Trim(); + + var removeArguments = new List { "rm", "--cached", "--quiet", "--" }; + removeArguments.AddRange(reservedPaths); + await runner.RunAsync( + repository, + removeArguments, + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + + // A pathspec-limited commit reads the working tree for the named paths and reports + // "nothing to commit" for an index-only deletion, which would leave the untracking + // staged forever. The commit therefore takes the index as it stands - and only when + // nothing else is staged, so an enclosing repository's own work is never swept in. + GitCommandResult staged = await runner.RunAsync( + repository, + ["diff", "--cached", "--name-only", "-z"], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + string[] stagedPaths = staged.Stdout.Split('\0', StringSplitOptions.RemoveEmptyEntries); + if (stagedPaths.Length != reservedPaths.Count + || !stagedPaths.All(path => reservedPaths.Contains(path, StringComparer.Ordinal))) + { + await ResetIndexAsync(repository, runner, indexTree, repository.Pathspec) + .ConfigureAwait(false); + _logger.LogInformation( + "Left reserved project paths tracked because other changes are staged in the repository."); + return; + } + + await runner.RunAsync( + repository, + [ + "-c", + "core.logAllRefUpdates=true", + "commit", + "--no-gpg-sign", + "-m", + "beutl: stop tracking reserved project state", + "-m", + "Beutl-Snapshot: init", + ], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Cancellation lands here with the reserved paths already removed from the index but not + // committed. Leaving that staged would let the next user commit drop them silently, so + // this step's own index change is undone before the cancellation surfaces. + await TryRestoreIndexAfterUntrackAsync(repository, runner, indexTree) + .ConfigureAwait(false); + throw; + } + catch (Exception ex) + { + // Initialization has already succeeded by the time this runs, so a failure here must not + // fail it; undo only this step's own index change and leave the rest untouched. + await TryRestoreIndexAfterUntrackAsync(repository, runner, indexTree) + .ConfigureAwait(false); + + LogWarningBestEffort( + ex, + "Could not stop tracking reserved project paths; pulls will report the repository dirty until they are untracked manually."); + } + } + + // ResetIndexAsync deliberately runs without a cancellation token: this is a compensating action + // that also has to complete on the cancellation path. + private async Task TryRestoreIndexAfterUntrackAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string? indexTree) + { + if (indexTree is null) + { + return; + } + + try + { + await ResetIndexAsync(repository, runner, indexTree, repository.Pathspec) + .ConfigureAwait(false); + } + catch (Exception restoreException) + { + LogWarningBestEffort( + restoreException, + "Could not restore the index after failing to untrack reserved project paths."); + } + } + + private Task PrefetchBranchLfsObjectsCoreAsync( + string name, + CancellationToken cancellationToken) + { + ValidateSwitchBranchName(name); + return PrefetchLfsObjectsCoreAsync(name, cancellationToken); + } + + private Task PrefetchCommitLfsObjectsCoreAsync( + string sha, + CancellationToken cancellationToken) + { + GitRevisionValidator.ValidateCommitId(sha, nameof(sha)); + return PrefetchLfsObjectsCoreAsync(sha, cancellationToken); + } + + private async Task PrefetchLfsObjectsCoreAsync( + string reference, + CancellationToken cancellationToken) + { + RepositoryInfo repository = GetRepository(); + IGitCliRunner runner = await GetInstalledRunnerCoreAsync(cancellationToken).ConfigureAwait(false); + if (!await IsLfsActiveAsync(repository, runner, cancellationToken).ConfigureAwait(false)) + { + return; + } + + IReadOnlyList remotes = await GetRemotesCoreAsync(cancellationToken) + .ConfigureAwait(false); + if (remotes.Count == 0) + { + return; + } + + try + { + await runner.RunAsync( + repository, + [.. s_lfsPathFilterOverrides, "lfs", "fetch", remotes[0].Name, reference], + GitCommandOptions.Network, + cancellationToken).ConfigureAwait(false); + } + catch (GitOperationException ex) + { + // The objects may already be cached, so an unreachable endpoint must not turn an + // otherwise working transition into an error. What it must not do is let the caller + // close the project and leave the checkout's own smudge filter to download the missing + // content uncancellably, so the failure is only absorbed when the target needs nothing + // that is not already in the local object store. + if (await HasUncachedLfsObjectsAsync(repository, runner, reference, cancellationToken) + .ConfigureAwait(false)) + { + throw; + } + + _logger.LogWarning( + ex, + "Could not prefetch Git LFS objects for '{Reference}', but every object it needs is already cached.", + reference); + } + } + + // Fails safe: anything that stops this from proving the objects are present - an unreadable + // listing, an unknown storage layout, an unparsable line - counts as uncached, so the caller + // aborts while it still can instead of closing the project first. + private async Task HasUncachedLfsObjectsAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string reference, + CancellationToken cancellationToken) + { + string storage; + GitCommandResult listed; + try + { + storage = await GetLfsObjectStorageAsync(repository, runner, cancellationToken) + .ConfigureAwait(false); + listed = await runner.RunAsync( + repository, + [.. s_lfsPathFilterOverrides, "lfs", "ls-files", "--long", reference], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + } + catch (GitOperationException ex) + { + LogWarningBestEffort( + ex, + "Could not list the Git LFS objects required by a transition target."); + return true; + } + + foreach (string line in listed.Stdout.Split( + '\n', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + int separator = line.IndexOf(' '); + string oid = separator < 0 ? line : line[..separator]; + if (oid.Length < 4 || !oid.All(static character => Uri.IsHexDigit(character))) + { + return true; + } + + if (!File.Exists(Path.Combine(storage, oid[..2], oid[2..4], oid))) + { + return true; + } + } + + return false; + } + + private static async Task GetLfsObjectStorageAsync( + RepositoryInfo repository, + IGitCliRunner runner, + CancellationToken cancellationToken) + { + GitCommandResult gitDirectory = await runner.RunAsync( + repository, + ["rev-parse", "--git-common-dir"], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + string commonDirectory = gitDirectory.Stdout.Trim(); + if (commonDirectory.Length == 0) + { + throw new InvalidOperationException("Git returned an empty common directory."); + } + + string root = Path.GetFullPath( + Path.IsPathFullyQualified(commonDirectory) + ? commonDirectory + : Path.Combine(repository.RepoRoot, commonDirectory)); + GitCommandResult configured = await runner.RunAsync( + repository, + ["config", "--get", "--default", "", "lfs.storage"], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + string storage = configured.Stdout.Trim(); + if (storage.Length == 0) + { + return Path.Combine(root, "lfs", "objects"); + } + + string storageRoot = Path.IsPathFullyQualified(storage) + ? storage + : Path.Combine(root, storage); + return Path.Combine(storageRoot, "objects"); + } + + private async Task SwitchBranchCoreAsync( + string name, + CancellationToken cancellationToken) + { + ValidateSwitchBranchName(name); + await EnsureNotConflictedCoreAsync(cancellationToken).ConfigureAwait(false); + EnsureWorktreeMutationAllowed(); + IReadOnlyList branches = await GetBranchesCoreAsync(cancellationToken) + .ConfigureAwait(false); + if (!ContainsLocalBranch(branches, name)) + { + throw new ArgumentException( + "The branch must exactly name an existing local branch.", + nameof(name)); + } + + RepositoryInfo repository = GetRepository(); + IGitCliRunner runner = await GetInstalledRunnerCoreAsync(cancellationToken).ConfigureAwait(false); + await runner.RunAsync( + repository, + [.. s_lfsPathFilterOverrides, "switch", "--no-overwrite-ignore", name], + new GitCommandOptions(GitCommandExecutionKind.LocalWithLfs), + cancellationToken).ConfigureAwait(false); + await TryQueueStatusChangedCoreAsync().ConfigureAwait(false); + } + + private static void ValidateSwitchBranchName(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + if (name[0] == '-') + { + throw new ArgumentException( + "The branch name must not be interpreted as a Git command-line option.", + nameof(name)); + } + } + + private static bool ContainsLocalBranch( + IReadOnlyList branches, + string name) + { + return branches.Any(branch => + string.Equals(branch.Name, name, StringComparison.Ordinal)); + } + + private static bool BranchNamesConflict( + string existingName, + string candidateName, + StringComparison comparison) + { + return string.Equals(existingName, candidateName, comparison) + || existingName.StartsWith($"{candidateName}/", comparison) + || candidateName.StartsWith($"{existingName}/", comparison); + } + + private static async Task UsesCaseInsensitiveFilesRefStorageAsync( + RepositoryInfo repository, + IGitCliRunner runner, + CancellationToken cancellationToken) + { + try + { + GitCommandResult storage = await runner.RunAsync( + repository, + ["config", "--local", "--get", "extensions.refStorage"], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + if (!string.Equals( + storage.Stdout.Trim(), + "files", + StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + catch (GitOperationException ex) when (ex.ExitCode == 1) + { + // The traditional files backend omits extensions.refStorage. + } + + string headsDirectory = await ResolveGitPathAsync( + repository, + runner, + "refs/heads", + cancellationToken) + .ConfigureAwait(false); + return IsDirectoryStorageCaseInsensitive(headsDirectory); + } + + private static bool IsDirectoryStorageCaseInsensitive(string directory) + { + try + { + DirectoryInfo? current = new DirectoryInfo(directory); + while (current is not null && !current.Exists) + { + current = current.Parent; + } + + while (current?.Parent is not null) + { + string aliasName = current.Name.ToUpperInvariant(); + if (string.Equals(aliasName, current.Name, StringComparison.Ordinal)) + { + aliasName = current.Name.ToLowerInvariant(); + } + + if (!string.Equals(aliasName, current.Name, StringComparison.Ordinal)) + { + string aliasPath = Path.Combine(current.Parent.FullName, aliasName); + if (!Directory.Exists(aliasPath)) + { + return false; + } + + bool distinctAliasExists = current.Parent + .EnumerateDirectories() + .Any(candidate => string.Equals( + candidate.Name, + aliasName, + StringComparison.Ordinal)); + return !distinctAliasExists; + } + + current = current.Parent; + } + + return OperatingSystem.IsWindows(); + } + catch (Exception ex) + when (ex is IOException + or UnauthorizedAccessException + or NotSupportedException) + { + // Conservatively reject case aliases when the files backend cannot be inspected. + return true; + } + } + + private static async Task HasLooseBranchPathCollisionAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string candidateName, + CancellationToken cancellationToken) + { + string headsDirectory = await ResolveGitPathAsync( + repository, + runner, + "refs/heads", + cancellationToken) + .ConfigureAwait(false); + string candidatePath = Path.Combine( + headsDirectory, + candidateName.Replace('/', Path.DirectorySeparatorChar)); + if (Path.Exists(candidatePath)) + { + return true; + } + + string? parent = Path.GetDirectoryName(candidatePath); + while (parent is not null + && !string.Equals(parent, headsDirectory, StringComparison.Ordinal)) + { + if (File.Exists(parent)) + { + return true; + } + + parent = Path.GetDirectoryName(parent); + } + + return false; + } + + private async Task> GetRemotesCoreAsync( + CancellationToken cancellationToken) + { + RepositoryInfo repository = GetRepository(); + IGitCliRunner runner = await GetInstalledRunnerCoreAsync(cancellationToken).ConfigureAwait(false); + try + { + GitCommandResult result = await runner.RunAsync( + repository, + ["remote", "get-url", "origin"], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + string url = result.Stdout.Trim(); + return string.IsNullOrEmpty(url) ? [] : [new RemoteInfo("origin", url)]; + } + catch (GitOperationException ex) when (IsMissingRemoteFailure(ex)) + { + return []; + } + } + + private async Task SetRemoteCoreAsync( + string url, + CancellationToken cancellationToken) + { + await EnsureNotConflictedCoreAsync(cancellationToken).ConfigureAwait(false); + RepositoryInfo repository = GetRepository(); + IGitCliRunner runner = await GetInstalledRunnerCoreAsync(cancellationToken).ConfigureAwait(false); + bool isFirstRemote = (await GetRemotesCoreAsync(cancellationToken).ConfigureAwait(false)).Count == 0; + if (isFirstRemote) + { + await runner.RunAsync( + repository, + ["remote", "add", "origin", url], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + } + else + { + await UpdateLocalConfigAtomicallyAsync( + repository, + runner, + async (stagingPath, updateCancellation) => + { + await runner.RunAsync( + repository, + ["config", "--file", stagingPath, "--replace-all", "remote.origin.url", url], + GitCommandOptions.Local, + updateCancellation).ConfigureAwait(false); + await runner.RunAsync( + repository, + ["config", "--file", stagingPath, "--replace-all", "remote.origin.pushurl", url], + GitCommandOptions.Local, + updateCancellation).ConfigureAwait(false); + }, + "remote update", + cancellationToken).ConfigureAwait(false); + } + + await TryRaiseLfsQuotaNoticeIfNeededAsync( + repository, + runner).ConfigureAwait(false); + + await TryQueueStatusChangedCoreAsync().ConfigureAwait(false); + } + + private async Task PushCoreAsync( + IProgress? progress, + CancellationToken cancellationToken) + { + await EnsureNotConflictedCoreAsync(cancellationToken).ConfigureAwait(false); + RepositoryInfo repository = GetRepository(); + IGitCliRunner runner = await GetInstalledRunnerCoreAsync(cancellationToken).ConfigureAwait(false); + try + { + await runner.RunAsync( + repository, + ["push", "--progress", "-u", "origin", "HEAD"], + GitCommandOptions.Network, + cancellationToken, + progress).ConfigureAwait(false); + await TryQueueStatusChangedCoreAsync().ConfigureAwait(false); + return new RemoteOpResult.Success(); + } + catch (GitOperationException ex) + { + CaptureRecoverableLock(ex); + return MapRemoteFailure(ex); + } + } + + private async Task PreflightPullCoreAsync( + CheckedOutBranchTip expectedCurrent, + CancellationToken cancellationToken) + { + await EnsureNotConflictedCoreAsync(cancellationToken).ConfigureAwait(false); + ValidateAttachedBranchTip(expectedCurrent, nameof(expectedCurrent)); + RepositoryInfo repository = GetRepository(); + IGitCliRunner runner = await GetInstalledRunnerCoreAsync(cancellationToken) + .ConfigureAwait(false); + await EnsureNoExternalRepositoryOperationAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + CheckedOutBranchTip currentTip = await GetCheckedOutBranchTipCoreAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + if (!EqualsBranchTip(currentTip, expectedCurrent)) + { + throw new InvalidOperationException( + "The checked-out branch changed before the pull preflight started."); + } + + bool hasOrigin = (await GetRemotesCoreAsync(cancellationToken).ConfigureAwait(false)).Count > 0; + PullFetchTarget fetchTarget = await ResolvePullFetchTargetAsync( + repository, + runner, + hasOrigin, + expectedCurrent.RefName, + cancellationToken) + .ConfigureAwait(false); + try + { + await runner.RunAsync( + repository, + fetchTarget.Arguments, + GitCommandOptions.Network, + cancellationToken).ConfigureAwait(false); + } + catch (GitOperationException ex) + { + CaptureRecoverableLock(ex); + return new PullPreflightResult( + MapRemoteFailure(ex), + RequiresTransition: false, + UpstreamCommit: null); + } + + string upstreamRef = fetchTarget.UpstreamRef; + GitCommandResult upstreamResult; + try + { + upstreamResult = await runner.RunAsync( + repository, + ["rev-parse", "--verify", $"{upstreamRef}^{{commit}}"], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + } + catch (GitOperationException ex) + { + CaptureRecoverableLock(ex); + return new PullPreflightResult( + MapRemoteFailure(ex), + RequiresTransition: false, + UpstreamCommit: null); + } + + string upstreamCommit = upstreamResult.Stdout.Trim(); + PullRelation relation = await GetPullRelationAsync( + repository, + runner, + expectedCurrent.Commit, + upstreamCommit, + cancellationToken) + .ConfigureAwait(false); + + currentTip = await GetCheckedOutBranchTipCoreAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + if (!EqualsBranchTip(currentTip, expectedCurrent)) + { + throw new InvalidOperationException( + "The checked-out branch changed while the pull preflight was running."); + } + + await EnsureNoExternalRepositoryOperationAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + + return relation switch + { + PullRelation.LocalBehind => new PullPreflightResult( + new RemoteOpResult.Success(), + RequiresTransition: true, + upstreamCommit), + PullRelation.Equal or PullRelation.LocalAhead => new PullPreflightResult( + new RemoteOpResult.Success(), + RequiresTransition: false, + UpstreamCommit: null), + _ => new PullPreflightResult( + new RemoteOpResult.Diverged(), + RequiresTransition: false, + UpstreamCommit: null), + }; + } + + private async Task PullFastForwardCoreAsync( + CheckedOutBranchTip expectedCurrent, + ProjectCheckpoint? checkpoint, + string projectFile, + CancellationToken cancellationToken) + { + await EnsureNotConflictedCoreAsync(cancellationToken).ConfigureAwait(false); + ValidateAttachedBranchTip(expectedCurrent, nameof(expectedCurrent)); + RepositoryInfo repository = GetRepository(); + IGitCliRunner runner = await GetInstalledRunnerCoreAsync(cancellationToken).ConfigureAwait(false); + await EnsureNoExternalRepositoryOperationAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + EnsureWorktreeMutationAllowed(); + CheckedOutBranchTip currentTip = await GetCheckedOutBranchTipCoreAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + if (!EqualsBranchTip(currentTip, expectedCurrent)) + { + throw new InvalidOperationException( + "The checked-out branch changed before the fast-forward pull started."); + } + + WorktreeStateFingerprint? checkpointState = null; + string? checkpointTree = null; + if (checkpoint is null) + { + if (!await IsWholeRepositoryCleanAsync(repository, runner, cancellationToken) + .ConfigureAwait(false)) + { + return new FastForwardPullResult( + new RemoteOpResult.RepositoryDirty(), + expectedCurrent); + } + } + else + { + await ValidateCheckpointAsync(repository, runner, checkpoint, cancellationToken) + .ConfigureAwait(false); + if (!EqualsBranchTip(checkpoint.BaseTip, expectedCurrent)) + { + throw new InvalidOperationException( + "The project checkpoint does not belong to the expected pull tip."); + } + + checkpointState = await CaptureWorktreeStateAsync( + repository, + runner, + expectedCurrent.Commit, + ".", + cancellationToken) + .ConfigureAwait(false); + checkpointTree = await ResolveTreeAsync( + repository, + runner, + checkpoint.Commit, + cancellationToken) + .ConfigureAwait(false); + if (!string.Equals( + checkpointState.Tree, + checkpointTree, + StringComparison.OrdinalIgnoreCase) + || !await IsWholeIndexCleanAsync(repository, runner, cancellationToken) + .ConfigureAwait(false)) + { + return new FastForwardPullResult( + new RemoteOpResult.RepositoryDirty(), + expectedCurrent); + } + } + + bool hasOrigin = (await GetRemotesCoreAsync(cancellationToken).ConfigureAwait(false)).Count > 0; + PullFetchTarget fetchTarget = await ResolvePullFetchTargetAsync( + repository, + runner, + hasOrigin, + expectedCurrent.RefName, + cancellationToken) + .ConfigureAwait(false); + try + { + await runner.RunAsync( + repository, + fetchTarget.Arguments, + GitCommandOptions.Network, + cancellationToken).ConfigureAwait(false); + } + catch (GitOperationException ex) + { + CaptureRecoverableLock(ex); + return new FastForwardPullResult(MapRemoteFailure(ex), expectedCurrent); + } + + string upstreamRef = fetchTarget.UpstreamRef; + GitCommandResult upstreamResult; + try + { + upstreamResult = await runner.RunAsync( + repository, + ["rev-parse", "--verify", $"{upstreamRef}^{{commit}}"], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + } + catch (GitOperationException ex) + { + CaptureRecoverableLock(ex); + return new FastForwardPullResult(MapRemoteFailure(ex), expectedCurrent); + } + + string upstreamCommit = upstreamResult.Stdout.Trim(); + PullRelation relation = await GetPullRelationAsync( + repository, + runner, + expectedCurrent.Commit, + upstreamCommit, + cancellationToken) + .ConfigureAwait(false); + currentTip = await GetCheckedOutBranchTipCoreAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + if (!EqualsBranchTip(currentTip, expectedCurrent)) + { + throw new InvalidOperationException( + "The checked-out branch changed while the fast-forward pull was being prepared."); + } + + if (relation == PullRelation.Diverged) + { + return new FastForwardPullResult(new RemoteOpResult.Diverged(), expectedCurrent); + } + + if (relation == PullRelation.LocalAhead + || relation == PullRelation.Equal && checkpoint is null) + { + return new FastForwardPullResult(new RemoteOpResult.Success(), expectedCurrent); + } + + string? ignoredCollision = await FindIgnoredIncomingPathAsync( + repository, + runner, + expectedCurrent.Commit, + upstreamCommit, + cancellationToken) + .ConfigureAwait(false); + if (ignoredCollision is not null) + { + return new FastForwardPullResult( + new RemoteOpResult.Failed( + $"The pull would overwrite the ignored path '{ignoredCollision}'."), + expectedCurrent); + } + + await EnsureNoExternalRepositoryOperationAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + + if (checkpoint is not null) + { + return await PullCheckpointedProjectCoreAsync( + repository, + runner, + expectedCurrent, + upstreamCommit, + checkpoint, + checkpointState!, + checkpointTree!, + projectFile, + cancellationToken) + .ConfigureAwait(false); + } + + WorktreeStateFingerprint expectedWorktree = await CaptureWorktreeStateAsync( + repository, + runner, + expectedCurrent.Commit, + ".", + cancellationToken) + .ConfigureAwait(false); + string expectedTree = await ResolveTreeAsync( + repository, + runner, + expectedCurrent.Commit, + cancellationToken) + .ConfigureAwait(false); + currentTip = await GetCheckedOutBranchTipCoreAsync(repository, runner, cancellationToken) + .ConfigureAwait(false); + if (!EqualsBranchTip(currentTip, expectedCurrent)) + { + throw new InvalidOperationException( + "The checked-out branch changed while the fast-forward pull was being prepared."); + } + + if (!string.Equals(expectedWorktree.Tree, expectedTree, StringComparison.OrdinalIgnoreCase) + || !await IsWholeRepositoryCleanAsync(repository, runner, cancellationToken) + .ConfigureAwait(false)) + { + return new FastForwardPullResult( + new RemoteOpResult.RepositoryDirty(), + expectedCurrent); + } + + ignoredCollision = await FindIgnoredIncomingPathAsync( + repository, + runner, + expectedCurrent.Commit, + upstreamCommit, + cancellationToken) + .ConfigureAwait(false); + if (ignoredCollision is not null) + { + return new FastForwardPullResult( + new RemoteOpResult.Failed( + $"The pull would overwrite the ignored path '{ignoredCollision}'."), + expectedCurrent); + } + + cancellationToken.ThrowIfCancellationRequested(); + await EnsureNoExternalRepositoryOperationAsync( + repository, + runner, + CancellationToken.None) + .ConfigureAwait(false); + var pulledTip = new CheckedOutBranchTip(expectedCurrent.RefName, upstreamCommit); + TreeTransitionResult transitionResult = await ApplyTreeTransitionAsync( + repository, + runner, + expectedCurrent, + pulledTip, + expectedCurrent.Commit, + upstreamCommit, + "pull: fast-forward", + indexPlan: null, + CancellationToken.None, + validatePreparedTarget: () => + ValidateRecoveryProjectFilePhysicalContainment(repository, projectFile)) + .ConfigureAwait(false); + if (transitionResult.Outcome != TreeTransitionOutcome.AppliedTarget) + { + if (transitionResult.Error is GitOperationException operationException) + { + CaptureRecoverableLock(operationException); + } + + RemoteOpResult failure = transitionResult.Outcome switch + { + TreeTransitionOutcome.OwnershipLost => new RemoteOpResult.Failed( + transitionResult.Error?.Message + ?? "The repository changed while the fast-forward pull was being applied."), + TreeTransitionOutcome.RestoredCurrent when transitionResult.Error is GitOperationException gitException + => MapRemoteFailure(gitException), + _ => new RemoteOpResult.Failed( + transitionResult.Error?.Message + ?? "The fast-forward pull could not be applied safely."), + }; + return new FastForwardPullResult( + failure, + transitionResult.ActualTip ?? expectedCurrent, + transitionResult.Outcome switch + { + TreeTransitionOutcome.OwnershipLost => PullTransitionState.OwnershipLost, + TreeTransitionOutcome.RecoveryFailed => PullTransitionState.RecoveryFailed, + _ => PullTransitionState.Unchanged, + }, + pulledTip); + } + + try + { + ValidateRecoveryProjectFilePhysicalContainment(repository, projectFile); + } + catch (Exception ex) + { + return new FastForwardPullResult( + new RemoteOpResult.Failed(ex.Message), + pulledTip, + PullTransitionState.Applied, + pulledTip); + } + + await TryQueueStatusChangedCoreAsync().ConfigureAwait(false); + return new FastForwardPullResult( + new RemoteOpResult.Success(), + pulledTip, + PullTransitionState.Applied, + pulledTip); + } + + private async Task PullCheckpointedProjectCoreAsync( + RepositoryInfo repository, + IGitCliRunner runner, + CheckedOutBranchTip expectedCurrent, + string upstreamCommit, + ProjectCheckpoint checkpoint, + WorktreeStateFingerprint expectedCheckpointState, + string checkpointTree, + string projectFile, + CancellationToken cancellationToken) + { + string mergedTree = await BuildMergedTreeAsync( + repository, + runner, + checkpoint.BaseTip.Commit, + upstreamCommit, + checkpoint.Commit, + cancellationToken) + .ConfigureAwait(false); + GitCommandResult commit = await runner.RunAsync( + repository, + [ + "commit-tree", + mergedTree, + "-p", + upstreamCommit, + "-m", + PullSafetyCommitMessage, + "-m", + "Beutl-Snapshot: safety", + ], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + var safetyTip = new CheckedOutBranchTip(expectedCurrent.RefName, commit.Stdout.Trim()); + + cancellationToken.ThrowIfCancellationRequested(); + PendingPullRecovery recovery = await PersistPendingPullRecoveryCoreAsync( + checkpoint, + safetyTip, + projectFile, + cancellationToken) + .ConfigureAwait(false); + + try + { + await ValidateCheckpointAsync(repository, runner, checkpoint, CancellationToken.None) + .ConfigureAwait(false); + CheckedOutBranchTip ownershipTip = await GetCheckedOutBranchTipCoreAsync( + repository, + runner, + CancellationToken.None) + .ConfigureAwait(false); + WorktreeStateFingerprint ownershipState = await CaptureWorktreeStateAsync( + repository, + runner, + expectedCurrent.Commit, + ".", + CancellationToken.None) + .ConfigureAwait(false); + string? ignoredCollision = await FindIgnoredIncomingPathAsync( + repository, + runner, + expectedCurrent.Commit, + upstreamCommit, + CancellationToken.None) + .ConfigureAwait(false); + if (!EqualsBranchTip(ownershipTip, expectedCurrent)) + { + return new FastForwardPullResult( + new RemoteOpResult.Failed( + "The checked-out branch changed while the checkpointed pull was being prepared."), + ownershipTip, + PullTransitionState.OwnershipLost, + safetyTip, + recovery); + } + + if (ownershipState != expectedCheckpointState + || !string.Equals( + ownershipState.Tree, + checkpointTree, + StringComparison.OrdinalIgnoreCase) + || !await IsWholeIndexCleanAsync(repository, runner, CancellationToken.None) + .ConfigureAwait(false)) + { + return new FastForwardPullResult( + new RemoteOpResult.RepositoryDirty(), + expectedCurrent, + Recovery: recovery); + } + + if (ignoredCollision is not null) + { + return new FastForwardPullResult( + new RemoteOpResult.Failed( + $"The pull would overwrite the ignored path '{ignoredCollision}'."), + expectedCurrent, + Recovery: recovery); + } + + await EnsureNoExternalRepositoryOperationAsync( + repository, + runner, + CancellationToken.None) + .ConfigureAwait(false); + } + catch (Exception ex) + { + return new FastForwardPullResult( + new RemoteOpResult.Failed(ex.Message), + expectedCurrent, + PullTransitionState.RecoveryFailed, + safetyTip, + recovery); + } + + TreeTransitionResult transitionResult; + try + { + transitionResult = await ApplyTreeTransitionAsync( + repository, + runner, + expectedCurrent, + safetyTip, + checkpoint.Commit, + safetyTip.Commit, + "pull: fast-forward with project checkpoint", + new TreeTransitionIndexPlan( + PrepareCommit: checkpoint.Commit, + RestoreCommit: expectedCurrent.Commit), + CancellationToken.None, + validatePreparedTarget: () => + ValidateRecoveryProjectFilePhysicalContainment(repository, projectFile)) + .ConfigureAwait(false); + } + catch (Exception ex) + { + return new FastForwardPullResult( + new RemoteOpResult.Failed(ex.Message), + expectedCurrent, + PullTransitionState.RecoveryFailed, + safetyTip, + recovery); + } + + if (transitionResult.Outcome != TreeTransitionOutcome.AppliedTarget) + { + if (transitionResult.Error is GitOperationException gitException) + { + CaptureRecoverableLock(gitException); + } + return new FastForwardPullResult( + transitionResult.Outcome == TreeTransitionOutcome.OwnershipLost + ? new RemoteOpResult.Failed( + transitionResult.Error?.Message + ?? "The repository changed while the checkpointed pull was being applied.") + : transitionResult.Error is GitOperationException operationException + ? MapRemoteFailure(operationException) + : new RemoteOpResult.Failed( + transitionResult.Error?.Message + ?? "The checkpointed pull could not be applied safely."), + transitionResult.ActualTip ?? expectedCurrent, + transitionResult.Outcome switch + { + TreeTransitionOutcome.OwnershipLost => PullTransitionState.OwnershipLost, + TreeTransitionOutcome.RecoveryFailed => PullTransitionState.RecoveryFailed, + _ => PullTransitionState.Unchanged, + }, + safetyTip, + recovery); + } + + try + { + ValidateRecoveryProjectFilePhysicalContainment(repository, projectFile); + } + catch (Exception ex) + { + return new FastForwardPullResult( + new RemoteOpResult.Failed(ex.Message), + safetyTip, + PullTransitionState.RecoveryFailed, + safetyTip, + recovery); + } + + await TryQueueStatusChangedCoreAsync().ConfigureAwait(false); + return new FastForwardPullResult( + new RemoteOpResult.Success(), + safetyTip, + PullTransitionState.Applied, + safetyTip, + recovery); + } + + private async Task InitializeCoreAsync( + InitOptions options, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(options.TargetRepository); + string projectRoot = options.TargetRepository.ProjectRoot; + + (GitAvailability availability, IGitCliRunner? nullableRunner) + = await GetGitRuntimeCoreAsync(cancellationToken).ConfigureAwait(false); + if (availability.State != GitAvailabilityState.Installed || nullableRunner is null) + { + throw new InvalidOperationException("Git is not available."); + } + + IGitCliRunner runner = nullableRunner; + RepositoryInfo? discoveredRepository = Directory.Exists(projectRoot) + ? await DiscoverRepositoryCoreAsync( + projectRoot, + runner, + cancellationToken) + .ConfigureAwait(false) + : null; + RepositoryInfo repository; + if (discoveredRepository is { IsNestedInForeignRepo: true }) + { + if (!MatchesRepositorySelection(discoveredRepository, options.TargetRepository)) + { + throw new EnclosingRepositoryConsentRequiredException(discoveredRepository); + } + + repository = discoveredRepository; + } + else if (discoveredRepository is not null) + { + if (!MatchesRepositorySelection(discoveredRepository, options.TargetRepository)) + { + throw new InvalidOperationException( + "The selected repository does not match the repository containing the project."); + } + + repository = discoveredRepository; + } + else + { + if (options.TargetRepository.IsNestedInForeignRepo) + { + throw new InvalidOperationException( + "The selected existing repository no longer contains the project."); + } + + repository = options.TargetRepository; + } + + ValidateProjectSnapshotLayout(repository.ProjectRoot); + + if (Repository is not null + && !string.Equals(Repository.ProjectRoot, projectRoot, PathComparison) + && !MatchesRepositorySelection(Repository, repository)) + { + throw new InvalidOperationException( + "This service is already associated with a different project."); + } + + GitIdentity? identity = options.Identity; + if (identity is not null) + { + ValidateIdentity(identity); + } + else if (Directory.Exists(repository.RepoRoot)) + { + identity = await GetIdentityCoreAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + } + + if (identity is null) + { + throw new GitIdentityRequiredException(); + } + + EnsureHygienePathsAreSafe(repository); + if (discoveredRepository is not null) + { + await EnsureInitializationPreflightCoreAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + } + else + { + string? ignoredPath = await FindIgnoredRequiredProjectPathBeforeInitAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + ThrowIfRequiredProjectPathIgnored(ignoredPath); + } + + if (discoveredRepository is null) + { + Directory.CreateDirectory(projectRoot); + Repository = repository; + await runner.RunAsync( + repository, + ["init"], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + await runner.RunAsync( + repository, + ["symbolic-ref", "HEAD", "refs/heads/main"], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + + RepositoryInfo? initializedRepository = await DiscoverRepositoryCoreAsync( + projectRoot, + runner, + cancellationToken) + .ConfigureAwait(false); + if (initializedRepository is not null + && !MatchesRepositorySelection( + initializedRepository, + options.TargetRepository)) + { + throw new InvalidOperationException( + "The initialized repository could not be resolved safely."); + } + + if (initializedRepository is not null) + { + repository = initializedRepository; + Repository = repository; + } + } + else + { + Repository = repository; + } + + if (options.Identity is not null) + { + await SetLocalIdentityCoreAsync( + repository, + runner, + options.Identity, + cancellationToken) + .ConfigureAwait(false); + } + + string ignorePath = Path.Combine(repository.ProjectRoot, ".gitignore"); + string attributesPath = Path.Combine(repository.ProjectRoot, ".gitattributes"); + HygieneFileSnapshot originalIgnore = await ReadHygieneFileSnapshotAsync( + ignorePath, + cancellationToken) + .ConfigureAwait(false); + HygieneFileSnapshot originalAttributes = await ReadHygieneFileSnapshotAsync( + attributesPath, + cancellationToken) + .ConfigureAwait(false); + HygieneFileSnapshot? initializedIgnore = null; + HygieneFileSnapshot? initializedAttributes = null; + string? branchRef = null; + string? originalBranchTip = null; + string? originalIndexTree = null; + string? reflogAction = null; + bool indexMayHaveChanged = false; + bool commitAttempted = false; + bool useLfs = options.UseLfsWhenAvailable && availability.LfsInstalled; + try + { + if (useLfs) + { + useLfs = await TryInstallLfsLocallyAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + } + + await EnsureLinesAsync( + ignorePath, + s_gitIgnoreLines, + cancellationToken) + .ConfigureAwait(false); + initializedIgnore = await ReadHygieneFileSnapshotAsync( + ignorePath, + CancellationToken.None) + .ConfigureAwait(false); + await EnsureAttributesAsync( + attributesPath, + useLfs, + cancellationToken) + .ConfigureAwait(false); + initializedAttributes = await ReadHygieneFileSnapshotAsync( + attributesPath, + CancellationToken.None) + .ConfigureAwait(false); + + WorkspaceStatus status = await GetStatusCoreAsync( + repository, + runner, + cancellationToken, + CreateSnapshotExcludePathspecs(repository)) + .ConfigureAwait(false); + if (!status.IsClean) + { + await RaiseLargeMediaNoticeIfNeededAsync( + repository, + runner, + status, + cancellationToken) + .ConfigureAwait(false); + await EnsureNoExternalRepositoryOperationAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + branchRef = await GetAttachedBranchRefCoreAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + originalBranchTip = await TryResolveCommitAsync( + repository, + runner, + branchRef, + cancellationToken) + .ConfigureAwait(false); + GitCommandResult originalIndex = await runner.RunAsync( + repository, + ["write-tree"], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + originalIndexTree = originalIndex.Stdout.Trim(); + string currentBranchRef = await GetAttachedBranchRefCoreAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + string? currentBranchTip = await TryResolveCommitAsync( + repository, + runner, + currentBranchRef, + cancellationToken) + .ConfigureAwait(false); + if (!string.Equals(currentBranchRef, branchRef, StringComparison.Ordinal) + || !string.Equals( + currentBranchTip, + originalBranchTip, + StringComparison.OrdinalIgnoreCase)) + { + throw new ProjectCheckpointStateChangedException(); + } + + await EnsureNoExternalRepositoryOperationAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + reflogAction = $"beutl-initialize/{Guid.NewGuid():N}"; + indexMayHaveChanged = true; + IReadOnlyList snapshotExcludes = CreateSnapshotExcludePathspecs(repository); + var addArguments = new List + { + "add", + "-A", + "--", + CreateSnapshotBasePathspec(repository), + }; + addArguments.AddRange(snapshotExcludes); + await runner.RunAsync( + repository, + addArguments, + new GitCommandOptions(GitCommandExecutionKind.LocalWithLfs) + { + UseLiteralPathspecs = false, + }, + cancellationToken).ConfigureAwait(false); + commitAttempted = true; + var commitArguments = new List + { + "-c", + "core.logAllRefUpdates=true", + "commit", + "--no-gpg-sign", + "-m", + "beutl: initialize version control", + "-m", + "Beutl-Snapshot: init", + "--", + CreateSnapshotCommitPathspec(repository), + }; + commitArguments.AddRange(snapshotExcludes); + await runner.RunAsync( + repository, + commitArguments, + new GitCommandOptions( + GitCommandExecutionKind.Local, + new Dictionary + { + ["GIT_REFLOG_ACTION"] = reflogAction, + }) + { + UseLiteralPathspecs = false, + }, + cancellationToken).ConfigureAwait(false); + } + + } + catch (Exception operationException) + { + bool durableCommitRecorded = false; + if (commitAttempted) + { + string? durableCommit; + try + { + durableCommit = await TryFindCommitByReflogActionAsync( + repository, + runner, + branchRef!, + reflogAction!) + .ConfigureAwait(false); + } + catch (Exception observationException) + { + throw new AggregateException( + "The initial snapshot failed and its durable commit result could not be observed. The initialization state was left unchanged.", + operationException, + observationException); + } + + if (durableCommit is not null) + { + durableCommitRecorded = true; + LogWarningBestEffort( + operationException, + "Git reported an initial snapshot failure after its durable commit was recorded."); + } + } + + if (!durableCommitRecorded && indexMayHaveChanged) + { + string observedBranchRef; + string? observedBranchTip; + try + { + observedBranchRef = await GetAttachedBranchRefCoreAsync( + repository, + runner, + CancellationToken.None) + .ConfigureAwait(false); + observedBranchTip = await TryResolveCommitAsync( + repository, + runner, + observedBranchRef, + CancellationToken.None) + .ConfigureAwait(false); + } + catch (Exception observationException) + { + throw new AggregateException( + "The initial snapshot failed and the current branch tip could not be observed. The initialization state was left unchanged.", + operationException, + observationException); + } + + if (!string.Equals(observedBranchRef, branchRef, StringComparison.Ordinal) + || !string.Equals( + observedBranchTip, + originalBranchTip, + StringComparison.OrdinalIgnoreCase)) + { + throw new AggregateException( + "The initial snapshot failed, but the branch tip changed before its durable result could be identified. The initialization state was left unchanged.", + operationException, + new InvalidOperationException( + $"Expected branch '{branchRef}' at '{originalBranchTip ?? ""}', but observed '{observedBranchRef}' at '{observedBranchTip ?? ""}'.")); + } + } + + if (!durableCommitRecorded) + { + try + { + await RestoreFailedInitializationAsync( + repository, + runner, + indexMayHaveChanged ? originalIndexTree : null, + ignorePath, + originalIgnore, + initializedIgnore, + attributesPath, + originalAttributes, + initializedAttributes) + .ConfigureAwait(false); + } + catch (Exception restoreException) + { + throw new AggregateException( + "Version-control initialization failed and the prior index or repository hygiene state could not be restored.", + operationException, + restoreException); + } + + throw; + } + } + + TryEnsureWatcher(); + await TryRaiseLfsQuotaNoticeIfNeededAsync( + repository, + runner).ConfigureAwait(false); + await TryQueueStatusChangedCoreAsync().ConfigureAwait(false); + } + + private async Task EnsureInitializationPreflightCoreAsync( + RepositoryInfo repository, + IGitCliRunner runner, + CancellationToken cancellationToken) + { + EnsureHygienePathsAreSafe(repository); + await GetAttachedBranchRefCoreAsync(repository, runner, cancellationToken) + .ConfigureAwait(false); + await EnsureRepositoryStatusAndIgnorePreflightCoreAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + } + + private async Task EnsureRepositoryHygienePreflightCoreAsync( + RepositoryInfo repository, + IGitCliRunner runner, + CancellationToken cancellationToken) + { + EnsureHygienePathsAreSafe(repository); + await GetCheckedOutBranchTipCoreAsync(repository, runner, cancellationToken) + .ConfigureAwait(false); + await EnsureRepositoryStatusAndIgnorePreflightCoreAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + } + + private async Task EnsureRepositoryStatusAndIgnorePreflightCoreAsync( + RepositoryInfo repository, + IGitCliRunner runner, + CancellationToken cancellationToken) + { + WorkspaceStatus status = await GetStatusCoreAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + ThrowIfConflicted(status); + string? ignoredPath = await FindIgnoredRequiredProjectPathAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + ThrowIfRequiredProjectPathIgnored(ignoredPath); + } + + private async Task EnsureRepositoryHygieneCoreAsync( + RepositoryInfo repository, + IGitCliRunner runner, + bool useLfs, + CancellationToken cancellationToken) + { + EnsureHygienePathsAreSafe(repository); + if (useLfs) + { + useLfs = await TryInstallLfsLocallyAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + } + + await EnsureLinesAsync( + Path.Combine(repository.ProjectRoot, ".gitignore"), + s_gitIgnoreLines, + cancellationToken).ConfigureAwait(false); + await EnsureAttributesAsync( + Path.Combine(repository.ProjectRoot, ".gitattributes"), + useLfs, + cancellationToken).ConfigureAwait(false); + } + + private async Task TryInstallLfsLocallyAsync( + RepositoryInfo repository, + IGitCliRunner runner, + CancellationToken cancellationToken) + { + try + { + await runner.RunAsync( + repository, + ["lfs", "install", "--local"], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + return true; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (GitOperationException ex) + { + LogWarningBestEffort( + ex, + "Git LFS could not be enabled locally; continuing without Beutl-managed LFS rules."); + return false; + } + } + + private static async Task DiscoverRepositoryCoreAsync( + string projectRoot, + IGitCliRunner runner, + CancellationToken cancellationToken) + { + string normalizedProjectRoot = Path.TrimEndingDirectorySeparator( + Path.GetFullPath(projectRoot)); + if (normalizedProjectRoot.Any(char.IsControl)) + { + throw new ArgumentException( + "Repository discovery does not support control characters in project paths.", + nameof(projectRoot)); + } + + var discoveryContext = new RepositoryInfo(normalizedProjectRoot, normalizedProjectRoot); + try + { + GitCommandResult rootResult = await runner.RunAsync( + discoveryContext, + ["rev-parse", "--show-toplevel"], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + GitCommandResult prefixResult = await runner.RunAsync( + discoveryContext, + ["rev-parse", "--show-prefix"], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + string root = ParseRepositoryDiscoveryPath( + rootResult.Stdout, + allowEmpty: false, + description: "repository root"); + string prefix = ParseRepositoryDiscoveryPath( + prefixResult.Stdout, + allowEmpty: true, + description: "project prefix"); + string repoRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root)); + string resolvedProjectRoot = GetDiscoveredProjectRoot( + repoRoot, + prefix); + if (!RepositoryPathComparer.AreEquivalent( + resolvedProjectRoot, + normalizedProjectRoot)) + { + throw new InvalidOperationException( + "Git repository discovery returned a project root that does not match the requested path."); + } + + return new RepositoryInfo(repoRoot, resolvedProjectRoot); + } + catch (GitOperationException ex) when (IsNotRepositoryFailure(ex)) + { + return null; + } + } + + private static string ParseRepositoryDiscoveryPath( + string stdout, + bool allowEmpty, + string description) + { + if (stdout.Length == 0) + { + if (allowEmpty) + { + return string.Empty; + } + + throw new InvalidOperationException( + $"Git repository discovery returned an empty {description}."); + } + + if (!stdout.EndsWith('\n')) + { + throw new InvalidOperationException( + $"Git repository discovery returned an invalid {description} record."); + } + + string value = stdout[..^1]; + if (value.EndsWith('\r')) + { + value = value[..^1]; + } + + if ((!allowEmpty && value.Length == 0) || value.Any(char.IsControl)) + { + throw new InvalidOperationException( + $"Git repository discovery returned an invalid {description}."); + } + + return value; + } + + private static string GetDiscoveredProjectRoot(string repoRoot, string prefix) + { + string normalizedPrefix = NormalizeGitPath(prefix); + if (Path.IsPathFullyQualified(normalizedPrefix) + || normalizedPrefix + .Split('/', StringSplitOptions.RemoveEmptyEntries) + .Contains("..", StringComparer.Ordinal)) + { + throw new InvalidOperationException( + "Git repository discovery returned an invalid project prefix."); + } + + string platformPrefix = normalizedPrefix.Replace('/', Path.DirectorySeparatorChar); + return Path.TrimEndingDirectorySeparator(Path.GetFullPath( + Path.Combine(repoRoot, platformPrefix))); + } + + private static bool MatchesRepositorySelection( + RepositoryInfo discovered, + RepositoryInfo selected) + { + return discovered.IsNestedInForeignRepo == selected.IsNestedInForeignRepo + && RepositoryPathComparer.AreEquivalent( + discovered.RepoRoot, + selected.RepoRoot) + && RepositoryPathComparer.AreEquivalent( + discovered.ProjectRoot, + selected.ProjectRoot); + } + + private async Task CommitAllCoreAsync( + string message, + SnapshotKind kind, + CancellationToken cancellationToken) + { + RepositoryInfo repository = GetRepository(); + await EnsureNotConflictedCoreAsync(cancellationToken).ConfigureAwait(false); + try + { + ValidateProjectSnapshotLayout(repository.ProjectRoot); + } + catch (Exception ex) when (ex is not OperationCanceledException + and not OutOfMemoryException) + { + // Git can begin a merge after the initial status check and write conflict + // markers before the project graph is deserialized. Prefer the conflict + // guidance when that race is observed, but preserve unrelated parse errors. + try + { + await EnsureNotConflictedCoreAsync(cancellationToken).ConfigureAwait(false); + } + catch (VersionControlConflictedException) + { + throw; + } + catch (OperationCanceledException) + { + throw; + } + catch (OutOfMemoryException) + { + throw; + } + catch (Exception) + { + } + + throw; + } + IGitCliRunner runner = await GetInstalledRunnerCoreAsync(cancellationToken).ConfigureAwait(false); + string branchRef = await GetAttachedBranchRefCoreAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + string? ignoredPath = await FindIgnoredExistingRequiredProjectPathAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + ThrowIfRequiredProjectPathIgnored(ignoredPath); + WorkspaceStatus status = await GetSnapshotStatusCoreAsync(cancellationToken).ConfigureAwait(false); + ThrowIfConflicted(status); + await EnsureNoExternalRepositoryOperationAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + if (status.IsClean) + { + return new CommitResult.NoChanges(); + } + + GitIdentity? identity = await GetIdentityCoreAsync(repository, runner, cancellationToken) + .ConfigureAwait(false); + if (identity is null) + { + if (kind != SnapshotKind.Manual) + { + await RaiseMissingIdentityNoticeIfNeededAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + return new CommitResult.SkippedNoIdentity(); + } + + throw new GitIdentityRequiredException(); + } + + await RaiseLargeMediaNoticeIfNeededAsync( + repository, + runner, + status, + cancellationToken).ConfigureAwait(false); + + string? originalBranchTip = await TryResolveCommitAsync( + repository, + runner, + branchRef, + cancellationToken) + .ConfigureAwait(false); + GitCommandResult originalIndex = await runner.RunAsync( + repository, + ["write-tree"], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + string originalIndexTree = originalIndex.Stdout.Trim(); + string reflogAction = $"beutl-snapshot/{Guid.NewGuid():N}"; + + var arguments = new List + { + "-c", + "core.logAllRefUpdates=true", + "commit", + "-m", + message, + }; + if (kind != SnapshotKind.Manual) + { + arguments.Add("--no-gpg-sign"); + arguments.Add("-m"); + arguments.Add($"Beutl-Snapshot: {kind.ToString().ToLowerInvariant()}"); + } + + arguments.Add("--"); + arguments.Add(CreateSnapshotCommitPathspec(repository)); + IReadOnlyList snapshotExcludes = CreateSnapshotExcludePathspecs(repository); + arguments.AddRange(snapshotExcludes); + await EnsureNoExternalRepositoryOperationAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + try + { + var addArguments = new List + { + "-c", + "advice.addIgnoredFile=false", + "add", + "-A", + "--", + CreateSnapshotBasePathspec(repository), + }; + addArguments.AddRange(snapshotExcludes); + await runner.RunAsync( + repository, + addArguments, + new GitCommandOptions(GitCommandExecutionKind.LocalWithLfs) + { + UseLiteralPathspecs = false, + }, + cancellationToken).ConfigureAwait(false); + await runner.RunAsync( + repository, + arguments, + new GitCommandOptions( + GitCommandExecutionKind.Local, + new Dictionary + { + ["GIT_REFLOG_ACTION"] = reflogAction, + }) + { + UseLiteralPathspecs = false, + }, + cancellationToken).ConfigureAwait(false); + } + catch (Exception operationException) + { + string? durableCommit; + try + { + durableCommit = await TryFindCommitByReflogActionAsync( + repository, + runner, + branchRef, + reflogAction) + .ConfigureAwait(false); + } + catch (Exception observationException) + { + throw new AggregateException( + "The snapshot operation failed and its durable commit result could not be observed. The index was left unchanged.", + operationException, + observationException); + } + + if (durableCommit is not null) + { + LogWarningBestEffort( + operationException, + "Git reported a snapshot failure after its durable commit was recorded."); + await TryQueueStatusChangedCoreAsync().ConfigureAwait(false); + return new CommitResult.Committed(new CommitRevision.Known(durableCommit)); + } + + string? observedBranchTip; + try + { + observedBranchTip = await TryResolveCommitAsync( + repository, + runner, + branchRef, + CancellationToken.None) + .ConfigureAwait(false); + } + catch (Exception observationException) + { + throw new AggregateException( + "The snapshot operation failed and the current branch tip could not be observed. The index was left unchanged.", + operationException, + observationException); + } + + if (!string.Equals( + observedBranchTip, + originalBranchTip, + StringComparison.OrdinalIgnoreCase)) + { + throw new AggregateException( + "The snapshot operation failed, but the branch tip changed before its durable result could be identified. The index was left unchanged.", + operationException, + new InvalidOperationException( + $"Expected branch tip '{originalBranchTip ?? ""}', but observed '{observedBranchTip ?? ""}'.")); + } + + try + { + await ResetIndexAsync( + repository, + runner, + originalIndexTree, + repository.Pathspec) + .ConfigureAwait(false); + } + catch (Exception restoreException) + { + throw new AggregateException( + "The snapshot staging or commit failed and the original index could not be restored.", + operationException, + restoreException); + } + + throw; + } + + // An external client can move HEAD between capturing the branch and running the commit, in + // which case Git records the snapshot on whichever branch is current. Confirm the captured + // branch still owns HEAD and grew from the tip it was captured at before reporting success. + await EnsureSnapshotLandedOnCapturedBranchAsync( + repository, + runner, + branchRef, + originalBranchTip) + .ConfigureAwait(false); + + CommitResult result = await ResolveCommittedResultAsync(repository, runner) + .ConfigureAwait(false); + await TryQueueStatusChangedCoreAsync().ConfigureAwait(false); + return result; + } + + private static async Task EnsureSnapshotLandedOnCapturedBranchAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string branchRef, + string? originalBranchTip) + { + GitCommandResult head = await runner.RunAsync( + repository, + ["symbolic-ref", "--quiet", "HEAD"], + GitCommandOptions.Local, + CancellationToken.None).ConfigureAwait(false); + string checkedOutRef = head.Stdout.Trim(); + if (!string.Equals(checkedOutRef, branchRef, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"The snapshot was committed while '{checkedOutRef}' was checked out instead of the captured branch '{branchRef}'."); + } + + if (originalBranchTip is null) + { + return; + } + + GitCommandResult parents = await runner.RunAsync( + repository, + ["rev-list", "--parents", "-1", branchRef], + GitCommandOptions.Local, + CancellationToken.None).ConfigureAwait(false); + string[] revisions = parents.Stdout.Split( + ' ', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (revisions.Length < 2 + || !revisions + .Skip(1) + .Any(parent => string.Equals( + parent, + originalBranchTip, + StringComparison.OrdinalIgnoreCase))) + { + throw new InvalidOperationException( + $"The captured branch '{branchRef}' no longer grows from the tip '{originalBranchTip}' the snapshot was built on."); + } + } + + private static async Task TryFindCommitByReflogActionAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string branchRef, + string reflogAction) + { + try + { + GitCommandResult result = await runner.RunAsync( + repository, + [ + "log", + "-g", + "-1", + "--format=%H", + $"--grep-reflog={reflogAction}:", + branchRef, + ], + GitCommandOptions.Local, + CancellationToken.None).ConfigureAwait(false); + string commit = result.Stdout.Trim(); + return string.IsNullOrEmpty(commit) ? null : commit; + } + catch (GitOperationException ex) when (ex.ExitCode is 1 or 128) + { + return null; + } + } + + private async Task ResolveCommittedResultAsync( + RepositoryInfo repository, + IGitCliRunner runner) + { + try + { + GitCommandResult revParse = await runner.RunAsync( + repository, + ["rev-parse", "HEAD"], + GitCommandOptions.Local, + CancellationToken.None).ConfigureAwait(false); + string sha = revParse.Stdout.Trim(); + if (string.IsNullOrEmpty(sha)) + { + throw new InvalidOperationException( + "Git did not report the revision created by the successful commit."); + } + + return new CommitResult.Committed(new CommitRevision.Known(sha)); + } + catch (Exception ex) + { + LogWarningBestEffort( + ex, + "Failed to resolve the revision created by a successful Git commit."); + return new CommitResult.Committed(new CommitRevision.Unavailable()); + } + } + + private async Task EnsureNotConflictedCoreAsync(CancellationToken cancellationToken) + { + RepositoryInfo repository = GetRepository(); + IGitCliRunner runner = await GetInstalledRunnerCoreAsync(cancellationToken) + .ConfigureAwait(false); + WorkspaceStatus status = await GetStatusCoreAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + ThrowIfConflicted(status); + } + + private static async Task EnsureNoExternalRepositoryOperationAsync( + RepositoryInfo repository, + IGitCliRunner runner, + CancellationToken cancellationToken) + { + var arguments = new List { "rev-parse" }; + foreach (string operationRef in s_repositoryOperationRefs) + { + arguments.Add("--git-path"); + arguments.Add(operationRef); + } + + GitCommandResult result = await runner.RunAsync( + repository, + arguments, + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + string stdout = result.Stdout.Replace("\r\n", "\n", StringComparison.Ordinal); + if (!stdout.EndsWith('\n')) + { + throw new InvalidOperationException( + "Git returned an invalid repository-operation path list."); + } + + string[] paths = stdout[..^1].Split('\n'); + if (paths.Length != s_repositoryOperationRefs.Length + || paths.Any(static path => path.Length == 0 || path.Any(char.IsControl))) + { + throw new InvalidOperationException( + "Git returned an invalid repository-operation path list."); + } + + foreach (string path in paths) + { + string fullPath = Path.GetFullPath( + Path.IsPathFullyQualified(path) + ? path + : Path.Combine(repository.RepoRoot, path)); + if (RepositoryOperationPathExists(fullPath)) + { + throw new VersionControlConflictedException( + Strings.VersionControl_ConflictGuidance); + } + } + } + + private static bool RepositoryOperationPathExists(string path) + { + try + { + _ = File.GetAttributes(path); + return true; + } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) + { + return false; + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or NotSupportedException) + { + throw new InvalidOperationException( + $"The Git repository-operation path '{path}' could not be inspected safely.", + ex); + } + } + + private static void ThrowIfConflicted(WorkspaceStatus status) + { + if (status.HasConflicts) + { + throw new VersionControlConflictedException(Strings.VersionControl_ConflictGuidance); + } + } + + private static SnapshotKind ParseSnapshotKind(string trailer) + { + return trailer.Trim().ToLowerInvariant() switch + { + "save" => SnapshotKind.Save, + "close" => SnapshotKind.Close, + "safety" => SnapshotKind.Safety, + "restore" => SnapshotKind.Restore, + "recovery" => SnapshotKind.Recovery, + "init" => SnapshotKind.Init, + _ => SnapshotKind.Manual, + }; + } + + private static bool IsNotRepositoryFailure(GitOperationException exception) + { + return exception.Stderr.Contains( + "not a git repository", + StringComparison.OrdinalIgnoreCase) + || exception.Stderr.Contains( + "not in a git directory", + StringComparison.OrdinalIgnoreCase); + } + + private static bool IsMissingRemoteFailure(GitOperationException exception) + { + return exception.Stderr.Contains( + "No such remote", + StringComparison.OrdinalIgnoreCase) + || exception.Stderr.Contains( + "does not appear to be a git repository", + StringComparison.OrdinalIgnoreCase); + } + + internal static RemoteOpResult MapRemoteFailure(GitOperationException exception) + { + string stderr = exception.Stderr; + if (ContainsAny( + stderr, + "non-fast-forward", + "not possible to fast-forward", + "fetch first", + "divergent branches", + "[rejected]")) + { + return new RemoteOpResult.Diverged(); + } + + if (ContainsAny( + stderr, + "authentication failed", + "permission denied", + "could not read username", + "publickey", + "access denied", + "authorization failed")) + { + return new RemoteOpResult.AuthFailed(Strings.VersionControl_AuthenticationFailed); + } + + if (ContainsAny( + stderr, + "could not resolve host", + "failed to connect", + "network is unreachable", + "connection timed out", + "connection refused", + "could not read from remote repository")) + { + return new RemoteOpResult.Offline(); + } + + return new RemoteOpResult.Failed(stderr); + } + + private static bool ContainsAny(string value, params string[] candidates) + { + foreach (string candidate in candidates) + { + if (value.Contains(candidate, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static FileChangeStatus MapNameStatus(char status) + { + return status switch + { + 'A' => FileChangeStatus.Added, + 'D' => FileChangeStatus.Deleted, + _ => FileChangeStatus.Modified, + }; + } + + private static string ValidateDiffPath(RepositoryInfo repository, string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + string normalized = NormalizeGitPath(path); + if (Path.IsPathFullyQualified(path) + || normalized.StartsWith("/", StringComparison.Ordinal) + || normalized.Split('/').Any(static segment => segment == "..")) + { + throw new ArgumentException("The diff path must be repository-relative.", nameof(path)); + } + + if (repository.Pathspec != "." + && !string.Equals(normalized, repository.Pathspec, StringComparison.Ordinal) + && !normalized.StartsWith($"{repository.Pathspec}/", StringComparison.Ordinal)) + { + throw new ArgumentException( + "The diff path must be inside the project pathspec.", + nameof(path)); + } + + return normalized; + } + + private static string NormalizeGitPath(string path) + => OperatingSystem.IsWindows() ? path.Replace('\\', '/') : path; + + private void EnsureWorktreeMutationAllowed() + { + if (!_isWorktreeMutationAllowed()) + { + throw new InvalidOperationException( + "The project must be closed before changing version-controlled project files."); + } + } + + private async Task GetInstalledRunnerCoreAsync(CancellationToken cancellationToken) + { + (GitAvailability availability, IGitCliRunner? runner) + = await GetGitRuntimeCoreAsync(cancellationToken).ConfigureAwait(false); + if (availability.State != GitAvailabilityState.Installed || runner is null) + { + throw new InvalidOperationException("Git is not available."); + } + + return runner; + } + + private static async Task GetIdentityCoreAsync( + RepositoryInfo repository, + IGitCliRunner runner, + CancellationToken cancellationToken) + { + string? name = await TryGetConfigValueAsync( + repository, + runner, + "user.name", + cancellationToken).ConfigureAwait(false); + string? email = await TryGetConfigValueAsync( + repository, + runner, + "user.email", + cancellationToken).ConfigureAwait(false); + return string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(email) + ? null + : new GitIdentity(name, email); + } + + private async Task SetLocalIdentityCoreAsync( + RepositoryInfo repository, + IGitCliRunner runner, + GitIdentity identity, + CancellationToken cancellationToken) + { + await UpdateLocalConfigAtomicallyAsync( + repository, + runner, + async (stagingPath, updateCancellation) => + { + await runner.RunAsync( + repository, + ["config", "--file", stagingPath, "--replace-all", "user.name", identity.Name], + GitCommandOptions.Local, + updateCancellation).ConfigureAwait(false); + await runner.RunAsync( + repository, + ["config", "--file", stagingPath, "--replace-all", "user.email", identity.Email], + GitCommandOptions.Local, + updateCancellation).ConfigureAwait(false); + }, + "identity update", + cancellationToken).ConfigureAwait(false); + } + + private async Task UpdateLocalConfigAtomicallyAsync( + RepositoryInfo repository, + IGitCliRunner runner, + Func stageUpdate, + string operationName, + CancellationToken cancellationToken) + { + string configPath = await ResolveGitPathAsync( + repository, + runner, + "config", + cancellationToken) + .ConfigureAwait(false); + string lockPath = configPath + ".lock"; + string configDirectory = Path.GetDirectoryName(configPath) + ?? throw new InvalidOperationException( + "The local Git configuration has no parent directory."); + string stagingPath = Path.Combine( + configDirectory, + $".beutl-config-{Guid.NewGuid():N}.tmp"); + FileStream lockStream; + try + { + lockStream = new FileStream( + lockPath, + new FileStreamOptions + { + Mode = FileMode.CreateNew, + Access = FileAccess.Write, + Share = FileShare.None, + Options = FileOptions.Asynchronous | FileOptions.WriteThrough, + }); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + throw new GitOperationException( + 128, + $"Unable to acquire the local Git configuration lock '{lockPath}': {ex.Message}"); + } + + bool committed = false; + try + { + byte[] originalConfig; + byte[] stagedConfig; + FileAttributes originalAttributes; + UnixFileMode? originalUnixMode = null; + await using (lockStream) + { + EnsureLocalConfigPathIsRegular(configPath); + originalConfig = await File.ReadAllBytesAsync(configPath, cancellationToken) + .ConfigureAwait(false); + originalAttributes = File.GetAttributes(configPath); + if (!OperatingSystem.IsWindows()) + { + originalUnixMode = File.GetUnixFileMode(configPath); + } + + await using (var stagingStream = new FileStream( + stagingPath, + new FileStreamOptions + { + Mode = FileMode.CreateNew, + Access = FileAccess.Write, + Share = FileShare.None, + Options = FileOptions.Asynchronous, + })) + { + await stagingStream.WriteAsync(originalConfig, cancellationToken) + .ConfigureAwait(false); + await stagingStream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + await stageUpdate(stagingPath, cancellationToken).ConfigureAwait(false); + + stagedConfig = await File.ReadAllBytesAsync(stagingPath, cancellationToken) + .ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + byte[] currentConfig = await File.ReadAllBytesAsync( + configPath, + cancellationToken) + .ConfigureAwait(false); + if (!originalConfig.AsSpan().SequenceEqual(currentConfig)) + { + throw new InvalidOperationException( + $"The local Git configuration changed while the {operationName} was staged."); + } + + cancellationToken.ThrowIfCancellationRequested(); + await lockStream.WriteAsync(stagedConfig, CancellationToken.None) + .ConfigureAwait(false); + await lockStream.FlushAsync(CancellationToken.None).ConfigureAwait(false); + lockStream.Flush(flushToDisk: true); + } + + File.SetAttributes(lockPath, originalAttributes); + if (!OperatingSystem.IsWindows() && originalUnixMode is { } unixMode) + { + File.SetUnixFileMode(lockPath, unixMode); + } + + EnsureLocalConfigPathIsRegular(configPath); + byte[] finalConfig = await File.ReadAllBytesAsync( + configPath, + CancellationToken.None) + .ConfigureAwait(false); + if (!originalConfig.AsSpan().SequenceEqual(finalConfig)) + { + throw new InvalidOperationException( + $"The local Git configuration changed before the staged {operationName} was committed."); + } + + File.Move(lockPath, configPath, overwrite: true); + committed = true; + } + finally + { + TryDeleteOwnedLocalConfigFile(stagingPath + ".lock"); + TryDeleteOwnedLocalConfigFile(stagingPath); + if (!committed) + { + try + { + File.Delete(lockPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + LogWarningBestEffort( + ex, + $"Failed to release the local Git configuration lock after a {operationName} failure."); + } + } + } + } + + private void TryDeleteOwnedLocalConfigFile(string path) + { + try + { + File.Delete(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + LogWarningBestEffort( + ex, + "Failed to remove an owned temporary Git configuration file."); + } + } + + private static void EnsureLocalConfigPathIsRegular(string path) + { + var file = new FileInfo(path); + file.Refresh(); + if (!file.Exists + || file.LinkTarget is not null + || (file.Attributes & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidOperationException( + $"The local Git configuration path '{path}' is not a regular file."); + } + } + + private static void ValidateIdentity(GitIdentity identity) + { + ArgumentException.ThrowIfNullOrWhiteSpace(identity.Name); + ArgumentException.ThrowIfNullOrWhiteSpace(identity.Email); + } + + private async Task RaiseLfsQuotaNoticeIfNeededAsync( + RepositoryInfo repository, + IGitCliRunner runner, + CancellationToken cancellationToken) + { + IReadOnlyList remotes = await GetRemotesCoreAsync(cancellationToken) + .ConfigureAwait(false); + string? remoteUrl = remotes.FirstOrDefault()?.Url; + if (remoteUrl is null) + { + return; + } + + string acknowledgementKey = LfsQuotaNoticeConfigKeyPrefix + + GetConfigKeyHash(repository.Pathspec); + if (!await IsLfsActiveAsync(repository, runner, cancellationToken).ConfigureAwait(false) + || await GetLocalBooleanConfigAsync( + repository, + runner, + acknowledgementKey, + cancellationToken).ConfigureAwait(false)) + { + return; + } + + if (!await PresentPolicyNoticeAsync( + new VersionControlPolicyNotice.LfsRemoteQuota(), + cancellationToken).ConfigureAwait(false)) + { + return; + } + + await SetLocalConfigValueAsync( + repository, + runner, + acknowledgementKey, + "true", + cancellationToken).ConfigureAwait(false); + } + + private async Task RaiseLargeMediaNoticeIfNeededAsync( + RepositoryInfo repository, + IGitCliRunner runner, + WorkspaceStatus status, + CancellationToken cancellationToken) + { + string acknowledgementKey = LargeMediaNoticeConfigKeyPrefix + + GetConfigKeyHash(repository.Pathspec); + (GitAvailability availability, _) = await GetGitRuntimeCoreAsync(cancellationToken) + .ConfigureAwait(false); + if (await GetLocalBooleanConfigAsync( + repository, + runner, + acknowledgementKey, + cancellationToken).ConfigureAwait(false)) + { + return; + } + + long thresholdBytes = Math.Max( + 0L, + (long)_installationLocator.Config.LargeMediaWarningThresholdMb * 1024 * 1024); + var candidates = new List<(FileChange Change, string Path)>(); + foreach (FileChange change in status.Changes) + { + cancellationToken.ThrowIfCancellationRequested(); + string? path = GetLargeMediaPath(repository, change.Path, thresholdBytes); + if (path is not null) + { + candidates.Add((change, path)); + } + } + + HashSet lfsCoveredPaths = availability.LfsInstalled + ? await GetEffectiveLfsPathsAsync( + repository, + runner, + candidates.Select(static candidate => candidate.Change.Path).ToArray(), + cancellationToken) + .ConfigureAwait(false) + : []; + foreach ((FileChange change, string path) in candidates) + { + if (lfsCoveredPaths.Contains(change.Path)) + { + continue; + } + + if (!TryGetFileLength(path, out long sizeBytes) || sizeBytes <= thresholdBytes) + { + continue; + } + + if (!await PresentPolicyNoticeAsync( + new VersionControlPolicyNotice.LargeMediaWithoutLfs( + NormalizeGitPath(Path.GetRelativePath(repository.ProjectRoot, path)), + sizeBytes), + cancellationToken).ConfigureAwait(false)) + { + return; + } + + try + { + await SetLocalConfigValueAsync( + repository, + runner, + acknowledgementKey, + "true", + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + LogWarningBestEffort( + ex, + "Failed to persist the large-media notice acknowledgement."); + } + + return; + } + } + + internal static async Task> GetEffectiveLfsPathsAsync( + RepositoryInfo repository, + IGitCliRunner runner, + IReadOnlyList repoRelativePaths, + CancellationToken cancellationToken) + { + var coveredPaths = new HashSet(StringComparer.Ordinal); + var chunk = new List(); + int expectedOutputBytes = 0; + foreach (string path in repoRelativePaths) + { + int pathOutputBytes = Encoding.UTF8.GetByteCount(path) + 20; + if (chunk.Count > 0 + && pathOutputBytes > MaxLfsAttributeOutputBytes - expectedOutputBytes) + { + LfsAttributeQueryResult result = await QueryEffectiveLfsPathsAsync( + repository, + runner, + chunk, + cancellationToken) + .ConfigureAwait(false); + coveredPaths.UnionWith(result.CoveredPaths); + if (!result.IsComplete) + { + return coveredPaths; + } + + chunk.Clear(); + expectedOutputBytes = 0; + } + + chunk.Add(path); + expectedOutputBytes = pathOutputBytes > MaxLfsAttributeOutputBytes - expectedOutputBytes + ? MaxLfsAttributeOutputBytes + : expectedOutputBytes + pathOutputBytes; + } + + if (chunk.Count > 0) + { + LfsAttributeQueryResult result = await QueryEffectiveLfsPathsAsync( + repository, + runner, + chunk, + cancellationToken) + .ConfigureAwait(false); + coveredPaths.UnionWith(result.CoveredPaths); + } + + return coveredPaths; + } + + private static async Task QueryEffectiveLfsPathsAsync( + RepositoryInfo repository, + IGitCliRunner runner, + IReadOnlyList repoRelativePaths, + CancellationToken cancellationToken) + { + var standardInput = new StringBuilder(); + foreach (string path in repoRelativePaths) + { + standardInput.Append(path).Append('\0'); + } + + GitCommandResult result; + try + { + result = await runner.RunAsync( + repository, + ["check-attr", "--stdin", "-z", "filter"], + new GitCommandOptions( + GitCommandExecutionKind.Local, + MaxStdoutBytes: MaxLfsAttributeOutputBytes, + StandardInput: standardInput.ToString()), + cancellationToken).ConfigureAwait(false); + } + catch (GitOperationException) + { + return new([], false); + } + catch (TimeoutException) + { + return new([], false); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return new([], false); + } + + if (result.ExitCode != 0 || result.Stderr.Length != 0) + { + return new([], false); + } + + var coveredPaths = new HashSet(StringComparer.Ordinal); + int position = 0; + for (int i = 0; i < repoRelativePaths.Count; i++) + { + if (!TryReadNullTerminatedField(result.Stdout, ref position, out string path) + || !TryReadNullTerminatedField(result.Stdout, ref position, out string attribute) + || !TryReadNullTerminatedField(result.Stdout, ref position, out string value)) + { + return new(coveredPaths, false); + } + + if (!string.Equals(path, repoRelativePaths[i], StringComparison.Ordinal) + || !string.Equals(attribute, "filter", StringComparison.Ordinal)) + { + return new(coveredPaths, false); + } + + if (string.Equals(value, "lfs", StringComparison.Ordinal)) + { + coveredPaths.Add(repoRelativePaths[i]); + } + } + + bool isComplete = !result.StdoutTruncated && position == result.Stdout.Length; + return isComplete + ? new(coveredPaths, true) + : new([], false); + } + + private static bool TryReadNullTerminatedField( + string value, + ref int position, + out string field) + { + int end = value.IndexOf('\0', position); + if (end < 0) + { + field = string.Empty; + return false; + } + + field = value[position..end]; + position = end + 1; + return true; + } + + private async Task RaiseMissingIdentityNoticeIfNeededAsync( + RepositoryInfo repository, + IGitCliRunner runner, + CancellationToken cancellationToken) + { + string acknowledgementKey = MissingIdentityNoticeConfigKeyPrefix + + GetConfigKeyHash(repository.Pathspec); + if (await GetLocalBooleanConfigAsync( + repository, + runner, + acknowledgementKey, + cancellationToken).ConfigureAwait(false)) + { + return; + } + + if (!await PresentPolicyNoticeAsync( + new VersionControlPolicyNotice.MissingIdentity(), + cancellationToken).ConfigureAwait(false)) + { + return; + } + + await SetLocalConfigValueAsync( + repository, + runner, + acknowledgementKey, + "true", + cancellationToken).ConfigureAwait(false); + } + + private async Task PresentPolicyNoticeAsync( + VersionControlPolicyNotice notice, + CancellationToken cancellationToken) + { + if (_policyNoticeSink is null) + { + return false; + } + + try + { + await _policyNoticeSink(notice, cancellationToken).ConfigureAwait(false); + return true; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + return false; + } + } + + private static string GetConfigKeyHash(string value) + { + byte[] hash = System.Security.Cryptography.SHA256.HashData( + Encoding.UTF8.GetBytes(value)); + return Convert.ToHexString(hash.AsSpan(0, 8)).ToLowerInvariant(); + } + + private async Task IsLfsActiveAsync( + RepositoryInfo repository, + IGitCliRunner runner, + CancellationToken cancellationToken) + { + (GitAvailability availability, _) = await GetGitRuntimeCoreAsync(cancellationToken) + .ConfigureAwait(false); + if (!availability.LfsInstalled) + { + return false; + } + + string prefix = repository.Pathspec == "." ? string.Empty : repository.Pathspec + "/"; + string[] mediaPaths = GetRequiredProjectRelativePaths(repository.ProjectRoot) + .Where(path => s_mediaExtensions.Contains(Path.GetExtension(path))) + .Select(path => prefix + path) + .ToArray(); + HashSet coveredPaths = await GetEffectiveLfsPathsAsync( + repository, + runner, + mediaPaths, + cancellationToken) + .ConfigureAwait(false); + return coveredPaths.Count > 0; + } + + private static string? GetLargeMediaPath( + RepositoryInfo repository, + string repoRelativePath, + long thresholdBytes) + { + string normalizedPath = NormalizeGitPath(repoRelativePath); + string projectRelativePath; + if (repository.Pathspec == ".") + { + projectRelativePath = normalizedPath; + } + else if (normalizedPath.StartsWith($"{repository.Pathspec}/", StringComparison.Ordinal)) + { + projectRelativePath = normalizedPath[(repository.Pathspec.Length + 1)..]; + } + else + { + return null; + } + + if (!s_mediaExtensions.Contains(Path.GetExtension(projectRelativePath))) + { + return null; + } + + string path = Path.GetFullPath(Path.Combine( + repository.ProjectRoot, + projectRelativePath.Replace('/', Path.DirectorySeparatorChar))); + if (!TryGetFileLength(path, out long length) || length <= thresholdBytes) + { + return null; + } + + return path; + } + + private static bool TryGetFileLength(string path, out long length) + { + try + { + var file = new FileInfo(path); + if (!file.Exists) + { + length = 0; + return false; + } + + length = file.Length; + return true; + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or System.Security.SecurityException) + { + length = 0; + return false; + } + } + + private static async Task GetLocalBooleanConfigAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string key, + CancellationToken cancellationToken) + { + string? value = await TryGetConfigValueAsync( + repository, + runner, + key, + cancellationToken).ConfigureAwait(false); + return bool.TryParse(value, out bool parsed) && parsed; + } + + private static async Task SetLocalConfigValueAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string key, + string value, + CancellationToken cancellationToken) + { + await runner.RunAsync( + repository, + ["config", "--local", key, value], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + } + + private static async Task TryGetConfigValueAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string key, + CancellationToken cancellationToken) + { + try + { + GitCommandResult result = await runner.RunAsync( + repository, + ["config", "--get", key], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + string value = result.Stdout.Trim(); + return string.IsNullOrEmpty(value) ? null : value; + } + catch (GitOperationException ex) when (ex.ExitCode == 1) + { + return null; + } + } + + private async Task EnsureLinesAsync( + string path, + IReadOnlyList requiredLines, + CancellationToken cancellationToken) + { + await UpdateHygieneFileAsync( + path, + lines => + { + foreach (string requiredLine in requiredLines) + { + if (!lines.Contains(requiredLine, StringComparer.Ordinal)) + { + lines.Add(requiredLine); + } + } + + return lines; + }, + cancellationToken).ConfigureAwait(false); + } + + private async Task EnsureAttributesAsync( + string path, + bool useLfs, + CancellationToken cancellationToken) + { + await UpdateHygieneFileAsync( + path, + lines => + { + int? managedBlockIndex = RemoveManagedLfsBlocks(lines); + foreach (string requiredLine in s_textAttributeLines) + { + if (!lines.Contains(requiredLine, StringComparer.Ordinal)) + { + lines.Add(requiredLine); + } + } + + if (useLfs) + { + int insertionIndex = managedBlockIndex is { } existingIndex + ? Math.Min(existingIndex, lines.Count) + : 0; + lines.InsertRange( + insertionIndex, + [ManagedLfsBeginMarker, .. s_lfsAttributeLines, ManagedLfsEndMarker]); + } + + return lines; + }, + cancellationToken).ConfigureAwait(false); + } + + private async Task HasVersionTrackingOptInCoreAsync( + RepositoryInfo repository, + CancellationToken cancellationToken) + { + if (await IsRepositoryHygieneAppliedCoreAsync(repository.ProjectRoot, cancellationToken) + .ConfigureAwait(false)) + { + return true; + } + + // The hygiene files can be deleted or checked out away, so the durable record of an + // earlier opt-in is a snapshot Beutl itself committed for this project. A repository with + // no readable history has none, and asking again is the safe answer. + IGitCliRunner runner = await GetInstalledRunnerCoreAsync(cancellationToken) + .ConfigureAwait(false); + try + { + GitCommandResult result = await runner.RunAsync( + repository, + [ + "log", + "--no-show-signature", + "--max-count=1", + "--format=%H", + "--grep=^Beutl-Snapshot: ", + "HEAD", + "--", + repository.Pathspec, + ], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + return !string.IsNullOrWhiteSpace(result.Stdout); + } + catch (GitOperationException) + { + return false; + } + } + + private static async Task IsRepositoryHygieneAppliedCoreAsync( + string projectRoot, + CancellationToken cancellationToken) + { + string normalizedRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(projectRoot)); + return await HygieneFileContainsLinesAsync( + Path.Combine(normalizedRoot, ".gitignore"), + s_gitIgnoreLines, + cancellationToken) + .ConfigureAwait(false) + && await HygieneFileContainsLinesAsync( + Path.Combine(normalizedRoot, ".gitattributes"), + s_textAttributeLines, + cancellationToken) + .ConfigureAwait(false); + } + + private static async Task HygieneFileContainsLinesAsync( + string path, + IReadOnlyList requiredLines, + CancellationToken cancellationToken) + { + HygieneFileSnapshot snapshot = await ReadHygieneFileSnapshotAsync(path, cancellationToken) + .ConfigureAwait(false); + if (!snapshot.Exists) + { + return false; + } + + List lines = ReadHygieneLines(snapshot.Contents); + return requiredLines.All(required => lines.Contains(required, StringComparer.Ordinal)); + } + + private static int? RemoveManagedLfsBlocks(List lines) + { + int? firstBlockIndex = null; + int index = 0; + while (index < lines.Count) + { + if (!string.Equals(lines[index], ManagedLfsBeginMarker, StringComparison.Ordinal)) + { + index++; + continue; + } + + int end = lines.FindIndex( + index + 1, + static line => string.Equals( + line, + ManagedLfsEndMarker, + StringComparison.Ordinal)); + if (end < 0) + { + index++; + continue; + } + + firstBlockIndex ??= index; + lines.RemoveRange(index, end - index + 1); + } + + return firstBlockIndex; + } + + private static async Task RestoreFailedInitializationAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string? originalIndexTree, + string ignorePath, + HygieneFileSnapshot originalIgnore, + HygieneFileSnapshot? initializedIgnore, + string attributesPath, + HygieneFileSnapshot originalAttributes, + HygieneFileSnapshot? initializedAttributes) + { + var failures = new List(); + if (originalIndexTree is not null) + { + try + { + await ResetIndexAsync( + repository, + runner, + originalIndexTree, + repository.Pathspec) + .ConfigureAwait(false); + } + catch (Exception ex) + { + failures.Add(ex); + } + } + + if (initializedIgnore is not null) + { + try + { + await RestoreHygieneFileIfUnchangedAsync( + ignorePath, + originalIgnore, + initializedIgnore) + .ConfigureAwait(false); + } + catch (Exception ex) + { + failures.Add(ex); + } + } + + if (initializedAttributes is not null) + { + try + { + await RestoreHygieneFileIfUnchangedAsync( + attributesPath, + originalAttributes, + initializedAttributes) + .ConfigureAwait(false); + } + catch (Exception ex) + { + failures.Add(ex); + } + } + + if (failures.Count > 0) + { + throw new AggregateException(failures); + } + } + + private static async Task RestoreHygieneFileIfUnchangedAsync( + string path, + HygieneFileSnapshot original, + HygieneFileSnapshot initialized) + { + HygieneFileSnapshot current = await ReadHygieneFileSnapshotAsync( + path, + CancellationToken.None) + .ConfigureAwait(false); + if (current != initialized || current == original) + { + return; + } + + if (!original.Exists) + { + current = await ReadHygieneFileSnapshotAsync(path, CancellationToken.None) + .ConfigureAwait(false); + if (current == initialized) + { + File.Delete(path); + } + + return; + } + + string temporaryPath = await WriteTemporaryHygieneFileAsync( + path, + original.Contents ?? string.Empty, + original, + CancellationToken.None) + .ConfigureAwait(false); + try + { + current = await ReadHygieneFileSnapshotAsync(path, CancellationToken.None) + .ConfigureAwait(false); + if (current == initialized) + { + File.Move(temporaryPath, path, overwrite: true); + } + } + finally + { + TryDeleteHygieneTemporaryFile(temporaryPath); + } + } + + private async Task UpdateHygieneFileAsync( + string path, + Func, List> updateLines, + CancellationToken cancellationToken) + { + for (int attempt = 0; attempt < MaxHygieneWriteAttempts; attempt++) + { + HygieneFileSnapshot snapshot = await ReadHygieneFileSnapshotAsync( + path, + cancellationToken) + .ConfigureAwait(false); + List lines = ReadHygieneLines(snapshot.Contents); + string contents = string.Join('\n', updateLines(lines)) + '\n'; + if (snapshot.Exists + && string.Equals(snapshot.Contents, contents, StringComparison.Ordinal)) + { + return; + } + + string temporaryPath = await WriteTemporaryHygieneFileAsync( + path, + contents, + snapshot, + cancellationToken) + .ConfigureAwait(false); + try + { + if (_beforeHygieneFileReplace is not null) + { + await _beforeHygieneFileReplace(path, cancellationToken).ConfigureAwait(false); + } + + HygieneFileSnapshot current = await ReadHygieneFileSnapshotAsync( + path, + cancellationToken) + .ConfigureAwait(false); + if (current != snapshot) + { + continue; + } + + cancellationToken.ThrowIfCancellationRequested(); + if (_beforeHygieneFileCommit is not null) + { + await _beforeHygieneFileCommit(path, cancellationToken).ConfigureAwait(false); + } + + HygieneFileSnapshot finalSnapshot = await ReadHygieneFileSnapshotAsync( + path, + cancellationToken) + .ConfigureAwait(false); + if (finalSnapshot != snapshot) + { + continue; + } + + cancellationToken.ThrowIfCancellationRequested(); + try + { + File.Move(temporaryPath, path, overwrite: snapshot.Exists); + } + catch (IOException) when (!snapshot.Exists && File.Exists(path)) + { + continue; + } + + return; + } + finally + { + TryDeleteHygieneTemporaryFile(temporaryPath); + } + } + + throw new InvalidOperationException( + $"Repository hygiene could not update '{path}' because it kept changing."); + } + + private static async Task WriteTemporaryHygieneFileAsync( + string path, + string contents, + HygieneFileSnapshot snapshot, + CancellationToken cancellationToken) + { + string directory = Path.GetDirectoryName(path) + ?? throw new InvalidOperationException( + $"The repository hygiene path '{path}' has no parent directory."); + string temporaryPath = Path.Combine( + directory, + $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp"); + try + { + await using (var stream = new FileStream( + temporaryPath, + new FileStreamOptions + { + Mode = FileMode.CreateNew, + Access = FileAccess.Write, + Share = FileShare.None, + Options = FileOptions.Asynchronous, + })) + await using (var writer = new StreamWriter( + stream, + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false))) + { + await writer.WriteAsync(contents.AsMemory(), cancellationToken) + .ConfigureAwait(false); + } + + CopyHygieneFileMetadata(temporaryPath, snapshot); + return temporaryPath; + } + catch + { + TryDeleteHygieneTemporaryFile(temporaryPath); + throw; + } + } + + private static void CopyHygieneFileMetadata( + string temporaryPath, + HygieneFileSnapshot snapshot) + { + if (snapshot.Attributes is { } attributes) + { + File.SetAttributes(temporaryPath, attributes); + } + + if (!OperatingSystem.IsWindows() && snapshot.UnixMode is { } unixMode) + { + File.SetUnixFileMode(temporaryPath, unixMode); + } + } + + private static async Task ReadHygieneFileSnapshotAsync( + string path, + CancellationToken cancellationToken) + { + EnsureHygienePathIsSafe(path); + try + { + if (!File.Exists(path)) + { + return new HygieneFileSnapshot( + Exists: false, + Contents: null, + Attributes: null, + UnixMode: null); + } + + string contents = await File.ReadAllTextAsync(path, cancellationToken) + .ConfigureAwait(false); + FileAttributes attributes = File.GetAttributes(path); + UnixFileMode? unixMode = null; + if (!OperatingSystem.IsWindows()) + { + unixMode = File.GetUnixFileMode(path); + } + + EnsureHygienePathIsSafe(path); + return new HygieneFileSnapshot( + Exists: true, + contents, + attributes, + unixMode); + } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) + { + EnsureHygienePathIsSafe(path); + return new HygieneFileSnapshot( + Exists: false, + Contents: null, + Attributes: null, + UnixMode: null); + } + } + + private static List ReadHygieneLines(string? contents) + { + if (string.IsNullOrEmpty(contents)) + { + return []; + } + + var lines = new List(); + using var reader = new StringReader(contents); + while (reader.ReadLine() is { } line) + { + lines.Add(line); + } + + return lines; + } + + private static void TryDeleteHygieneTemporaryFile(string path) + { + try + { + File.Delete(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + } + } + + private sealed record HygieneFileSnapshot( + bool Exists, + string? Contents, + FileAttributes? Attributes, + UnixFileMode? UnixMode); + + private static void EnsureHygienePathsAreSafe(RepositoryInfo repository) + { + EnsureHygienePathIsSafe(Path.Combine(repository.ProjectRoot, ".gitignore")); + EnsureHygienePathIsSafe(Path.Combine(repository.ProjectRoot, ".gitattributes")); + } + + private static void EnsureHygienePathIsSafe(string path) + { + try + { + var file = new FileInfo(path); + file.Refresh(); + if (file.LinkTarget is not null + || (file.Exists && (file.Attributes & FileAttributes.ReparsePoint) != 0) + || Directory.Exists(path)) + { + throw new InvalidOperationException( + $"Repository hygiene requires '{path}' to be a regular file."); + } + } + catch (InvalidOperationException) + { + throw; + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or NotSupportedException) + { + throw new InvalidOperationException( + $"The repository hygiene path '{path}' could not be inspected safely.", + ex); + } + } + + private async Task FindIgnoredRequiredProjectPathAsync( + RepositoryInfo repository, + IGitCliRunner runner, + CancellationToken cancellationToken) + { + string prefix = repository.Pathspec == "." ? string.Empty : repository.Pathspec + "/"; + var paths = GetRequiredProjectRelativePaths(repository.ProjectRoot) + .Select(path => prefix + path) + .ToList(); + if (repository.Pathspec != ".") + { + paths.Add(repository.Pathspec + "/"); + } + + return await FindIgnoredPathAsync( + repository, + runner, + paths, + environmentOverrides: null, + includeTrackedFiles: true, + cancellationToken) + .ConfigureAwait(false); + } + + private async Task FindIgnoredExistingRequiredProjectPathAsync( + RepositoryInfo repository, + IGitCliRunner runner, + CancellationToken cancellationToken) + { + IReadOnlyList pathspecs = CreateIgnoredRequiredProjectPathspecs(repository); + GitCommandResult result = await runner.RunAsync( + repository, + [ + "ls-files", + "--others", + "--ignored", + "--exclude-standard", + "-z", + "--", + .. pathspecs, + ], + new GitCommandOptions( + GitCommandExecutionKind.Local, + MaxStdoutBytes: MaxIgnoredRequiredPathOutputBytes, + UseLiteralPathspecs: false), + cancellationToken).ConfigureAwait(false); + if (result.StdoutTruncated + || !HasOnlyExcludedBeutlDirectoryWarnings(repository, result.Stderr)) + { + throw new InvalidOperationException( + "Git could not safely determine whether required project files are ignored."); + } + + string? ignoredPath = GitCliRunner.SplitNullSeparated(result.Stdout).FirstOrDefault(); + if (ignoredPath is not null) + { + return ignoredPath; + } + + string prefix = repository.Pathspec == "." ? string.Empty : repository.Pathspec + "/"; + return await FindIgnoredPathAsync( + repository, + runner, + GetSerializedProjectRelativePaths(repository.ProjectRoot) + .Select(path => prefix + path), + environmentOverrides: null, + includeTrackedFiles: false, + cancellationToken) + .ConfigureAwait(false); + } + + private static bool HasOnlyExcludedBeutlDirectoryWarnings( + RepositoryInfo repository, + string stderr) + { + if (stderr.Length == 0) + { + return true; + } + + if (!stderr.EndsWith('\n')) + { + return false; + } + + const string warningPrefix = "warning: could not open directory '"; + const string pathTerminator = "': "; + int lineStart = 0; + while (lineStart < stderr.Length) + { + int lineEnd = stderr.IndexOf('\n', lineStart); + if (lineEnd < 0) + { + return false; + } + + ReadOnlySpan line = stderr.AsSpan(lineStart, lineEnd - lineStart); + if (!line.IsEmpty && line[^1] == '\r') + { + line = line[..^1]; + } + + if (line.IsEmpty + || !IsExcludedBeutlDirectoryWarning(repository, line, warningPrefix, pathTerminator)) + { + return false; + } + + lineStart = lineEnd + 1; + } + + return true; + } + + private static bool IsExcludedBeutlDirectoryWarning( + RepositoryInfo repository, + ReadOnlySpan line, + string warningPrefix, + string pathTerminator) + { + if (!line.StartsWith(warningPrefix, StringComparison.Ordinal)) + { + return false; + } + + ReadOnlySpan remainder = line[warningPrefix.Length..]; + int terminatorIndex = remainder.IndexOf(pathTerminator, StringComparison.Ordinal); + if (terminatorIndex <= 0) + { + return false; + } + + ReadOnlySpan warningPath = remainder[..terminatorIndex]; + ReadOnlySpan reason = remainder[(terminatorIndex + pathTerminator.Length)..]; + if (reason.IsEmpty + || warningPath.Length < 2 + || warningPath[^1] != '/' + || warningPath[0] == '/') + { + return false; + } + + warningPath = warningPath[..^1]; + foreach (char character in warningPath) + { + if (character is '\'' or '"' or '\\' || char.IsControl(character)) + { + return false; + } + } + + if (reason.Trim().IsEmpty) + { + return false; + } + + foreach (char character in reason) + { + if (character is '\'' or '"' or '\\' || char.IsControl(character)) + { + return false; + } + } + + ReadOnlySpan projectPath = repository.Pathspec.AsSpan(); + if (repository.Pathspec != "." + && (warningPath.Length <= projectPath.Length + || !warningPath[..projectPath.Length].Equals(projectPath, PathComparison) + || warningPath[projectPath.Length] != '/')) + { + return false; + } + + ReadOnlySpan relativePath = repository.Pathspec == "." + ? warningPath + : warningPath[(projectPath.Length + 1)..]; + int componentStart = 0; + bool isInBeutlStateDirectory = false; + while (componentStart < relativePath.Length) + { + int separator = relativePath[componentStart..].IndexOf('/'); + int componentLength = separator < 0 + ? relativePath.Length - componentStart + : separator; + ReadOnlySpan component = relativePath.Slice(componentStart, componentLength); + if (component.IsEmpty || component.SequenceEqual(".") || component.SequenceEqual("..")) + { + return false; + } + + isInBeutlStateDirectory |= component.Equals( + ".beutl", + StringComparison.OrdinalIgnoreCase); + if (separator < 0) + { + return isInBeutlStateDirectory; + } + + componentStart += componentLength + 1; + } + + return false; + } + + private static IReadOnlyList CreateIgnoredRequiredProjectPathspecs( + RepositoryInfo repository) + { + string prefix = repository.Pathspec == "." + ? string.Empty + : EscapeGitGlobPath(repository.Pathspec) + "/"; + var result = new List( + s_ignoredRequiredProjectPathspecSuffixes.Length + + s_ignoredOptionalProjectPathspecSuffixes.Length); + foreach (string suffix in s_ignoredRequiredProjectPathspecSuffixes) + { + result.Add($":(top,glob){prefix}{suffix}"); + } + + foreach (string suffix in s_ignoredOptionalProjectPathspecSuffixes) + { + result.Add($":(top,exclude,glob){prefix}{suffix}"); + } + + return result; + } + + private static string EscapeGitGlobPath(string path) + { + var builder = new StringBuilder(path.Length); + foreach (char character in path) + { + if (character is '\\' or '*' or '?' or '[' or ']') + { + builder.Append('\\'); + } + + builder.Append(character); + } + + return builder.ToString(); + } + + private async Task FindIgnoredRequiredProjectPathBeforeInitAsync( + RepositoryInfo repository, + IGitCliRunner runner, + CancellationToken cancellationToken) + { + if (!Directory.Exists(repository.ProjectRoot)) + { + return null; + } + + string probeRoot = Path.Combine( + Path.GetTempPath(), + $"beutl-git-ignore-{Guid.NewGuid():N}"); + Directory.CreateDirectory(probeRoot); + try + { + var probeRepository = new RepositoryInfo(probeRoot, probeRoot); + await runner.RunAsync( + probeRepository, + ["init"], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + var environmentOverrides = new Dictionary + { + ["GIT_DIR"] = Path.Combine(probeRoot, ".git"), + ["GIT_WORK_TREE"] = repository.ProjectRoot, + }; + return await FindIgnoredPathAsync( + probeRepository, + runner, + GetRequiredProjectRelativePaths(repository.ProjectRoot), + environmentOverrides, + includeTrackedFiles: true, + cancellationToken) + .ConfigureAwait(false); + } + finally + { + TryDeleteIgnoreProbeDirectory(probeRoot); + } + } + + private static async Task FindIgnoredPathAsync( + RepositoryInfo repository, + IGitCliRunner runner, + IEnumerable paths, + IReadOnlyDictionary? environmentOverrides, + bool includeTrackedFiles, + CancellationToken cancellationToken) + { + string input = string.Join( + '\0', + paths.Distinct(StringComparer.Ordinal)) + '\0'; + if (input.Length == 1) + { + return null; + } + + try + { + GitCommandResult result = await runner.RunAsync( + repository, + includeTrackedFiles + ? ["check-ignore", "--no-index", "--stdin", "-z"] + : ["check-ignore", "--stdin", "-z"], + new GitCommandOptions( + GitCommandExecutionKind.Local, + EnvironmentOverrides: environmentOverrides, + StandardInput: input, + UseLiteralPathspecs: false), + cancellationToken).ConfigureAwait(false); + return GitCliRunner.SplitNullSeparated(result.Stdout).FirstOrDefault(); + } + catch (GitOperationException ex) when (ex.ExitCode == 1) + { + return null; + } + } + + private IReadOnlyList GetRequiredProjectRelativePaths(string projectRoot) + { + IReadOnlySet serializedPaths = GetSerializedProjectRelativePaths(projectRoot); + var paths = new HashSet(StringComparer.Ordinal) + { + ".gitignore", + ".gitattributes", + "beutl-required-project.bep", + "beutl-required-project.scene", + "beutl-required-project.belm", + }; + foreach (string extension in s_mediaExtensions) + { + paths.Add($"resources/beutl-required-media{extension}"); + } + + if (Directory.Exists(projectRoot)) + { + foreach (string path in EnumerateRequiredProjectFiles(projectRoot, serializedPaths)) + { + paths.Add(NormalizeGitPath(Path.GetRelativePath(projectRoot, path))); + } + } + + paths.UnionWith(serializedPaths); + + return [.. paths]; + } + + private IReadOnlySet GetSerializedProjectRelativePaths(string projectRoot) + { + if (_projectFile is null || !File.Exists(_projectFile)) + { + return new HashSet(StringComparer.Ordinal); + } + + return SerializedProjectGraph.GetRelativePaths(_projectFile, projectRoot); + } + + private static async Task ResolvePullFetchTargetAsync( + RepositoryInfo repository, + IGitCliRunner runner, + bool hasOrigin, + string localBranchRef, + CancellationToken cancellationToken) + { + if (!hasOrigin) + { + return new PullFetchTarget(["fetch"], "@{upstream}"); + } + + string? configuredUpstream = await TryGetUpstreamRefAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + string branchName = GetBranchShortName(localBranchRef); + string upstreamRef = $"{OriginRefPrefix}{branchName}"; + if (configuredUpstream is not null + && configuredUpstream.StartsWith(OriginRefPrefix, StringComparison.Ordinal) + && configuredUpstream.Length > OriginRefPrefix.Length) + { + upstreamRef = configuredUpstream; + branchName = configuredUpstream[OriginRefPrefix.Length..]; + } + + return new PullFetchTarget( + [ + "fetch", + "origin", + $"+refs/heads/{branchName}:{upstreamRef}", + ], + upstreamRef); + } + + private void ValidateRequiredProjectFileLayout(string projectRoot) + { + IReadOnlySet serializedPaths = GetSerializedProjectRelativePaths(projectRoot); + // Recorded here because every snapshot path validates the layout first, so the exclusion + // decision stays as fresh as the graph without deserializing the project again. + _snapshotsRequireTemporaryFiles = RequiresTemporaryFileSnapshots(serializedPaths); + foreach (string _ in EnumerateRequiredProjectFiles(projectRoot, serializedPaths)) + { + } + } + + private static IEnumerable EnumerateRequiredProjectFiles( + string projectRoot, + IReadOnlySet serializedPaths) + { + var pending = new Stack<( + string Directory, + bool IsResourceDirectory, + string? SymbolicLinkDirectory, + string? NestedRepositoryDirectory)>(); + pending.Push(( + projectRoot, + IsResourceDirectory: false, + SymbolicLinkDirectory: null, + NestedRepositoryDirectory: null)); + // Ordinal, not the platform rule: this dedupes directories the walk actually reached, and + // a case-sensitive volume can hold both Assets/ and assets/ as distinct trees. Folding them + // together would skip one subtree's symlink and nested-repository validation entirely. + var visitedDirectories = new HashSet(StringComparer.Ordinal); + var options = new EnumerationOptions { AttributesToSkip = 0 }; + while (pending.TryPop(out var item)) + { + string canonicalDirectory = RepositoryPathComparer.ResolveCanonicalPath(item.Directory); + if (!visitedDirectories.Add(canonicalDirectory)) + { + continue; + } + + foreach (string file in Directory.EnumerateFiles(item.Directory, "*", options)) + { + string extension = Path.GetExtension(file); + string relativeFile = NormalizeGitPath(Path.GetRelativePath(projectRoot, file)); + if (!string.Equals(extension, ".tmp", StringComparison.OrdinalIgnoreCase) + && (item.IsResourceDirectory + || s_projectFileExtensions.Contains(extension) + || s_mediaExtensions.Contains(extension) + || serializedPaths.Contains(relativeFile))) + { + var fileInfo = new FileInfo(file); + fileInfo.Refresh(); + if (fileInfo.LinkTarget is not null + || (fileInfo.Attributes & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidOperationException( + $"The required project file symbolic link '{relativeFile}' cannot be snapshotted safely."); + } + + if (item.SymbolicLinkDirectory is not null) + { + string relativeLink = NormalizeGitPath(Path.GetRelativePath( + projectRoot, + item.SymbolicLinkDirectory)); + throw new InvalidOperationException( + $"The required project content beneath symbolic-link directory '{relativeLink}' cannot be snapshotted safely."); + } + + if (item.NestedRepositoryDirectory is not null) + { + string relativeRepository = NormalizeGitPath(Path.GetRelativePath( + projectRoot, + item.NestedRepositoryDirectory)); + throw new InvalidOperationException( + $"The required project content beneath nested Git repository '{relativeRepository}' cannot be snapshotted safely."); + } + + yield return file; + } + } + + foreach (string child in Directory.EnumerateDirectories(item.Directory, "*", options)) + { + string name = Path.GetFileName(Path.TrimEndingDirectorySeparator(child)); + if (!string.Equals( + name, + ".beutl", + StringComparison.OrdinalIgnoreCase) + && !string.Equals( + name, + ".git", + StringComparison.OrdinalIgnoreCase)) + { + string? nestedRepositoryDirectory = item.NestedRepositoryDirectory; + if (nestedRepositoryDirectory is null + && (Directory.Exists(Path.Combine(child, ".git")) + || File.Exists(Path.Combine(child, ".git")))) + { + nestedRepositoryDirectory = child; + } + + pending.Push(( + child, + item.IsResourceDirectory + || string.Equals(name, "resources", StringComparison.OrdinalIgnoreCase), + item.SymbolicLinkDirectory + ?? ((File.GetAttributes(child) & FileAttributes.ReparsePoint) != 0 + ? child + : null), + nestedRepositoryDirectory)); + } + } + } + } + + private void ValidateProjectSnapshotLayout(string projectRoot) + { + ValidateRequiredProjectFileLayout(projectRoot); + if (_projectFile is null || !File.Exists(_projectFile)) + { + return; + } + + Project project = CoreSerializer.RestoreFromUri(new Uri(_projectFile)); + ExternalResourceCollector.SerializationGraph graph = + ExternalResourceCollector.DiscoverSerializationGraph(project); + string projectDirectory = Path.GetDirectoryName(_projectFile) + ?? throw new InvalidOperationException( + "The project file has no parent directory."); + Uri? reservedReference = graph.Objects + .Select(static obj => obj.Uri) + .Concat(graph.UnaddressableFileSources) + .FirstOrDefault(uri => uri is not null + && ExternalResourceCollector.IsInReservedProjectPath( + uri, + projectDirectory)); + reservedReference ??= ExternalResourceCollector + .Collect(graph, projectDirectory, stagedStorageObjects: null) + .FileSources + .Select(static source => source.OriginalUri) + .FirstOrDefault(uri => ExternalResourceCollector.IsInReservedProjectPath( + uri, + projectDirectory)); + if (reservedReference is not null) + { + string relativePath = NormalizeGitPath(Path.GetRelativePath( + projectDirectory, + reservedReference.LocalPath)); + throw new InvalidOperationException( + $"The required project path '{relativePath}' is beneath a reserved state directory."); + } + } + + private static (int Ahead, int Behind) ParseAheadBehindCounts(string output) + { + string[] values = output.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (values.Length != 2 + || !int.TryParse(values[0], out int ahead) + || !int.TryParse(values[1], out int behind)) + { + throw new InvalidOperationException("Git returned invalid ahead/behind counts."); + } + + return (ahead, behind); + } + + private static void TryDeleteIgnoreProbeDirectory(string path) + { + try + { + Directory.Delete(path, recursive: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + } + } + + private static void ThrowIfRequiredProjectPathIgnored(string? path) + { + if (path is not null) + { + throw new InvalidOperationException( + $"The required project path '{path}' is ignored by the repository. " + + "Update the repository's ignore rules before enabling version control."); + } + } + + private static void ValidateRemoteUrl(string url) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out Uri? uri)) + { + return; + } + + if (!string.IsNullOrEmpty(uri.Query) + || !string.IsNullOrEmpty(uri.Fragment)) + { + throw new ArgumentException( + "Remote URLs must not embed credentials. Configure a Git credential helper instead.", + nameof(url)); + } + + if (string.IsNullOrEmpty(uri.UserInfo)) + { + return; + } + + bool isSsh = string.Equals(uri.Scheme, "ssh", StringComparison.OrdinalIgnoreCase); + bool hasPassword = Uri.UnescapeDataString(uri.UserInfo) + .Contains(':'); + if (!isSsh || hasPassword) + { + throw new ArgumentException( + "Remote URLs must not embed credentials. Configure a Git credential helper instead.", + nameof(url)); + } + } + + private async Task QueueStatusChangedCoreAsync(CancellationToken cancellationToken) + { + WorkspaceStatus status = await GetStatusCoreAsync(cancellationToken).ConfigureAwait(false); + QueueStatusChanged(status); + } + + private async Task TryQueueStatusChangedCoreAsync() + { + try + { + await QueueStatusChangedCoreAsync(CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + LogWarningBestEffort( + ex, + "Failed to publish version-control status after a durable Git operation."); + } + } + + private async Task TryRaiseLfsQuotaNoticeIfNeededAsync( + RepositoryInfo repository, + IGitCliRunner runner) + { + try + { + await RaiseLfsQuotaNoticeIfNeededAsync( + repository, + runner, + CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + LogWarningBestEffort( + ex, + "Failed to publish the Git LFS quota notice after configuring the remote."); + } + } + + private void QueueStatusChanged(WorkspaceStatus status) + { + _statusNotifications.Enqueue(status); + if (Interlocked.CompareExchange(ref _statusNotificationDrainScheduled, 1, 0) != 0) + { + return; + } + + try + { + _statusNotificationScheduler(DrainStatusNotifications); + } + catch + { + Volatile.Write(ref _statusNotificationDrainScheduled, 0); + throw; + } + } + + private static void ScheduleStatusNotificationDrain(Action drain) + { + ThreadPool.UnsafeQueueUserWorkItem( + static state => ((Action)state!).Invoke(), + drain, + preferLocal: false); + } + + private void DrainStatusNotifications() + { + while (true) + { + while (_statusNotifications.TryDequeue(out WorkspaceStatus? status)) + { + NotifyStatusChanged(status); + } + + Volatile.Write(ref _statusNotificationDrainScheduled, 0); + if (_statusNotifications.IsEmpty + || Interlocked.CompareExchange(ref _statusNotificationDrainScheduled, 1, 0) != 0) + { + return; + } + } + } + + private void NotifyStatusChanged(WorkspaceStatus status) + { + if (IsDisposed || StatusChanged is not { } handlers) + { + return; + } + + foreach (EventHandler handler in handlers.GetInvocationList()) + { + try + { + handler(this, status); + } + catch (Exception ex) + { + LogWarningBestEffort( + ex, + "Failed to notify a version-control status subscriber."); + } + } + } + + private RepositoryInfo GetRepository() + { + return Repository + ?? throw new InvalidOperationException( + "The project is not associated with a Git repository."); + } + + private void EnsureWatcher() + { + lock (_lifetimeSync) + { + if (IsDisposed + || !_createWatcherWhenRepositoryAvailable + || _watcher is not null + || Repository is null) + { + return; + } + + _watcher = new RepositoryWatcher(Repository); + _watcher.Changed += OnRepositoryChanged; + } + } + + private void TryEnsureWatcher() + { + try + { + EnsureWatcher(); + } + catch (Exception ex) + { + LogWarningBestEffort( + ex, + "Failed to start repository watching after initializing version control."); + } + } + + private async Task<(GitAvailability Availability, IGitCliRunner? Runner)> GetGitRuntimeCoreAsync( + CancellationToken cancellationToken) + { + while (true) + { + int revision; + lock (_runtimeSync) + { + if (_cachedAvailability is not null) + { + return (_cachedAvailability, _runner); + } + + revision = _configurationRevision; + } + + GitAvailability availability = await _installationLocator + .LocateAsync(cancellationToken) + .ConfigureAwait(false); + IGitCliRunner? runner = availability.State == GitAvailabilityState.Installed + && availability.GitPath is not null + ? _runnerFactory(availability.GitPath) + : null; + + lock (_runtimeSync) + { + if (revision != _configurationRevision) + { + continue; + } + + _cachedAvailability = availability; + _runner = runner; + return (availability, runner); + } + } + } + + private async Task RunSerializedAsync( + Func> operation, + CancellationToken cancellationToken) + { + await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ThrowIfDisposed(); + return await Task.Run(operation, cancellationToken).ConfigureAwait(false); + } + catch (GitOperationException ex) + { + CaptureRecoverableLock(ex); + throw; + } + finally + { + _operationGate.Release(); + } + } + + private async Task RunSerializedAsync( + Func operation, + CancellationToken cancellationToken) + { + await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ThrowIfDisposed(); + await Task.Run(operation, cancellationToken).ConfigureAwait(false); + } + catch (GitOperationException ex) + { + CaptureRecoverableLock(ex); + throw; + } + finally + { + _operationGate.Release(); + } + } + + private void OnRepositoryChanged(object? sender, EventArgs e) + { + if (!IsDisposed) + { + _ = RefreshStatusFromWatcherAsync(); + } + } + + private void CaptureRecoverableLock(GitOperationException exception) + { + IGitCliRunner? runner = _runner; + RepositoryInfo? repository = Repository; + if (!exception.IsRepositoryLockFailure + || repository is null + || runner is null) + { + return; + } + + RepositoryLockInfo? lockInfo = runner.GetRecoverableRepositoryLock(repository); + if (lockInfo is null) + { + return; + } + + RecoverableLock = lockInfo; + ThreadPool.UnsafeQueueUserWorkItem( + static state => + { + var payload = (( + GitCliVersionControlService Service, + RepositoryLockInfo LockInfo))state!; + payload.Service.NotifyRecoverableLockAvailable(payload.LockInfo); + }, + (this, lockInfo), + preferLocal: false); + } + + private void NotifyRecoverableLockAvailable(RepositoryLockInfo lockInfo) + { + if (IsDisposed + || !Equals(RecoverableLock, lockInfo) + || RecoverableLockAvailable is not { } handlers) + { + return; + } + + foreach (EventHandler handler in handlers.GetInvocationList()) + { + try + { + handler(this, lockInfo); + } + catch (Exception ex) + { + LogWarningBestEffort( + ex, + "Failed to notify a recoverable repository-lock subscriber."); + } + } + } + + private void LogWarningBestEffort(Exception exception, string message) + { + try + { + _logger.LogWarning(exception, message); + } + catch + { + } + } + + private void OnVersionControlConfigChanged(object? sender, EventArgs e) + { + if (IsDisposed) + { + return; + } + + lock (_runtimeSync) + { + _configurationRevision++; + _cachedAvailability = null; + _runner = null; + } + } + + private async Task RefreshStatusFromWatcherAsync() + { + try + { + await RunSerializedAsync( + () => QueueStatusChangedCoreAsync(CancellationToken.None), + CancellationToken.None) + .ConfigureAwait(false); + } + catch (Exception) when (IsDisposed) + { + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Failed to refresh version-control status after a repository change."); + } + } + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(IsDisposed, this); + } + + private bool IsDisposed + => (ServiceLifetimeState)Volatile.Read(ref _lifetimeState) != ServiceLifetimeState.Active; + + private sealed class Transaction : IProjectVersionControlTransaction + { + private readonly GitCliVersionControlService _service; + + public Transaction(GitCliVersionControlService service) + { + _service = service; + } + + public Task CommitAllAsync( + string message, + SnapshotKind kind, + CancellationToken cancellationToken) + => _service.CommitAllCoreAsync(message, kind, cancellationToken); + + public Task GetCheckedOutBranchTipAsync( + CancellationToken cancellationToken) + => _service.GetCheckedOutBranchTipCoreAsync(cancellationToken); + + public Task PreflightPullAsync( + CheckedOutBranchTip expectedCurrent, + CancellationToken cancellationToken) + => _service.PreflightPullCoreAsync(expectedCurrent, cancellationToken); + + public Task CreateProjectCheckpointAsync( + string message, + CancellationToken cancellationToken) + => _service.CreateProjectCheckpointCoreAsync(message, cancellationToken); + + public Task PersistPendingPullRecoveryAsync( + ProjectCheckpoint checkpoint, + CheckedOutBranchTip targetTip, + string projectFile, + CancellationToken cancellationToken) + => _service.PersistPendingPullRecoveryCoreAsync( + checkpoint, + targetTip, + projectFile, + cancellationToken); + + public Task> GetPendingPullRecoveriesAsync( + CancellationToken cancellationToken) + => _service.GetPendingPullRecoveriesCoreAsync(cancellationToken); + + public Task RecoverPendingPullRecoveryAsync( + PendingPullRecovery recovery, + CancellationToken cancellationToken) + => _service.RecoverPendingPullRecoveryCoreAsync(recovery, cancellationToken); + + public Task CompletePendingPullRecoveryAsync( + PendingPullRecovery recovery, + CancellationToken cancellationToken) + => _service.CompletePendingPullRecoveryCoreAsync(recovery, cancellationToken); + + public Task RestoreProjectCheckpointAsync( + ProjectCheckpoint checkpoint, + CancellationToken cancellationToken) + => _service.RestoreProjectCheckpointCoreAsync(checkpoint, cancellationToken); + + public Task CommitProjectTreeAsync( + CheckedOutBranchTip expectedCurrent, + string sourceCommit, + string message, + SnapshotKind kind, + CancellationToken cancellationToken) + => _service.CommitProjectTreeCoreAsync( + expectedCurrent, + sourceCommit, + message, + kind, + cancellationToken); + + public Task RevisionContainsProjectFileAsync( + string sha, + string projectFile, + CancellationToken cancellationToken) + => _service.RevisionContainsProjectFileCoreAsync( + sha, + projectFile, + cancellationToken); + + public Task TryRollbackBranchTipAsync( + CheckedOutBranchTip expectedCurrent, + CheckedOutBranchTip target, + CancellationToken cancellationToken) + => _service.TryRollbackBranchTipCoreAsync(expectedCurrent, target, cancellationToken); + + public Task DeleteProjectCheckpointAsync( + ProjectCheckpoint checkpoint, + CancellationToken cancellationToken) + => _service.DeleteProjectCheckpointCoreAsync(checkpoint, cancellationToken); + + public Task GetStatusAsync(CancellationToken cancellationToken) + => _service.GetStatusCoreAsync(cancellationToken); + + public Task> GetBranchesAsync( + CancellationToken cancellationToken) + => _service.GetBranchesCoreAsync(cancellationToken); + + public Task CanCreateBranchAsync( + string name, + CancellationToken cancellationToken) + => _service.CanCreateBranchCoreAsync(name, cancellationToken); + + public Task CreateBranchAsync( + string name, + string startPoint, + CancellationToken cancellationToken) + => _service.CreateBranchCoreAsync(name, startPoint, cancellationToken); + + public Task PrefetchBranchLfsObjectsAsync(string name, CancellationToken cancellationToken) + => _service.PrefetchBranchLfsObjectsCoreAsync(name, cancellationToken); + + public Task PrefetchCommitLfsObjectsAsync(string sha, CancellationToken cancellationToken) + => _service.PrefetchCommitLfsObjectsCoreAsync(sha, cancellationToken); + + public Task SwitchBranchAsync(string name, CancellationToken cancellationToken) + => _service.SwitchBranchCoreAsync(name, cancellationToken); + + public Task PullFastForwardAsync( + CheckedOutBranchTip expectedCurrent, + ProjectCheckpoint? checkpoint, + string projectFile, + CancellationToken cancellationToken) + => _service.PullFastForwardCoreAsync( + expectedCurrent, + checkpoint, + projectFile, + cancellationToken); + } + + private enum ServiceLifetimeState + { + Active, + Retiring, + Retired, + } + + private static StringComparison PathComparison + => FileSystemPathComparison.ForCurrentPlatform; +} + +internal static class GitRevisionValidator +{ + public static void ValidateCommitId(string revision, string paramName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(revision, paramName); + if (revision.Length is < 4 or > 64 + || revision.Any(static character => character is not (>= '0' and <= '9' + or >= 'a' and <= 'f' + or >= 'A' and <= 'F'))) + { + throw new ArgumentException( + "The commit revision must be a hexadecimal object ID between 4 and 64 characters.", + paramName); + } + } +} diff --git a/src/Beutl.Editor/VersionControl/GitInstallationLocator.cs b/src/Beutl.Editor/VersionControl/GitInstallationLocator.cs new file mode 100644 index 0000000000..9ac0b8af9e --- /dev/null +++ b/src/Beutl.Editor/VersionControl/GitInstallationLocator.cs @@ -0,0 +1,491 @@ +using System.Diagnostics; +using System.Text.RegularExpressions; +using Beutl.Configuration; + +namespace Beutl.Editor.VersionControl; + +public sealed partial class GitInstallationLocator +{ + private static readonly TimeSpan s_defaultDiscoveryTimeout = TimeSpan.FromSeconds(10); + + public static readonly Version MinimumVersion = new(2, 23); + + private readonly VersionControlConfig _config; + private readonly TimeSpan _discoveryTimeout; + private readonly IGitInstallationProbe _probe; + private readonly GitHostPlatform _platform; + + public GitInstallationLocator(VersionControlConfig config) + : this(config, ProcessGitInstallationProbe.Instance, GetCurrentPlatform()) + { + } + + internal GitInstallationLocator( + VersionControlConfig config, + IGitInstallationProbe probe, + GitHostPlatform platform) + : this(config, probe, platform, s_defaultDiscoveryTimeout) + { + } + + internal GitInstallationLocator( + VersionControlConfig config, + IGitInstallationProbe probe, + GitHostPlatform platform, + TimeSpan discoveryTimeout) + { + if (discoveryTimeout <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(discoveryTimeout)); + } + + _config = config ?? throw new ArgumentNullException(nameof(config)); + _probe = probe ?? throw new ArgumentNullException(nameof(probe)); + _platform = platform; + _discoveryTimeout = discoveryTimeout; + } + + internal VersionControlConfig Config => _config; + + public async Task LocateAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + using var timeoutCts = new CancellationTokenSource(_discoveryTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeoutCts.Token); + CancellationToken discoveryToken = linkedCts.Token; + GitAvailability timeoutFallback = GitAvailability.NotInstalled; + try + { + IReadOnlyList candidates = await GetCandidatesAsync(discoveryToken).ConfigureAwait(false); + discoveryToken.ThrowIfCancellationRequested(); + GitAvailability? oldestSupportedFailure = null; + + foreach (string candidate in candidates.Distinct(PathComparer)) + { + discoveryToken.ThrowIfCancellationRequested(); + GitProbeResult result = await _probe.RunAsync( + candidate, + ["--version"], + discoveryToken).ConfigureAwait(false); + discoveryToken.ThrowIfCancellationRequested(); + if (result.ExitCode != 0 || !TryParseVersion(result.Stdout, out Version? version)) + { + continue; + } + + if (version < MinimumVersion) + { + oldestSupportedFailure ??= new GitAvailability( + GitAvailabilityState.VersionTooOld, + candidate, + version, + LfsInstalled: false); + timeoutFallback = oldestSupportedFailure; + continue; + } + + var installedWithoutLfs = new GitAvailability( + GitAvailabilityState.Installed, + candidate, + version, + LfsInstalled: false); + timeoutFallback = installedWithoutLfs; + GitProbeResult lfs = await _probe.RunAsync( + candidate, + ["lfs", "version"], + discoveryToken).ConfigureAwait(false); + discoveryToken.ThrowIfCancellationRequested(); + return installedWithoutLfs with { LfsInstalled = lfs.ExitCode == 0 }; + } + + discoveryToken.ThrowIfCancellationRequested(); + return oldestSupportedFailure ?? GitAvailability.NotInstalled; + } + catch (OperationCanceledException) when (linkedCts.IsCancellationRequested) + { + cancellationToken.ThrowIfCancellationRequested(); + return timeoutFallback; + } + } + + internal static bool TryParseVersion(string output, out Version? version) + { + Match match = GitVersionRegex().Match(output); + if (!match.Success) + { + version = null; + return false; + } + + version = new Version( + int.Parse(match.Groups["major"].Value, System.Globalization.CultureInfo.InvariantCulture), + int.Parse(match.Groups["minor"].Value, System.Globalization.CultureInfo.InvariantCulture), + int.Parse(match.Groups["patch"].Value, System.Globalization.CultureInfo.InvariantCulture)); + return true; + } + + private async Task> GetCandidatesAsync(CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(_config.GitExecutablePath)) + { + return [Path.GetFullPath(_config.GitExecutablePath)]; + } + + var candidates = new List(); + switch (_platform) + { + case GitHostPlatform.MacOS: + { + bool commandLineToolsInstalled + = await _probe.HasMacCommandLineToolsAsync(cancellationToken).ConfigureAwait(false); + foreach (string path in await _probe.FindOnPathAsync("git", cancellationToken).ConfigureAwait(false)) + { + if (!IsMacSystemGit(path) || commandLineToolsInstalled) + { + candidates.Add(path); + } + } + + if (commandLineToolsInstalled && _probe.FileExists("/usr/bin/git")) + { + candidates.Add("/usr/bin/git"); + } + + AddIfExists(candidates, "/opt/homebrew/bin/git"); + AddIfExists(candidates, "/usr/local/bin/git"); + break; + } + + case GitHostPlatform.Windows: + candidates.AddRange(await _probe.FindOnPathAsync("git", cancellationToken).ConfigureAwait(false)); + string? programFiles = _probe.GetEnvironmentVariable("ProgramFiles"); + if (!string.IsNullOrWhiteSpace(programFiles)) + { + AddIfExists(candidates, Path.Combine(programFiles, "Git", "cmd", "git.exe")); + } + break; + + default: + candidates.AddRange(await _probe.FindOnPathAsync("git", cancellationToken).ConfigureAwait(false)); + break; + } + + return candidates; + } + + private void AddIfExists(List candidates, string path) + { + if (_probe.FileExists(path)) + { + candidates.Add(path); + } + } + + private static bool IsMacSystemGit(string path) + => string.Equals(Path.GetFullPath(path), "/usr/bin/git", StringComparison.Ordinal); + + private static GitHostPlatform GetCurrentPlatform() + { + if (OperatingSystem.IsMacOS()) return GitHostPlatform.MacOS; + if (OperatingSystem.IsWindows()) return GitHostPlatform.Windows; + return GitHostPlatform.Linux; + } + + private static StringComparer PathComparer + => FileSystemPathComparison.ComparerForCurrentPlatform; + + [GeneratedRegex(@"git version (?\d+)\.(?\d+)\.(?\d+)", RegexOptions.CultureInvariant)] + private static partial Regex GitVersionRegex(); +} + +internal enum GitHostPlatform +{ + Windows, + MacOS, + Linux, +} + +internal sealed record GitProbeResult(int ExitCode, string Stdout, string Stderr); + +internal interface IGitInstallationProbe +{ + Task> FindOnPathAsync(string executableName, CancellationToken cancellationToken); + + Task HasMacCommandLineToolsAsync(CancellationToken cancellationToken); + + Task RunAsync( + string executablePath, + IReadOnlyList arguments, + CancellationToken cancellationToken); + + bool FileExists(string path); + + string? GetEnvironmentVariable(string name); +} + +internal sealed class ProcessGitInstallationProbe : IGitInstallationProbe +{ + private static readonly TimeSpan s_cleanupGracePeriod = TimeSpan.FromSeconds(1); + private static readonly TimeSpan s_defaultTimeout = TimeSpan.FromSeconds(5); + private readonly TimeSpan _timeout; + + public static ProcessGitInstallationProbe Instance { get; } = new(s_defaultTimeout); + + internal ProcessGitInstallationProbe(TimeSpan timeout) + { + if (timeout <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(timeout)); + } + + _timeout = timeout; + } + + public Task> FindOnPathAsync( + string executableName, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(FindOnPath( + executableName, + Environment.GetEnvironmentVariable("PATH"), + Environment.GetEnvironmentVariable("PATHEXT"))); + } + + // Searched in-process rather than through which/where: a minimal Linux image can carry git + // without those utilities, and reporting no candidates there disables version control for a + // Git that works. + internal static IReadOnlyList FindOnPath( + string executableName, + string? pathValue, + string? pathExtensionsValue) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executableName); + if (string.IsNullOrEmpty(pathValue)) + { + return []; + } + + string[] extensions = OperatingSystem.IsWindows() + ? [ + string.Empty, + .. (pathExtensionsValue ?? string.Empty) + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + ] + : [string.Empty]; + var candidates = new List(); + foreach (string directory in pathValue.Split( + Path.PathSeparator, + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + foreach (string extension in extensions) + { + string candidate; + try + { + candidate = Path.Combine(directory, executableName + extension); + } + catch (ArgumentException) + { + // A PATH entry with invalid path characters is not a directory to search. + break; + } + + if (File.Exists(candidate) && !candidates.Contains(candidate, StringComparer.Ordinal)) + { + candidates.Add(candidate); + } + } + } + + return candidates; + } + + public async Task HasMacCommandLineToolsAsync(CancellationToken cancellationToken) + { + if (!OperatingSystem.IsMacOS()) + { + return false; + } + + GitProbeResult result = await RunAsync( + "/usr/bin/xcode-select", + ["-p"], + cancellationToken).ConfigureAwait(false); + return result.ExitCode == 0 && !string.IsNullOrWhiteSpace(result.Stdout); + } + + public async Task RunAsync( + string executablePath, + IReadOnlyList arguments, + CancellationToken cancellationToken) + { + var startInfo = new ProcessStartInfo(executablePath) + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + foreach (string argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + using var timeoutCts = new CancellationTokenSource(_timeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeoutCts.Token); + var process = new Process { StartInfo = startInfo }; + bool disposeProcess = true; + try + { + try + { + process.Start(); + } + catch (System.ComponentModel.Win32Exception) + { + return new GitProbeResult(-1, string.Empty, string.Empty); + } + + Task stdout = process.StandardOutput.ReadToEndAsync(linkedCts.Token); + Task stderr = process.StandardError.ReadToEndAsync(linkedCts.Token); + Task processExit = process.WaitForExitAsync(linkedCts.Token); + Task completion = Task.WhenAll(processExit, stdout, stderr); + try + { + await completion.WaitAsync(linkedCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (linkedCts.IsCancellationRequested) + { + TryKillProcessTree(process); + Task cleanup = CreateCleanupTask(process, completion, processExit, stdout, stderr); + await WaitForCleanupGracePeriodAsync(cleanup).ConfigureAwait(false); + disposeProcess = false; + _ = DisposeAfterCleanupAsync(process, cleanup); + cancellationToken.ThrowIfCancellationRequested(); + return new GitProbeResult(-1, string.Empty, string.Empty); + } + + return new GitProbeResult( + process.ExitCode, + await stdout.ConfigureAwait(false), + await stderr.ConfigureAwait(false)); + } + finally + { + if (disposeProcess) + { + process.Dispose(); + } + } + } + + public bool FileExists(string path) => File.Exists(path); + + public string? GetEnvironmentVariable(string name) => Environment.GetEnvironmentVariable(name); + + internal static void TryKillProcessTree( + Process process, + Action? killProcessTree = null) + { + try + { + if (!process.HasExited) + { + killProcessTree ??= static target => target.Kill(entireProcessTree: true); + killProcessTree(process); + } + } + catch (Exception ex) when (ex is InvalidOperationException + or System.ComponentModel.Win32Exception + or NotSupportedException + or AggregateException) + { + } + } + + private static Task CreateCleanupTask( + Process process, + Task completion, + Task processExit, + Task stdout, + Task stderr) + { + Task finalExit; + try + { + finalExit = process.WaitForExitAsync(CancellationToken.None); + } + catch (Exception) + { + finalExit = Task.CompletedTask; + } + + return Task.WhenAll( + ObserveCleanupTaskAsync(completion), + ObserveCleanupTaskAsync(processExit), + ObserveCleanupTaskAsync(stdout), + ObserveCleanupTaskAsync(stderr), + ObserveCleanupTaskAsync(finalExit)); + } + + private static async Task WaitForCleanupGracePeriodAsync(Task cleanup) + { + try + { + await cleanup.WaitAsync(s_cleanupGracePeriod).ConfigureAwait(false); + } + catch (TimeoutException) + { + } + } + + private static async Task DisposeAfterCleanupAsync(Process process, Task cleanup) + { + await Task.Yield(); + TryCloseRedirectedStreams(process); + try + { + process.Dispose(); + } + catch (Exception) + { + } + + await cleanup.ConfigureAwait(false); + } + + private static void TryCloseRedirectedStreams(Process process) + { + try + { + process.StandardOutput.BaseStream.Dispose(); + } + catch (Exception) + { + } + + try + { + process.StandardError.BaseStream.Dispose(); + } + catch (Exception) + { + } + } + + private static async Task ObserveCleanupTaskAsync(Task task) + { + try + { + await task.ConfigureAwait(false); + } + catch (Exception) + { + } + } +} diff --git a/src/Beutl.Editor/VersionControl/IProjectFileWriteLease.cs b/src/Beutl.Editor/VersionControl/IProjectFileWriteLease.cs new file mode 100644 index 0000000000..5c2be793b6 --- /dev/null +++ b/src/Beutl.Editor/VersionControl/IProjectFileWriteLease.cs @@ -0,0 +1,7 @@ +namespace Beutl.Editor.VersionControl; + +/// +/// A reservation of the project workspace, held while project files are written so that no +/// version-control operation can replace them mid-write. Disposing it releases the reservation. +/// +public interface IProjectFileWriteLease : IDisposable; diff --git a/src/Beutl.Editor/VersionControl/IProjectVersionControlCoordinator.cs b/src/Beutl.Editor/VersionControl/IProjectVersionControlCoordinator.cs new file mode 100644 index 0000000000..bb566b2757 --- /dev/null +++ b/src/Beutl.Editor/VersionControl/IProjectVersionControlCoordinator.cs @@ -0,0 +1,48 @@ +namespace Beutl.Editor.VersionControl; + +public interface IProjectVersionControlCoordinator +{ + event EventHandler? PendingPullRecoveriesChanged; + + Task CommitManualAsync( + string message, + CancellationToken cancellationToken); + + Task RestoreAsync( + string sha, + CancellationToken cancellationToken); + + Task RestoreToNewBranchAsync( + string sha, + string branchName, + CancellationToken cancellationToken); + + Task CreateBranchAsync( + string branchName, + CancellationToken cancellationToken); + + Task SwitchBranchAsync( + string branchName, + CancellationToken cancellationToken); + + Task SetRemoteAsync( + string url, + CancellationToken cancellationToken); + + Task SetLocalIdentityAsync( + GitIdentity identity, + CancellationToken cancellationToken); + + Task PushAsync( + IProgress? progress, + CancellationToken cancellationToken); + + Task PullAsync(CancellationToken cancellationToken); + + Task> GetPendingPullRecoveriesAsync( + CancellationToken cancellationToken); + + Task RecoverPendingPullAsync( + string recoveryId, + CancellationToken cancellationToken); +} diff --git a/src/Beutl.Editor/VersionControl/IProjectVersionControlInitializer.cs b/src/Beutl.Editor/VersionControl/IProjectVersionControlInitializer.cs new file mode 100644 index 0000000000..5973d66f04 --- /dev/null +++ b/src/Beutl.Editor/VersionControl/IProjectVersionControlInitializer.cs @@ -0,0 +1,11 @@ +namespace Beutl.Editor.VersionControl; + +public interface IProjectVersionControlInitializer +{ + Task GetAvailabilityAsync(CancellationToken cancellationToken); + + Task InitializeCurrentProjectAsync( + Project expectedProject, + Func> requestIdentityAsync, + CancellationToken cancellationToken); +} diff --git a/src/Beutl.Editor/VersionControl/IProjectVersionControlService.cs b/src/Beutl.Editor/VersionControl/IProjectVersionControlService.cs new file mode 100644 index 0000000000..4db61f2008 --- /dev/null +++ b/src/Beutl.Editor/VersionControl/IProjectVersionControlService.cs @@ -0,0 +1,201 @@ +namespace Beutl.Editor.VersionControl; + +public interface IProjectVersionControlService +{ + RepositoryInfo? Repository { get; } + + Task GetAvailabilityAsync(CancellationToken cancellationToken); + + Task GetStatusAsync(CancellationToken cancellationToken); + + Task> GetHistoryAsync( + int skip, + int take, + CancellationToken cancellationToken); + + Task> GetCommitFilesAsync( + string sha, + CancellationToken cancellationToken); + + Task GetDiffAsync( + string sha, + string? path, + CancellationToken cancellationToken); + + Task> GetBranchesAsync( + CancellationToken cancellationToken); + + Task> GetRemotesAsync( + CancellationToken cancellationToken); + + Task GetIdentityAsync(CancellationToken cancellationToken); + + event EventHandler? StatusChanged; +} + +internal interface IProjectVersionControlBackend : + IProjectVersionControlService, + IRepositoryLockRecoveryService, + IDisposable +{ + Task DiscoverRepositoryAsync( + string projectRoot, + CancellationToken cancellationToken); + + /// + /// Lists the repository-relative .beutl/ and *.tmp entries the repository already + /// tracks. Generated ignore rules cannot untrack these, and snapshot status hides their + /// modifications, so they leave the repository permanently dirty for the pull precondition. + /// + Task> GetTrackedReservedPathsAsync(CancellationToken cancellationToken); + + /// + /// Drops the given entries from the index and records that in its own commit. The files stay on + /// disk. Requires the user's consent: a repository may be sharing them deliberately. + /// + Task UntrackReservedPathsAsync( + IReadOnlyList reservedPaths, + CancellationToken cancellationToken); + + Task InitializeAsync(InitOptions options, CancellationToken cancellationToken); + + Task EnsureRepositoryHygieneAsync(CancellationToken cancellationToken); + + /// + /// Reports whether this repository already records an opt-in for the project: either the + /// generated ignore and attribute rules are in place, or its history already carries a + /// snapshot Beutl committed for the project. That record is what distinguishes a repository + /// version tracking was enabled for from one the user created and Beutl has never managed. + /// + Task HasVersionTrackingOptInAsync( + RepositoryInfo repository, + CancellationToken cancellationToken); + + Task CommitAllAsync( + string message, + SnapshotKind kind, + CancellationToken cancellationToken); + + Task SetRemoteAsync(string url, CancellationToken cancellationToken); + + Task PushAsync( + IProgress? progress, + CancellationToken cancellationToken); + + Task SetLocalIdentityAsync( + GitIdentity identity, + CancellationToken cancellationToken); + + Task ExecuteExclusiveAsync( + Func> operation, + CancellationToken cancellationToken); + + Task RetireAsync(ProjectVersionControlFinalSnapshot? finalSnapshot); +} + +public interface IRepositoryLockRecoveryService +{ + RepositoryLockInfo? RecoverableLock { get; } + + Task RemoveRecoverableLockAsync(CancellationToken cancellationToken); + + event EventHandler? RecoverableLockAvailable; +} + +internal interface IProjectVersionControlTransaction +{ + Task CommitAllAsync( + string message, + SnapshotKind kind, + CancellationToken cancellationToken); + + Task GetCheckedOutBranchTipAsync(CancellationToken cancellationToken); + + Task PreflightPullAsync( + CheckedOutBranchTip expectedCurrent, + CancellationToken cancellationToken); + + Task CreateProjectCheckpointAsync( + string message, + CancellationToken cancellationToken); + + Task PersistPendingPullRecoveryAsync( + ProjectCheckpoint checkpoint, + CheckedOutBranchTip targetTip, + string projectFile, + CancellationToken cancellationToken); + + Task> GetPendingPullRecoveriesAsync( + CancellationToken cancellationToken); + + Task RecoverPendingPullRecoveryAsync( + PendingPullRecovery recovery, + CancellationToken cancellationToken); + + Task CompletePendingPullRecoveryAsync( + PendingPullRecovery recovery, + CancellationToken cancellationToken); + + Task RestoreProjectCheckpointAsync( + ProjectCheckpoint checkpoint, + CancellationToken cancellationToken); + + Task CommitProjectTreeAsync( + CheckedOutBranchTip expectedCurrent, + string sourceCommit, + string message, + SnapshotKind kind, + CancellationToken cancellationToken); + + Task RevisionContainsProjectFileAsync( + string sha, + string projectFile, + CancellationToken cancellationToken); + + Task TryRollbackBranchTipAsync( + CheckedOutBranchTip expectedCurrent, + CheckedOutBranchTip target, + CancellationToken cancellationToken); + + Task DeleteProjectCheckpointAsync( + ProjectCheckpoint checkpoint, + CancellationToken cancellationToken); + + Task GetStatusAsync(CancellationToken cancellationToken); + + Task> GetBranchesAsync(CancellationToken cancellationToken); + + Task CanCreateBranchAsync( + string name, + CancellationToken cancellationToken); + + Task CreateBranchAsync( + string name, + string startPoint, + CancellationToken cancellationToken); + + /// + /// Downloads the Git LFS objects the named branch needs, so a later switch does not have to + /// reach the network while the project is closed. Best effort: a failure leaves the switch to + /// fall back to whatever is already cached. + /// + Task PrefetchBranchLfsObjectsAsync(string name, CancellationToken cancellationToken); + + /// + /// The same prefetch for a target commit, so pull and restore do not reach the network from + /// their uncancellable checkout either. Best effort, exactly like the branch variant. + /// + Task PrefetchCommitLfsObjectsAsync(string sha, CancellationToken cancellationToken); + + Task SwitchBranchAsync(string name, CancellationToken cancellationToken); + + Task PullFastForwardAsync( + CheckedOutBranchTip expectedCurrent, + ProjectCheckpoint? checkpoint, + string projectFile, + CancellationToken cancellationToken); +} + +internal sealed record ProjectVersionControlFinalSnapshot( + string Message, + SnapshotKind Kind); diff --git a/src/Beutl.Editor/VersionControl/IProjectVersionControlSession.cs b/src/Beutl.Editor/VersionControl/IProjectVersionControlSession.cs new file mode 100644 index 0000000000..80ff4fb93e --- /dev/null +++ b/src/Beutl.Editor/VersionControl/IProjectVersionControlSession.cs @@ -0,0 +1,25 @@ +using Reactive.Bindings; + +namespace Beutl.Editor.VersionControl; + +public interface IProjectVersionControlSession +{ + IReadOnlyReactiveProperty IsGitAvailable { get; } + + IReadOnlyReactiveProperty IsTracked { get; } + + /// + /// Records that the project was explicitly saved, so a Save snapshot can be committed. + /// + /// + /// The reservation the finished save held. Passing it lets the snapshot take over the + /// workspace without ever leaving it unreserved, so the caller must have finished writing. + /// The snapshot may decline it — it is skipped entirely when the repository is untracked or + /// automatic snapshots are off — so the caller still owns the reservation and must dispose it; + /// disposing one that was taken over is a no-op. Passing makes the + /// snapshot compete for the workspace and be skipped when another operation holds it. + /// + Task NotifySavedAsync( + IProjectFileWriteLease? completedWrite = null, + CancellationToken cancellationToken = default); +} diff --git a/src/Beutl.Editor/VersionControl/ProjectConflictMarkerScanner.cs b/src/Beutl.Editor/VersionControl/ProjectConflictMarkerScanner.cs new file mode 100644 index 0000000000..f666bf8b03 --- /dev/null +++ b/src/Beutl.Editor/VersionControl/ProjectConflictMarkerScanner.cs @@ -0,0 +1,430 @@ +using System.Buffers; +using System.Text; +using Beutl.Editor; + +namespace Beutl.Editor.VersionControl; + +internal static class ProjectConflictMarkerScanner +{ + private const int ScanChunkSize = 4096; + private const int MinimumMarkerLength = 7; + + private static readonly byte[] s_utf8Bom = [0xef, 0xbb, 0xbf]; + private static readonly HashSet s_projectExtensions = new( + [".bep", ".scene", ".belm"], + StringComparer.OrdinalIgnoreCase); + private static readonly HashSet s_prunedDirectories = new( + [ + ".beutl", + ".git", + ".idea", + ".vs", + ], + StringComparer.OrdinalIgnoreCase); + + public static Task FindFirstAsync( + string projectFile, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(projectFile); + string? projectRoot = Path.GetDirectoryName(Path.GetFullPath(projectFile)); + // An extension can persist a sidecar under an extension this walk does not know, so the + // files the project itself references are scanned as well: restoration follows those URIs + // and would otherwise fail JSON parsing with no conflict guidance shown. + IReadOnlySet referenced = projectRoot is null + ? new HashSet(StringComparer.Ordinal) + : SerializedProjectGraph.TryGetRelativePaths(projectFile, projectRoot); + return FindFirstAsync(projectFile, referenced, cancellationToken); + } + + internal static async Task FindFirstAsync( + string projectFile, + IReadOnlySet referencedRelativePaths, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(projectFile); + ArgumentNullException.ThrowIfNull(referencedRelativePaths); + string projectRoot = Path.GetDirectoryName(Path.GetFullPath(projectFile)) + ?? throw new ArgumentException( + "The project file must have a parent directory.", + nameof(projectFile)); + if (!Directory.Exists(projectRoot)) + { + return null; + } + + HashSet referencedFiles = referencedRelativePaths + .Select(relativePath => Path.GetFullPath( + Path.Combine(projectRoot, relativePath.Replace('/', Path.DirectorySeparatorChar)))) + .ToHashSet(FileSystemPathComparison.ComparerForCurrentPlatform); + foreach (string referenced in referencedFiles) + { + cancellationToken.ThrowIfCancellationRequested(); + if (GitCliVersionControlService.IsSupportedMediaPath(referenced)) + { + continue; + } + + if (await ContainsConflictMarkerAsync(projectRoot, referenced, cancellationToken) + .ConfigureAwait(false)) + { + return referenced; + } + } + + var pendingDirectories = new Stack(); + pendingDirectories.Push(projectRoot); + while (pendingDirectories.TryPop(out string? directory)) + { + cancellationToken.ThrowIfCancellationRequested(); + string[] files; + string[] directories; + try + { + files = Directory.GetFiles(directory); + directories = Directory.GetDirectories(directory); + } + catch (IOException) + { + continue; + } + catch (UnauthorizedAccessException) + { + continue; + } + + foreach (string childDirectory in directories) + { + cancellationToken.ThrowIfCancellationRequested(); + if (ShouldDescendInto(childDirectory)) + { + pendingDirectories.Push(childDirectory); + } + } + + foreach (string file in files) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!s_projectExtensions.Contains(Path.GetExtension(file)) + || referencedFiles.Contains(Path.GetFullPath(file))) + { + continue; + } + + if (await ContainsConflictMarkerAsync(projectRoot, file, cancellationToken) + .ConfigureAwait(false)) + { + return file; + } + } + } + + return null; + } + + private static async Task ContainsConflictMarkerAsync( + string projectRoot, + string file, + CancellationToken cancellationToken) + { + if (!TryGetScannableLength(projectRoot, file, out long scanLength)) + { + return false; + } + + try + { + await using FileStream stream = new( + file, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete, + ScanChunkSize, + FileOptions.Asynchronous | FileOptions.SequentialScan); + return await ContainsConflictMarkerAsync(stream, scanLength, cancellationToken) + .ConfigureAwait(false); + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + } + + private static bool TryGetScannableLength( + string projectRoot, + string file, + out long scanLength) + { + scanLength = 0; + try + { + var info = new FileInfo(file); + info.Refresh(); + if (info.LinkTarget is not null + || info.Length <= 0 + || !RepositoryPathComparer.IsContainedWithin(projectRoot, info.FullName)) + { + return false; + } + + scanLength = info.Length; + return true; + } + catch (Exception ex) + when (ex is IOException + or UnauthorizedAccessException + or NotSupportedException) + { + return false; + } + } + + private static async Task ContainsConflictMarkerAsync( + Stream stream, + long scanLength, + CancellationToken cancellationToken) + { + byte[] buffer = new byte[ScanChunkSize]; + long lineLength = 0; + long prefixRunLength = 0; + long expectedMarkerLength = 0; + byte firstByte = 0; + byte byteAfterRun = 0; + byte lastByte = 0; + byte[] pendingRuneBytes = new byte[4]; + int pendingRuneByteCount = 0; + bool hasLabelContent = false; + MarkerSequenceState state = MarkerSequenceState.None; + long remaining = scanLength; + + bool ProcessByte(byte value) + { + if (value == (byte)'\n') + { + long contentLength = lineLength > 0 && lastByte == (byte)'\r' + ? lineLength - 1 + : lineLength; + bool result = ProcessLine( + firstByte, + prefixRunLength, + byteAfterRun, + hasLabelContent || pendingRuneByteCount > 0, + contentLength, + ref state, + ref expectedMarkerLength); + lineLength = 0; + prefixRunLength = 0; + firstByte = 0; + byteAfterRun = 0; + pendingRuneByteCount = 0; + hasLabelContent = false; + return result; + } + + if (lineLength == 0) + { + firstByte = value; + prefixRunLength = 1; + } + else if (lineLength == prefixRunLength) + { + if (value == firstByte) + { + prefixRunLength++; + } + else + { + byteAfterRun = value; + } + } + else if (!hasLabelContent + && HasNonWhitespaceRune( + value, + pendingRuneBytes, + ref pendingRuneByteCount)) + { + hasLabelContent = true; + } + + lineLength++; + lastByte = value; + return false; + } + + int initialLength = 0; + bool reachedEnd = false; + while (initialLength < s_utf8Bom.Length && remaining > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + int requested = (int)Math.Min(s_utf8Bom.Length - initialLength, remaining); + int read = await stream.ReadAsync( + buffer.AsMemory(initialLength, requested), + cancellationToken).ConfigureAwait(false); + if (read == 0) + { + reachedEnd = true; + break; + } + + initialLength += read; + remaining -= read; + } + + int initialOffset = initialLength == s_utf8Bom.Length + && buffer.AsSpan(0, initialLength).SequenceEqual(s_utf8Bom) + ? initialLength + : 0; + for (int i = initialOffset; i < initialLength; i++) + { + if (ProcessByte(buffer[i])) + { + return true; + } + } + + while (!reachedEnd && remaining > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + int requested = (int)Math.Min(ScanChunkSize, remaining); + int read = await stream.ReadAsync( + buffer.AsMemory(0, requested), + cancellationToken).ConfigureAwait(false); + if (read == 0) + { + break; + } + + remaining -= read; + for (int i = 0; i < read; i++) + { + if (ProcessByte(buffer[i])) + { + return true; + } + } + } + + long finalContentLength = lineLength > 0 && lastByte == (byte)'\r' + ? lineLength - 1 + : lineLength; + return ProcessLine( + firstByte, + prefixRunLength, + byteAfterRun, + hasLabelContent || pendingRuneByteCount > 0, + finalContentLength, + ref state, + ref expectedMarkerLength); + } + + private static bool ProcessLine( + byte firstByte, + long prefixRunLength, + byte byteAfterRun, + bool hasLabelContent, + long lineLength, + ref MarkerSequenceState state, + ref long expectedMarkerLength) + { + if (IsLabeledMarker( + firstByte, + prefixRunLength, + byteAfterRun, + hasLabelContent, + lineLength, + (byte)'<')) + { + state = MarkerSequenceState.StartSeen; + expectedMarkerLength = prefixRunLength; + } + else if (state == MarkerSequenceState.StartSeen + && firstByte == (byte)'=' + && prefixRunLength == expectedMarkerLength + && lineLength == prefixRunLength) + { + state = MarkerSequenceState.SeparatorSeen; + } + else if (state == MarkerSequenceState.SeparatorSeen + && prefixRunLength == expectedMarkerLength + && IsLabeledMarker( + firstByte, + prefixRunLength, + byteAfterRun, + hasLabelContent, + lineLength, + (byte)'>')) + { + return true; + } + + return false; + } + + private static bool IsLabeledMarker( + byte firstByte, + long prefixRunLength, + byte byteAfterRun, + bool hasLabelContent, + long lineLength, + byte markerByte) + { + return firstByte == markerByte + && prefixRunLength >= MinimumMarkerLength + && lineLength - prefixRunLength >= 2 + && byteAfterRun == (byte)' ' + && hasLabelContent; + } + + private static bool HasNonWhitespaceRune( + byte value, + byte[] pendingRuneBytes, + ref int pendingRuneByteCount) + { + pendingRuneBytes[pendingRuneByteCount++] = value; + OperationStatus status = Rune.DecodeFromUtf8( + pendingRuneBytes.AsSpan(0, pendingRuneByteCount), + out Rune rune, + out _); + if (status == OperationStatus.NeedMoreData + && pendingRuneByteCount < pendingRuneBytes.Length) + { + return false; + } + + pendingRuneByteCount = 0; + return status != OperationStatus.Done || !Rune.IsWhiteSpace(rune); + } + + internal static bool ShouldDescendInto(string directory) + { + string name = Path.GetFileName(Path.TrimEndingDirectorySeparator(directory)); + if (s_prunedDirectories.Contains(name)) + { + return false; + } + + try + { + return new DirectoryInfo(directory).LinkTarget is null; + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + } + + private enum MarkerSequenceState + { + None, + StartSeen, + SeparatorSeen, + } +} diff --git a/src/Beutl.Editor/VersionControl/RepositoryWatcher.cs b/src/Beutl.Editor/VersionControl/RepositoryWatcher.cs new file mode 100644 index 0000000000..de6ad4fda2 --- /dev/null +++ b/src/Beutl.Editor/VersionControl/RepositoryWatcher.cs @@ -0,0 +1,625 @@ +namespace Beutl.Editor.VersionControl; + +internal sealed class RepositoryWatcher : IDisposable +{ + private static readonly string[] AncestorRuleFileNames = [".gitignore", ".gitattributes"]; + + internal static readonly TimeSpan DebounceInterval = TimeSpan.FromMilliseconds(500); + internal static readonly TimeSpan MaximumDebounceDelay = TimeSpan.FromSeconds(2); + + private readonly object _sync = new(); + private readonly string _repoRoot; + private readonly string _projectRoot; + private readonly ITimer _debounceTimer; + private readonly TimeProvider _timeProvider; + private readonly Func _watcherFactory; + private readonly Action _watcherEnabler; + private readonly List _watchers = []; + private long? _debounceWindowStartedTimestamp; + private bool _disposed; + + internal RepositoryWatcher(RepositoryInfo repository, TimeProvider? timeProvider = null) + : this(repository, timeProvider ?? TimeProvider.System, startWatching: true) + { + } + + internal RepositoryWatcher( + RepositoryInfo repository, + TimeProvider timeProvider, + bool startWatching, + Func? watcherFactory = null, + Action? watcherEnabler = null) + { + ArgumentNullException.ThrowIfNull(repository); + ArgumentNullException.ThrowIfNull(timeProvider); + _repoRoot = repository.RepoRoot; + _projectRoot = repository.ProjectRoot; + _timeProvider = timeProvider; + _watcherFactory = watcherFactory ?? (static path => new FileSystemWatcher(path)); + _watcherEnabler = watcherEnabler ?? (static watcher => watcher.EnableRaisingEvents = true); + _debounceTimer = timeProvider.CreateTimer( + static state => ((RepositoryWatcher)state!).QueueChanged(), + this, + Timeout.InfiniteTimeSpan, + Timeout.InfiniteTimeSpan); + + if (startWatching) + { + try + { + Start(); + } + catch + { + Dispose(); + throw; + } + } + } + + public event EventHandler? Changed; + + internal static bool ShouldExcludePath(string projectRoot, string path) + { + string relativePath = NormalizeDirectorySeparators(Path.GetRelativePath(projectRoot, path)); + if (relativePath == ".." + || relativePath.StartsWith("../", StringComparison.Ordinal) + || Path.IsPathFullyQualified(relativePath)) + { + return true; + } + + if (relativePath.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + // A case-insensitive volume resolves `.BEUTL` to the same reserved directory, so matching it + // ordinally would let its view-state and autosave writes drive the status refresh pipeline. + return relativePath + .Split('/', StringSplitOptions.RemoveEmptyEntries) + .Any(static segment => + string.Equals(segment, ".git", FileSystemPathComparison.ForCurrentPlatform) + || string.Equals(segment, ".beutl", FileSystemPathComparison.ForCurrentPlatform)); + } + + internal static bool ShouldIncludeGitMetadataPath(string metadataRoot, string path) + { + string relativePath = NormalizeDirectorySeparators(Path.GetRelativePath(metadataRoot, path)); + if (relativePath == ".." + || relativePath.StartsWith("../", StringComparison.Ordinal) + || Path.IsPathFullyQualified(relativePath)) + { + return false; + } + + string fileName = Path.GetFileName(relativePath); + if (fileName.EndsWith(".lock", StringComparison.OrdinalIgnoreCase) + || fileName.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return relativePath is "index" + or "HEAD" + or "packed-refs" + or "config" + or "config.worktree" + or "info/exclude" + // Repository-local attributes outrank every .gitattributes file, so a change to + // text, eol or filter here can make project paths modified on its own. + or "info/attributes" + or "reftable" + or "refs" + || relativePath.StartsWith("reftable/", StringComparison.Ordinal) + || relativePath.StartsWith("refs/", StringComparison.Ordinal); + } + + private static string NormalizeDirectorySeparators(string path) + => OperatingSystem.IsWindows() ? path.Replace('\\', '/') : path; + + internal static (string GitDirectory, string CommonDirectory)? ResolveGitMetadataDirectories( + string repoRoot) + { + string normalizedRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(repoRoot)); + string dotGitPath = Path.Combine(normalizedRoot, ".git"); + string? gitDirectory; + + if (Directory.Exists(dotGitPath)) + { + gitDirectory = dotGitPath; + } + else if (File.Exists(dotGitPath)) + { + string pointer; + try + { + pointer = File.ReadLines(dotGitPath).FirstOrDefault()?.Trim() ?? string.Empty; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return null; + } + + const string Prefix = "gitdir:"; + if (!pointer.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + string gitDirectoryValue = pointer[Prefix.Length..].Trim(); + if (string.IsNullOrEmpty(gitDirectoryValue)) + { + return null; + } + + gitDirectory = Path.IsPathFullyQualified(gitDirectoryValue) + ? gitDirectoryValue + : Path.Combine(normalizedRoot, gitDirectoryValue); + } + else + { + return null; + } + + gitDirectory = Path.TrimEndingDirectorySeparator(Path.GetFullPath(gitDirectory)); + if (!Directory.Exists(gitDirectory)) + { + return null; + } + + string commonDirectory = gitDirectory; + string commonDirectoryFile = Path.Combine(gitDirectory, "commondir"); + if (File.Exists(commonDirectoryFile)) + { + try + { + string commonDirectoryValue = File.ReadLines(commonDirectoryFile) + .FirstOrDefault()?.Trim() ?? string.Empty; + if (!string.IsNullOrEmpty(commonDirectoryValue)) + { + commonDirectory = Path.IsPathFullyQualified(commonDirectoryValue) + ? commonDirectoryValue + : Path.Combine(gitDirectory, commonDirectoryValue); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return null; + } + } + + commonDirectory = Path.TrimEndingDirectorySeparator(Path.GetFullPath(commonDirectory)); + return Directory.Exists(commonDirectory) + ? (gitDirectory, commonDirectory) + : null; + } + + internal void NotifyPathChanged(string path) + { + if (ShouldExcludePath(_projectRoot, path)) + { + return; + } + + ScheduleChanged(); + } + + internal void NotifyPathRenamed(string oldPath, string newPath) + { + if (ShouldExcludePath(_projectRoot, oldPath) + && ShouldExcludePath(_projectRoot, newPath)) + { + return; + } + + ScheduleChanged(); + } + + public void Dispose() + { + FileSystemWatcher[] watchers; + lock (_sync) + { + if (_disposed) + { + return; + } + + _disposed = true; + watchers = [.. _watchers]; + _watchers.Clear(); + _debounceTimer.Dispose(); + } + + foreach (FileSystemWatcher watcher in watchers) + { + watcher.Dispose(); + } + } + + private void Start() + { + if (!Directory.Exists(_repoRoot)) + { + throw new DirectoryNotFoundException($"Repository directory not found: {_repoRoot}"); + } + + if (!Directory.Exists(_projectRoot)) + { + throw new DirectoryNotFoundException($"Project directory not found: {_projectRoot}"); + } + + AddWatcher( + _projectRoot, + watcher => + { + watcher.IncludeSubdirectories = true; + watcher.NotifyFilter = NotifyFilters.FileName + | NotifyFilters.DirectoryName + | NotifyFilters.LastWrite + | NotifyFilters.Size; + watcher.Changed += OnFileSystemChanged; + watcher.Created += OnFileSystemChanged; + watcher.Deleted += OnFileSystemChanged; + watcher.Renamed += OnFileSystemChanged; + watcher.Error += OnWatcherError; + }); + + AddAncestorRuleWatchers(); + + (string GitDirectory, string CommonDirectory)? metadataDirectories + = ResolveGitMetadataDirectories(_repoRoot); + if (metadataDirectories is { } directories) + { + AddGitMetadataWatchers(directories.GitDirectory); + if (!RepositoryPathComparer.AreEquivalent( + directories.GitDirectory, + directories.CommonDirectory)) + { + AddGitMetadataWatchers(directories.CommonDirectory); + } + } + } + + private void AddAncestorRuleWatchers() + { + string relativeProject = Path.GetRelativePath(_repoRoot, _projectRoot); + if (relativeProject == ".") + { + return; + } + + string directory = _repoRoot; + foreach (string segment in relativeProject.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries)) + { + AddAncestorRuleWatcher(directory); + directory = Path.Combine(directory, segment); + } + } + + private void AddAncestorRuleWatcher(string directory) + { + AddWatcher( + directory, + watcher => + { + watcher.IncludeSubdirectories = false; + watcher.NotifyFilter = NotifyFilters.FileName + | NotifyFilters.LastWrite + | NotifyFilters.Size; + foreach (string fileName in AncestorRuleFileNames) + { + watcher.Filters.Add(fileName); + } + + watcher.Changed += OnAncestorRuleChanged; + watcher.Created += OnAncestorRuleChanged; + watcher.Deleted += OnAncestorRuleChanged; + watcher.Renamed += OnAncestorRuleChanged; + watcher.Error += OnWatcherError; + }); + } + + private void AddGitMetadataWatchers(string metadataRoot) + { + AddGitMetadataWatcher(metadataRoot, metadataRoot, includeSubdirectories: false); + RefreshGitRefsWatcher(metadataRoot); + RefreshGitInfoWatcher(metadataRoot); + RefreshGitReftableWatcher(metadataRoot); + } + + private void AddGitMetadataWatcher( + string watchedDirectory, + string metadataRoot, + bool includeSubdirectories, + bool rejectDuplicate = false) + { + AddWatcher( + watchedDirectory, + watcher => + { + watcher.IncludeSubdirectories = includeSubdirectories; + watcher.NotifyFilter = NotifyFilters.FileName + | NotifyFilters.DirectoryName + | NotifyFilters.LastWrite + | NotifyFilters.Size; + FileSystemEventHandler changed = (_, e) => OnGitMetadataChanged(metadataRoot, e); + RenamedEventHandler renamed = (_, e) => OnGitMetadataChanged(metadataRoot, e); + watcher.Changed += changed; + watcher.Created += changed; + watcher.Deleted += changed; + watcher.Renamed += renamed; + watcher.Error += OnWatcherError; + }, + rejectDuplicate + ? watchers => watchers.Any(watcher => + watcher.IncludeSubdirectories == includeSubdirectories + && PathsEqual(watcher.Path, watchedDirectory)) + : null); + } + + private void AddWatcher( + string directory, + Action configure, + Func, bool>? conflicts = null) + { + FileSystemWatcher watcher = _watcherFactory(directory) + ?? throw new InvalidOperationException( + "The watcher factory returned null."); + try + { + configure(watcher); + _watcherEnabler(watcher); + bool accepted; + lock (_sync) + { + accepted = !_disposed && conflicts?.Invoke(_watchers) != true; + if (accepted) + { + _watchers.Add(watcher); + } + } + + if (!accepted) + { + watcher.Dispose(); + } + } + catch + { + watcher.Dispose(); + throw; + } + } + + private void RefreshGitRefsWatcher(string metadataRoot, bool replaceExisting = false) + { + RefreshGitMetadataSubdirectoryWatcher( + metadataRoot, + "refs", + includeSubdirectories: true, + replaceExisting: replaceExisting); + } + + private void RefreshGitInfoWatcher(string metadataRoot, bool replaceExisting = false) + { + RefreshGitMetadataSubdirectoryWatcher( + metadataRoot, + "info", + includeSubdirectories: false, + replaceExisting: replaceExisting); + } + + private void RefreshGitReftableWatcher(string metadataRoot, bool replaceExisting = false) + { + RefreshGitMetadataSubdirectoryWatcher( + metadataRoot, + "reftable", + includeSubdirectories: true, + replaceExisting: replaceExisting); + } + + private void RefreshGitMetadataSubdirectoryWatcher( + string metadataRoot, + string directoryName, + bool includeSubdirectories, + bool replaceExisting) + { + string directory = Path.Combine(metadataRoot, directoryName); + List replacedWatchers = []; + lock (_sync) + { + if (_disposed) + { + return; + } + + for (int i = _watchers.Count - 1; i >= 0; i--) + { + FileSystemWatcher watcher = _watchers[i]; + if (watcher.IncludeSubdirectories == includeSubdirectories + && PathsEqual(watcher.Path, directory)) + { + if (!replaceExisting) + { + return; + } + + _watchers.RemoveAt(i); + replacedWatchers.Add(watcher); + } + } + } + + foreach (FileSystemWatcher watcher in replacedWatchers) + { + watcher.Dispose(); + } + + if (!Directory.Exists(directory)) + { + return; + } + + try + { + bool shouldAttach; + lock (_sync) + { + shouldAttach = !_disposed + && !_watchers.Any(watcher => + watcher.IncludeSubdirectories == includeSubdirectories + && PathsEqual(watcher.Path, directory)) + && Directory.Exists(directory); + } + + if (shouldAttach) + { + AddGitMetadataWatcher( + directory, + metadataRoot, + includeSubdirectories, + rejectDuplicate: true); + } + } + catch (Exception ex) + when (ex is IOException + or UnauthorizedAccessException + or ArgumentException) + { + // The metadata directory can disappear again between the root event and watcher setup. + } + } + + private void OnFileSystemChanged(object sender, FileSystemEventArgs e) + { + if (e is RenamedEventArgs renamed) + { + NotifyPathRenamed(renamed.OldFullPath, renamed.FullPath); + } + else + { + NotifyPathChanged(e.FullPath); + } + } + + private void OnWatcherError(object sender, ErrorEventArgs e) + { + ScheduleChanged(); + } + + private void OnAncestorRuleChanged(object sender, FileSystemEventArgs e) + { + ScheduleChanged(); + } + + private void OnGitMetadataChanged(string metadataRoot, FileSystemEventArgs e) + { + bool metadataSubdirectoryChanged = false; + string refsDirectory = Path.Combine(metadataRoot, "refs"); + if (PathsEqual(e.FullPath, refsDirectory) + || e is RenamedEventArgs refsRename + && PathsEqual(refsRename.OldFullPath, refsDirectory)) + { + RefreshGitRefsWatcher(metadataRoot, replaceExisting: true); + metadataSubdirectoryChanged = true; + } + + string infoDirectory = Path.Combine(metadataRoot, "info"); + if (PathsEqual(e.FullPath, infoDirectory) + || e is RenamedEventArgs infoRename + && PathsEqual(infoRename.OldFullPath, infoDirectory)) + { + RefreshGitInfoWatcher(metadataRoot, replaceExisting: true); + metadataSubdirectoryChanged = true; + } + + string reftableDirectory = Path.Combine(metadataRoot, "reftable"); + if (PathsEqual(e.FullPath, reftableDirectory) + || e is RenamedEventArgs reftableRename + && PathsEqual(reftableRename.OldFullPath, reftableDirectory)) + { + RefreshGitReftableWatcher(metadataRoot, replaceExisting: true); + metadataSubdirectoryChanged = true; + } + + bool include = metadataSubdirectoryChanged + || ShouldIncludeGitMetadataPath(metadataRoot, e.FullPath); + if (e is RenamedEventArgs renamed) + { + include |= ShouldIncludeGitMetadataPath(metadataRoot, renamed.OldFullPath); + } + + if (include) + { + ScheduleChanged(); + } + } + + private static bool PathsEqual(string left, string right) + { + StringComparison comparison = FileSystemPathComparison.ForCurrentPlatform; + return string.Equals( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)), + Path.TrimEndingDirectorySeparator(Path.GetFullPath(right)), + comparison); + } + + private void ScheduleChanged() + { + lock (_sync) + { + if (_disposed) + { + return; + } + + long now = _timeProvider.GetTimestamp(); + _debounceWindowStartedTimestamp ??= now; + TimeSpan elapsed = _timeProvider.GetElapsedTime( + _debounceWindowStartedTimestamp.Value, + now); + TimeSpan maximumRemaining = MaximumDebounceDelay - elapsed; + TimeSpan dueTime = maximumRemaining <= TimeSpan.Zero + ? TimeSpan.Zero + : TimeSpan.FromTicks(Math.Min( + DebounceInterval.Ticks, + maximumRemaining.Ticks)); + _debounceTimer.Change(dueTime, Timeout.InfiniteTimeSpan); + } + } + + private void QueueChanged() + { + lock (_sync) + { + if (_disposed) + { + return; + } + + _debounceWindowStartedTimestamp = null; + } + + ThreadPool.UnsafeQueueUserWorkItem( + static state => ((RepositoryWatcher)state!).RaiseChanged(), + this, + preferLocal: false); + } + + private void RaiseChanged() + { + lock (_sync) + { + if (_disposed) + { + return; + } + } + + Changed?.Invoke(this, EventArgs.Empty); + } +} diff --git a/src/Beutl.Editor/VersionControl/SerializedProjectGraph.cs b/src/Beutl.Editor/VersionControl/SerializedProjectGraph.cs new file mode 100644 index 0000000000..43ab468e39 --- /dev/null +++ b/src/Beutl.Editor/VersionControl/SerializedProjectGraph.cs @@ -0,0 +1,56 @@ +using Beutl.Serialization; + +namespace Beutl.Editor.VersionControl; + +// The set of files a project actually references, which is what tells required project state apart +// from whatever else happens to sit in the project directory. +internal static class SerializedProjectGraph +{ + public static IReadOnlySet GetRelativePaths(string projectFile, string projectRoot) + { + ArgumentException.ThrowIfNullOrWhiteSpace(projectFile); + ArgumentException.ThrowIfNullOrWhiteSpace(projectRoot); + var paths = new HashSet(StringComparer.Ordinal); + Project project = CoreSerializer.RestoreFromUri(new Uri(projectFile)); + ExternalResourceCollector.SerializationGraph graph = + ExternalResourceCollector.DiscoverSerializationGraph(project); + foreach (Uri uri in graph.Objects + .Select(static obj => obj.Uri) + .Concat(graph.UnaddressableFileSources) + .Concat(graph.AddressableFileSources) + .OfType()) + { + if (!uri.IsFile) + { + continue; + } + + string relativePath = Path.GetRelativePath(projectRoot, uri.LocalPath); + if (!Path.IsPathFullyQualified(relativePath) + && relativePath != ".." + && !relativePath.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal) + && !relativePath.StartsWith($"..{Path.AltDirectorySeparatorChar}", StringComparison.Ordinal)) + { + paths.Add(OperatingSystem.IsWindows() + ? relativePath.Replace('\\', '/') + : relativePath); + } + } + + return paths; + } + + // The project file itself can be the conflicted one, and a half-written graph must not stop the + // caller from scanning what it already knows about. + public static IReadOnlySet TryGetRelativePaths(string projectFile, string projectRoot) + { + try + { + return GetRelativePaths(projectFile, projectRoot); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not OperationCanceledException) + { + return new HashSet(StringComparer.Ordinal); + } + } +} diff --git a/src/Beutl.Editor/VersionControl/VersionControlModels.cs b/src/Beutl.Editor/VersionControl/VersionControlModels.cs new file mode 100644 index 0000000000..213945df7f --- /dev/null +++ b/src/Beutl.Editor/VersionControl/VersionControlModels.cs @@ -0,0 +1,698 @@ +using System.Text.RegularExpressions; + +namespace Beutl.Editor.VersionControl; + +internal static class RepositoryPathComparer +{ + private const int MaxSymbolicLinkHops = 64; + + // Both comparisons below run on canonical paths, where ResolveCanonicalPath has already folded + // the casing the filesystem itself merges. Ordinal is therefore exact in both directions: two + // spellings of one directory have converged, and two directories that only a case-insensitive + // rule would merge stay apart on a case-sensitive volume. + internal static bool AreEquivalent(string left, string right) + { + return string.Equals( + ResolveCanonicalPath(left), + ResolveCanonicalPath(right), + StringComparison.Ordinal); + } + + internal static bool IsContainedWithin(string root, string path) + { + string canonicalRoot = Path.TrimEndingDirectorySeparator(ResolveCanonicalPath(root)); + string canonicalPath = Path.TrimEndingDirectorySeparator(ResolveCanonicalPath(path)); + if (string.Equals(canonicalRoot, canonicalPath, StringComparison.Ordinal)) + { + return true; + } + + string prefix = canonicalRoot.EndsWith(Path.DirectorySeparatorChar) + ? canonicalRoot + : canonicalRoot + Path.DirectorySeparatorChar; + return canonicalPath.StartsWith(prefix, StringComparison.Ordinal); + } + + internal static string ResolveCanonicalPath(string path) + { + string fullPath = Path.GetFullPath(path); + string root = Path.GetPathRoot(fullPath) + ?? throw new IOException($"The path '{path}' has no root."); + var components = new Queue(SplitComponents(fullPath, root)); + var visitedStates = new HashSet(PathComparer) + { + CreateResolutionState(root, components), + }; + string resolved = root; + int linkHops = 0; + + while (components.TryDequeue(out string? component)) + { + if (component == ".") + { + continue; + } + + if (component == "..") + { + resolved = Path.GetDirectoryName(resolved) ?? resolved; + continue; + } + + string candidate = Path.GetFullPath(Path.Combine(resolved, component)); + candidate = NormalizeExistingEntryCasing(resolved, component, candidate); + string? target = TryGetLinkTarget(candidate); + if (target is null) + { + resolved = candidate; + continue; + } + + linkHops++; + if (linkHops > MaxSymbolicLinkHops) + { + throw new IOException( + $"The path '{path}' exceeds the symbolic-link resolution limit."); + } + + string targetRoot = Path.GetPathRoot(target) ?? string.Empty; + if (Path.IsPathFullyQualified(target)) + { + resolved = targetRoot; + } + else if (Path.IsPathRooted(target)) + { + if (!OperatingSystem.IsWindows() + || targetRoot.Length != 1 + || targetRoot[0] is not ('\\' or '/')) + { + throw new IOException( + $"The symbolic link '{candidate}' has an unsupported rooted target."); + } + + resolved = Path.GetPathRoot(resolved) + ?? throw new IOException( + $"The path '{path}' has no drive root."); + } + + IEnumerable targetComponents = SplitComponents(target, targetRoot); + components = new Queue(targetComponents.Concat(components)); + string state = CreateResolutionState(resolved, components); + if (!visitedStates.Add(state)) + { + throw new IOException( + $"A symbolic-link cycle was found while resolving '{path}'."); + } + } + + return Path.TrimEndingDirectorySeparator(Path.GetFullPath(resolved)); + } + + private static IEnumerable SplitComponents(string path, string root) + { + return path[root.Length..].Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries); + } + + private static string CreateResolutionState(string resolved, IEnumerable components) + { + return string.Join('\0', new[] { resolved }.Concat(components)); + } + + private static string? TryGetLinkTarget(string path) + { + FileSystemInfo info = Directory.Exists(path) + ? new DirectoryInfo(path) + : new FileInfo(path); + try + { + return info.LinkTarget; + } + catch (Exception ex) + when (ex is IOException + or UnauthorizedAccessException + or NotSupportedException + or ArgumentException) + { + throw new IOException( + $"Could not inspect symbolic-link metadata for '{path}'.", + ex); + } + } + + private static string NormalizeExistingEntryCasing( + string parent, + string component, + string candidate) + { + // Every case-insensitive platform, not just macOS: canonical paths are compared ordinally, + // so a component left in the caller's casing makes two spellings of one Windows directory + // compare as different repositories. + if (!FileSystemPathComparison.IsCaseInsensitive || !Path.Exists(candidate)) + { + return candidate; + } + + try + { + string? insensitiveMatch = null; + foreach (string entry in Directory.EnumerateFileSystemEntries(parent)) + { + string entryName = Path.GetFileName(entry); + if (string.Equals(entryName, component, StringComparison.Ordinal)) + { + return entry; + } + + if (insensitiveMatch is null + && string.Equals(entryName, component, StringComparison.OrdinalIgnoreCase)) + { + insensitiveMatch = entry; + } + } + + return insensitiveMatch ?? candidate; + } + catch (Exception ex) + when (ex is IOException + or UnauthorizedAccessException + or NotSupportedException) + { + throw new IOException( + $"Could not normalize the on-disk casing of '{candidate}'.", + ex); + } + } + + private static StringComparer PathComparer + => FileSystemPathComparison.ComparerForCurrentPlatform; +} + +public enum GitAvailabilityState +{ + Installed, + NotInstalled, + VersionTooOld, +} + +public sealed record GitAvailability( + GitAvailabilityState State, + string? GitPath, + Version? Version, + bool LfsInstalled) +{ + public static GitAvailability NotInstalled { get; } = new( + GitAvailabilityState.NotInstalled, + GitPath: null, + Version: null, + LfsInstalled: false); +} + +public sealed record RepositoryInfo +{ + private readonly string _canonicalRepoRoot; + private readonly string _canonicalProjectRoot; + + public RepositoryInfo(string repoRoot, string projectRoot) + { + ArgumentException.ThrowIfNullOrWhiteSpace(repoRoot); + ArgumentException.ThrowIfNullOrWhiteSpace(projectRoot); + + string normalizedRepoRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(repoRoot)); + string normalizedProjectRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(projectRoot)); + + // Containment and identity are decided on canonical paths compared ordinally. + // ResolveCanonicalPath follows symbolic links and rewrites each existing component to its + // on-disk casing, so two spellings of one directory converge while two directories that a + // case-insensitive rule would merge stay apart on a case-sensitive volume. Comparing the + // given paths under a per-platform rule gets one of those two cases wrong either way. + string canonicalRepoRoot = Path.TrimEndingDirectorySeparator( + RepositoryPathComparer.ResolveCanonicalPath(normalizedRepoRoot)); + string canonicalProjectRoot = Path.TrimEndingDirectorySeparator( + RepositoryPathComparer.ResolveCanonicalPath(normalizedProjectRoot)); + string relativeProject = GetContainedRelativePath(canonicalRepoRoot, canonicalProjectRoot) + ?? throw new ArgumentException( + "The project root must be inside the repository root.", + nameof(projectRoot)); + bool nested = relativeProject != "."; + + RepoRoot = normalizedRepoRoot; + ProjectRoot = normalizedProjectRoot; + _canonicalRepoRoot = canonicalRepoRoot; + _canonicalProjectRoot = canonicalProjectRoot; + IsNestedInForeignRepo = nested; + Pathspec = nested ? NormalizePathspec(relativeProject) : "."; + } + + // Returns null when path is not inside root. Both are fully qualified and trimmed, so an + // ordinal prefix test is exact; Path.GetRelativePath cannot be used because it applies the + // per-platform casing rule this type deliberately avoids. + private static string? GetContainedRelativePath(string root, string path) + { + if (string.Equals(root, path, StringComparison.Ordinal)) + { + return "."; + } + + string prefix = root.EndsWith(Path.DirectorySeparatorChar) + ? root + : root + Path.DirectorySeparatorChar; + return path.StartsWith(prefix, StringComparison.Ordinal) + ? path[prefix.Length..] + : null; + } + + private static string NormalizePathspec(string path) + => OperatingSystem.IsWindows() ? path.Replace('\\', '/') : path; + + public string RepoRoot { get; } + + public string ProjectRoot { get; } + + public bool IsNestedInForeignRepo { get; } + + public string Pathspec { get; } + + public bool Equals(RepositoryInfo? other) + { + return other is not null + && string.Equals(_canonicalRepoRoot, other._canonicalRepoRoot, StringComparison.Ordinal) + && string.Equals(_canonicalProjectRoot, other._canonicalProjectRoot, StringComparison.Ordinal); + } + + public override int GetHashCode() + { + return HashCode.Combine( + StringComparer.Ordinal.GetHashCode(_canonicalRepoRoot), + StringComparer.Ordinal.GetHashCode(_canonicalProjectRoot)); + } +} + +public enum SnapshotKind +{ + Manual, + Save, + Close, + Safety, + Restore, + Recovery, + Init, +} + +internal sealed record CheckedOutBranchTip(string RefName, string Commit); + +internal enum PullTransitionState +{ + Unchanged, + Applied, + OwnershipLost, + RecoveryFailed, +} + +internal sealed record PullPreflightResult( + RemoteOpResult Result, + bool RequiresTransition, + // The fetched upstream commit a transition would fast-forward to. Null whenever no transition + // is required, because then nothing was verified to fast-forward to. + string? UpstreamCommit); + +internal sealed record FastForwardPullResult( + RemoteOpResult Result, + CheckedOutBranchTip Tip, + PullTransitionState TransitionState = PullTransitionState.Unchanged, + CheckedOutBranchTip? TargetTip = null, + PendingPullRecovery? Recovery = null); + +internal sealed record ProjectCheckpoint( + string RefName, + string Commit, + CheckedOutBranchTip BaseTip); + +internal sealed record PendingPullRecovery( + string Id, + string DescriptorRef, + string DescriptorObject, + ProjectCheckpoint Checkpoint, + CheckedOutBranchTip TargetTip, + string ProjectFile, + DateTimeOffset CreatedAt) +{ + public string RecoveryBranchName => $"beutl/recovery/{Id}"; +} + +internal enum PendingPullRecoveryOutcome +{ + RestoredOriginal, + ReappliedCheckpoint, +} + +internal sealed class PendingPullRecoveryPreservedException : Exception +{ + public PendingPullRecoveryPreservedException(string recoveryReference, Exception? inner = null) + : base( + $"The checkpoint remains available at Git reference '{recoveryReference}', but the worktree could not be changed safely.", + inner) + { + RecoveryReference = recoveryReference; + } + + public string RecoveryReference { get; } +} + +public sealed record ProjectRecoveryInfo( + string Id, + string ProjectFileName, + DateTimeOffset CreatedAt); + +public abstract record ProjectRecoveryResult +{ + private ProjectRecoveryResult() + { + } + + public sealed record RestoredOriginal : ProjectRecoveryResult; + + public sealed record ReappliedCheckpoint(string RecoveryBranchName) : ProjectRecoveryResult; + + public sealed record Declined : ProjectRecoveryResult; + + public sealed record NotFoundOrChanged : ProjectRecoveryResult; + + public sealed record Unavailable : ProjectRecoveryResult; + + public sealed record FailedPreserved(string RecoveryReference) : ProjectRecoveryResult; + + public sealed record FailedUncertain : ProjectRecoveryResult; +} + +internal abstract record BranchTipRollbackResult +{ + private BranchTipRollbackResult() + { + } + + public sealed record RolledBack : BranchTipRollbackResult; + + public sealed record RefChanged(string? ActualCommit) : BranchTipRollbackResult; + + public sealed record UnsafeRepositoryState : BranchTipRollbackResult; +} + +public sealed record CommitInfo( + string Sha, + string ShortSha, + string Subject, + string AuthorName, + DateTimeOffset AuthorDate, + SnapshotKind Kind); + +public enum FileChangeStatus +{ + Added, + Modified, + Deleted, + Renamed, +} + +public sealed record FileChange( + string Path, + FileChangeStatus Status, + string? OldPath = null); + +public sealed record WorkspaceStatus( + string? Branch, + int Ahead, + int Behind, + IReadOnlyList Changes, + bool HasConflicts) +{ + public bool IsClean => Changes.Count == 0; +} + +/// +/// Describes whether the revision created by a successful commit could be observed. +/// +public abstract record CommitRevision +{ + private CommitRevision() + { + } + + /// + /// The successful commit revision was observed. + /// + public sealed record Known : CommitRevision + { + /// + /// Creates an observed commit revision. + /// + /// The non-empty Git object name. + public Known(string sha) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sha); + Sha = sha; + } + + /// + /// Gets the observed Git object name. + /// + public string Sha { get; } + } + + /// + /// The commit succeeded, but its revision could not be observed afterward. + /// + public sealed record Unavailable : CommitRevision; +} + +public abstract record CommitResult +{ + private CommitResult() + { + } + + public sealed record NoChanges : CommitResult; + + /// + /// A durable commit succeeded. records whether its SHA was observed. + /// + public sealed record Committed : CommitResult + { + /// + /// Creates a durable commit result with its revision observation state. + /// + /// The non-null revision observation state. + public Committed(CommitRevision revision) + { + Revision = revision ?? throw new ArgumentNullException(nameof(revision)); + } + + /// + /// Gets the revision observation state for the successful commit. + /// + public CommitRevision Revision { get; } + } + + public sealed record SkippedNoIdentity : CommitResult; +} + +public abstract record RemoteOpResult +{ + private RemoteOpResult() + { + } + + public sealed record Success : RemoteOpResult; + + public sealed record AuthFailed(string Guidance) : RemoteOpResult; + + public sealed record Diverged : RemoteOpResult; + + public sealed record Offline : RemoteOpResult; + + public sealed record RepositoryDirty : RemoteOpResult; + + public sealed record Failed(string Stderr) : RemoteOpResult; +} + +public sealed record BranchInfo(string Name, bool IsCurrent, string? UpstreamName); + +public sealed record RemoteInfo(string Name, string Url); + +public sealed record GitIdentity(string Name, string Email); + +internal abstract record VersionControlPolicyNotice +{ + private VersionControlPolicyNotice() + { + } + + internal sealed record LfsRemoteQuota : VersionControlPolicyNotice; + + internal sealed record LargeMediaWithoutLfs( + string Path, + long SizeBytes) : VersionControlPolicyNotice; + + internal sealed record MissingIdentity : VersionControlPolicyNotice; +} + +public sealed record RepositoryLockInfo( + string LockPath, + DateTimeOffset LastWriteTimeUtc); + +internal sealed record InitOptions( + RepositoryInfo TargetRepository, + bool UseLfsWhenAvailable = true) +{ + public GitIdentity? Identity { get; init; } +} + +internal static partial class GitDiagnosticSanitizer +{ + internal static string RedactCredentials(string value) + { + if (string.IsNullOrEmpty(value)) + { + return value; + } + + string redacted = CredentialUrlRegex().Replace(value, "${scheme}***@"); + return UrlQueryOrFragmentRegex().Replace( + redacted, + "${url}${separator}***"); + } + + [GeneratedRegex( + @"(?[a-z][a-z0-9+.-]*://)[^/\s@]+@", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex CredentialUrlRegex(); + + [GeneratedRegex( + @"(?[a-z][a-z0-9+.-]*://[^\s?#'\""<>]*)(?[?#])[^\s'\""<>]*", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex UrlQueryOrFragmentRegex(); +} + +public sealed class GitOperationException : Exception +{ + public GitOperationException(int exitCode, string stderr) + : base(CreateMessage(exitCode, GitDiagnosticSanitizer.RedactCredentials(stderr))) + { + ExitCode = exitCode; + Stderr = GitDiagnosticSanitizer.RedactCredentials(stderr); + } + + public int ExitCode { get; } + + public string Stderr { get; } + + public bool IsRepositoryLockFailure + => Stderr.Contains("another git process seems to be running", StringComparison.OrdinalIgnoreCase) + || Stderr.Contains("index.lock", StringComparison.OrdinalIgnoreCase) + || Stderr.Contains("HEAD.lock", StringComparison.OrdinalIgnoreCase) + || Stderr.Contains("config.lock", StringComparison.OrdinalIgnoreCase) + || Stderr.Contains("could not lock config file", StringComparison.OrdinalIgnoreCase); + + private static string CreateMessage(int exitCode, string stderr) + { + return string.IsNullOrEmpty(stderr) + ? $"Git exited with code {exitCode}." + : $"Git exited with code {exitCode}: {stderr}"; + } + +} + +public sealed class GitIdentityRequiredException : InvalidOperationException +{ + public GitIdentityRequiredException() + : base("A Git user name and email address are required to create this commit.") + { + } +} + +public sealed class EnclosingRepositoryConsentRequiredException : InvalidOperationException +{ + public EnclosingRepositoryConsentRequiredException(RepositoryInfo repository) + : base( + $"The project is inside the Git repository at '{repository.RepoRoot}'. " + + "Explicit consent is required before Beutl can use that repository.") + { + if (!repository.IsNestedInForeignRepo) + { + throw new ArgumentException( + "The repository must enclose the project root.", + nameof(repository)); + } + + Repository = repository; + } + + public RepositoryInfo Repository { get; } +} + +public sealed class VersionControlConflictedException : InvalidOperationException +{ + public VersionControlConflictedException(string guidance) + : base(guidance) + { + Guidance = guidance; + } + + public string Guidance { get; } +} + +internal sealed class DetachedHeadNotSupportedException : InvalidOperationException +{ + public DetachedHeadNotSupportedException() + : base("This operation requires a checked-out local branch; detached HEAD is not supported.") + { + } +} + +internal sealed class ProjectCheckpointChangedException : InvalidOperationException +{ + public ProjectCheckpointChangedException(string refName) + : base($"The project checkpoint ref '{refName}' changed outside Beutl.") + { + RefName = refName; + } + + public string RefName { get; } +} + +internal sealed class ProjectCheckpointStateChangedException : InvalidOperationException +{ + public ProjectCheckpointStateChangedException() + : base("The project changed after its safety checkpoint was created.") + { + } +} + +internal sealed class ProjectCheckpointStagedChangesException : InvalidOperationException +{ + public ProjectCheckpointStagedChangesException() + : base( + "A safety checkpoint cannot be created while the project contains staged changes.") + { + } +} + +internal sealed class PendingPullRecoveryChangedException : InvalidOperationException +{ + public PendingPullRecoveryChangedException(string refName) + : base($"The pending pull recovery ref '{refName}' changed outside Beutl.") + { + RefName = refName; + } + + public PendingPullRecoveryChangedException(string refName, Exception innerException) + : base( + $"The pending pull recovery ref '{refName}' changed outside Beutl.", + innerException) + { + RefName = refName; + } + + public string RefName { get; } +} diff --git a/src/Beutl.Engine/Media/Proxy/ProxyJobQueue.cs b/src/Beutl.Engine/Media/Proxy/ProxyJobQueue.cs index f89a05aedb..3f1f2841da 100644 --- a/src/Beutl.Engine/Media/Proxy/ProxyJobQueue.cs +++ b/src/Beutl.Engine/Media/Proxy/ProxyJobQueue.cs @@ -22,6 +22,7 @@ public sealed class ProxyJobQueue : IProxyJobQueue private readonly Task _drainTask; private readonly TimeSpan _minUnavailableBackoff; private readonly TimeSpan _maxUnavailableBackoff; + private readonly Func? _tryBeginOutputOperation; private TaskCompletionSource? _resumeAfterGeneratorUnavailable; private int _consecutiveUnavailable; private bool _disposed; @@ -40,8 +41,14 @@ internal ProxyJobQueue( IProxyGenerator generator, IProxyStore? store, TimeSpan minUnavailableBackoff, - TimeSpan maxUnavailableBackoff) - : this(EagerProvider(generator), store, minUnavailableBackoff, maxUnavailableBackoff) + TimeSpan maxUnavailableBackoff, + Func? tryBeginOutputOperation = null) + : this( + EagerProvider(generator), + store, + minUnavailableBackoff, + maxUnavailableBackoff, + tryBeginOutputOperation) { } @@ -58,11 +65,25 @@ public ProxyJobQueue(Func generatorProvider, IProxyStore? stor { } + internal ProxyJobQueue( + Func generatorProvider, + IProxyStore? store, + Func tryBeginOutputOperation) + : this( + generatorProvider, + store, + TimeSpan.FromSeconds(2), + TimeSpan.FromSeconds(30), + tryBeginOutputOperation) + { + } + internal ProxyJobQueue( Func generatorProvider, IProxyStore? store, TimeSpan minUnavailableBackoff, - TimeSpan maxUnavailableBackoff) + TimeSpan maxUnavailableBackoff, + Func? tryBeginOutputOperation = null) { ArgumentNullException.ThrowIfNull(generatorProvider); ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(minUnavailableBackoff, TimeSpan.Zero); @@ -72,6 +93,7 @@ internal ProxyJobQueue( _store = store; _minUnavailableBackoff = minUnavailableBackoff; _maxUnavailableBackoff = maxUnavailableBackoff; + _tryBeginOutputOperation = tryBeginOutputOperation; // Unbounded: each queued item needs exactly one wake permit, and the drain (single reader, // MaxConcurrency 1) consumes one per dispatch. Items already live in _items — deduplicated by // (source, preset) — so the channel is a pure wake signal, not the memory bound. A bounded @@ -411,6 +433,7 @@ private async Task ProcessOneAsync() OnJobChanged(item.Job, ProxyJobChangeKind.Started); bool requeued = false; + bool workspaceBlocked = false; try { // ResolveGenerator runs inside the guarded region: the provider is plugin-supplied @@ -427,11 +450,26 @@ private async Task ProcessOneAsync() } else { - item.Job.StatusMessage = null; - await generator.GenerateAsync(item.Job).ConfigureAwait(false); - item.Job.Status = ProxyJobStatus.Succeeded; - Interlocked.Exchange(ref _consecutiveUnavailable, 0); - OnJobChanged(item.Job, ProxyJobChangeKind.Succeeded); + IDisposable? outputOperation = _tryBeginOutputOperation?.Invoke(); + if (_tryBeginOutputOperation is not null && outputOperation is null) + { + item.Job.StatusMessage = "Waiting for the workspace transition to complete."; + workspaceBlocked = true; + requeued = RequeueForRetry(item); + if (!requeued) + CompleteCanceled(item); + } + else + { + using (outputOperation) + { + item.Job.StatusMessage = null; + await generator.GenerateAsync(item.Job).ConfigureAwait(false); + item.Job.Status = ProxyJobStatus.Succeeded; + Interlocked.Exchange(ref _consecutiveUnavailable, 0); + OnJobChanged(item.Job, ProxyJobChangeKind.Succeeded); + } + } } } catch (ProxyGenerationSkippedException ex) @@ -498,12 +536,32 @@ private async Task ProcessOneAsync() return; } - await WaitForGeneratorResumeOrDisposeAsync(item.Token).ConfigureAwait(false); + if (workspaceBlocked) + { + await WaitForWorkspaceTransitionAsync(item.Token).ConfigureAwait(false); + } + else + { + await WaitForGeneratorResumeOrDisposeAsync(item.Token).ConfigureAwait(false); + } + if (_disposeCts.IsCancellationRequested) return; } } + private async Task WaitForWorkspaceTransitionAsync(CancellationToken jobCancellation) + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource(_disposeCts.Token, jobCancellation); + try + { + await Task.Delay(_minUnavailableBackoff, linked.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + } + private bool RequeueForRetry(WorkItem item) { if (!item.ResetForRetry()) diff --git a/src/Beutl.Extensibility/ContextCommandAttribute.cs b/src/Beutl.Extensibility/ContextCommandAttribute.cs index 9dcc5fcf9e..997a8de065 100644 --- a/src/Beutl.Extensibility/ContextCommandAttribute.cs +++ b/src/Beutl.Extensibility/ContextCommandAttribute.cs @@ -29,6 +29,11 @@ public ContextCommandExecution(string commandName) public string CommandName { get; } public KeyEventArgs? KeyEventArgs { get; set; } + + // A handler that starts asynchronous work publishes it here; the caller that raised the command + // awaits it to know when the operation actually finished. It stays a completed task otherwise, + // so a synchronous handler needs no ceremony. + public Task Completion { get; set; } = Task.CompletedTask; } [AttributeUsage(AttributeTargets.Method)] diff --git a/src/Beutl.Extensibility/OutputExtension.cs b/src/Beutl.Extensibility/OutputExtension.cs index f2c623a7ef..6b53a2b2d1 100644 --- a/src/Beutl.Extensibility/OutputExtension.cs +++ b/src/Beutl.Extensibility/OutputExtension.cs @@ -39,7 +39,10 @@ public abstract class OutputExtension : Extension public abstract bool TryCreateControl(IEditorContext editorContext, [NotNullWhen(true)] out Control? control); - public abstract bool TryCreateContext(IEditorContext editorContext, [NotNullWhen(true)] out IOutputContext? context); + public abstract bool TryCreateContext( + IEditorContext editorContext, + IOutputOperationLeaseProvider outputOperations, + [NotNullWhen(true)] out IOutputContext? context); public abstract bool IsSupported(Type type); } diff --git a/src/Beutl.Extensibility/OutputOperationLeaseProvider.cs b/src/Beutl.Extensibility/OutputOperationLeaseProvider.cs new file mode 100644 index 0000000000..886c28c82a --- /dev/null +++ b/src/Beutl.Extensibility/OutputOperationLeaseProvider.cs @@ -0,0 +1,46 @@ +namespace Beutl.Extensibility; + +/// +/// Coordinates output work with host operations that can replace or mutate the output workspace. +/// +public interface IOutputOperationLeaseProvider +{ + /// + /// Attempts to begin an output operation in the current host. + /// + /// + /// A lease that must be held until the output operation completes, or + /// when the host cannot safely start output work. + /// + IDisposable? TryBeginOutputOperation(); +} + +/// +/// Allows output operations when no editor workspace coordinator is present. +/// +public sealed class StandaloneOutputOperationLeaseProvider : IOutputOperationLeaseProvider +{ + /// + /// Gets the shared standalone provider. + /// + public static StandaloneOutputOperationLeaseProvider Instance { get; } = new(); + + private StandaloneOutputOperationLeaseProvider() + { + } + + /// + public IDisposable TryBeginOutputOperation() + { + return NoOpLease.Instance; + } + + private sealed class NoOpLease : IDisposable + { + public static NoOpLease Instance { get; } = new(); + + public void Dispose() + { + } + } +} diff --git a/src/Beutl.Language/SettingsStrings.ja.resx b/src/Beutl.Language/SettingsStrings.ja.resx index a2cd35dec0..0d307315e7 100644 --- a/src/Beutl.Language/SettingsStrings.ja.resx +++ b/src/Beutl.Language/SettingsStrings.ja.resx @@ -623,4 +623,43 @@ 再インストール + + 新規プロジェクトを既定で追跡 + + + 新規プロジェクト画面に Git の利用可否が表示された後、履歴追跡をあらかじめ選択します + + + 保存時にバージョンを記録 + + + プロジェクトを明示的に保存した後、Git スナップショットを自動作成します + + + 閉じるときにバージョンを記録 + + + 追跡中のプロジェクトを閉じるとき、最後の Git スナップショットを自動作成します + + + Git 実行ファイル + + + 使用する Git 実行ファイルを指定します。空欄の場合は自動検出します + + + 自動 + + + 利用可能な場合は Git LFS を使用 + + + Git LFS がインストールされている場合、対応するメディア形式を Git LFS 用に設定します + + + 大きなメディアの警告しきい値(MB) + + + Git LFS を使わず、このサイズ以上のメディアをコミットする前に警告します + diff --git a/src/Beutl.Language/SettingsStrings.resx b/src/Beutl.Language/SettingsStrings.resx index 0e4991d03f..561e2c913f 100644 --- a/src/Beutl.Language/SettingsStrings.resx +++ b/src/Beutl.Language/SettingsStrings.resx @@ -627,4 +627,43 @@ Reinstall + + Track new projects by default + + + Preselect Git history tracking after Git availability is shown in the new-project dialog + + + Record versions on save + + + Create an automatic Git snapshot after explicit project saves + + + Record a version on close + + + Create a final automatic Git snapshot when a tracked project closes + + + Git executable + + + Use a specific Git executable, or leave blank to detect Git automatically + + + Automatic + + + Use Git LFS when available + + + Configure supported media patterns for Git LFS when Git LFS is installed + + + Large media warning threshold (MB) + + + Warn before committing media at or above this size without Git LFS + diff --git a/src/Beutl.Language/Strings.ja.resx b/src/Beutl.Language/Strings.ja.resx index 5494165ca5..85f9583cee 100644 --- a/src/Beutl.Language/Strings.ja.resx +++ b/src/Beutl.Language/Strings.ja.resx @@ -993,6 +993,9 @@ 移動のフォールバックも失敗しました。タイムラインの状態と表示が一致しない可能性があります。 + + クリップを追加する前にプロジェクトを保存してください。 + Beutlについて @@ -1534,6 +1537,331 @@ GetProperty<T>("path") または GetProperty<T>(guid, "propertyName" {0}: {1} + + バージョン管理 + + + バージョン管理を有効化… + + + バージョン管理を有効化しています… + + + プロジェクトを保存して最初のバージョンを記録しています。プロジェクトが大きい場合は時間がかかることがあります。 + + + このプロジェクトを Git で管理すると、保存時にバージョンが自動的に記録されます。 + + + Git をダウンロード + + + Git で履歴を記録 + + + バージョンをコミット… + + + プッシュ + + + プル + + + 復元 + + + 新しいブランチに復元… + + + 新しいブランチ… + + + 切り替え + + + バージョン履歴 + + + 変更されたファイル + + + コミットメッセージ + + + 手動 + + + 保存 + + + 終了時 + + + 安全スナップショット + + + 復元 + + + 復旧 + + + 初期化 + + + 記録する新しい変更はありません。 + + + コミット + + + バージョンを記録しました。 + + + プロジェクト履歴を記録するには Git が必要です。 + + + Git 2.23 以降が必要です。インストール済みのバージョンは {0} です。 + + + Git for Windows をインストールしてから Beutl を再起動してください。 + + + Xcode コマンドラインツール、または Homebrew で Git をインストールしてから Beutl を再起動してください。 + + + ディストリビューションのパッケージマネージャーで Git をインストールしてから Beutl を再起動してください。 + + + バージョン管理エラー + + + このリポジトリには未解決の競合があります。続行する前に外部の Git ツールで解決してください。 + + + 競合マーカーが検出されました + + + プロジェクト内の次のファイルに未解決の競合マーカーがあります: + +{0} + +編集する前に外部の Git ツールで競合を解決してください。プロジェクトを正しく開けない場合があります。 + + + コミット作成者の設定 + + + 名前 + + + メールアドレス + + + このリポジトリにコミット作成者が設定されていないため、自動スナップショットを一時停止しました。手動バージョンを作成してリポジトリ専用の名前とメールアドレスを設定してから、もう一度保存してください。 + + + プロジェクトを閉じて再度開くため、現在の取り消し履歴は消去されます。未保存の変更は先に保存され、安全スナップショットへ記録されます。続行しますか? + + + リビジョン {0} には現在のプロジェクトファイルが含まれていないため、復元できません。 + + + このリポジトリは Beutl のローカル状態ファイルを既に追跡しています。追跡を外すまで、プルのたびにコミットされていない変更があると報告されます。追跡を外しますか?ファイル自体はディスクに残ります。 + + + 書き出しまたは保存がプロジェクトへ書き込んでいる間は、このバージョン管理操作を実行できません。 + + + 操作に失敗し、元のプロジェクト状態も復元できませんでした。操作エラー: {0} 復旧エラー: {1} + + + 保護されたリポジトリ遷移の所有権が失われたか、復旧を検証できませんでした。バージョン管理の「プロジェクトを復旧」を使用するか、「最近使ったプロジェクト」から再度開くと読み込み前に復旧できます。作業ツリーを安全に変更できない場合、Beutl はチェックポイントを永続的な Git 参照に保持します。 + + + {0} の保護されたプルが {1:g} に正常終了しませんでした。プロジェクトを読み込む前に保存された状態を復旧しますか?復旧で置き換えられるプロジェクト状態は再読み込みされ、取り消し履歴が消去されます。保存されていない変更は破棄されます。 + + + 保護されたプルが正常終了しませんでした。保存されたプロジェクト状態から復旧できます。 + + + プロジェクトを復旧 + + + プルは完了できませんでしたが、保存されたプロジェクト状態を復旧しました。 + + + 保存されたプロジェクト状態をローカル変更として復旧しました。永続的なコピーはブランチ '{0}' に保持されています。 + + + 現在の作業ツリーを安全に変更できませんでした。保存されたプロジェクト状態は Git 参照 '{0}' に保持されています。Git でこの参照を確認してください。 + + + ブランチ: {0} + + + 現在のブランチ + + + ブランチ + + + {0} 先行、{1} 遅延 + + + 変更されたファイル: {0} + + + 作業ツリーに変更はありません + + + さらに読み込む + + + 差分 + + + このプロジェクトは Git で管理されていません。 + + + 記録されたバージョンはありません。 + + + バージョンはまだありません — プロジェクトを保存すると自動的に記録されます + + + 変更内容を表示するバージョンを選択してください + + + 差分を表示するファイルを選択してください + + + たった今 + + + 1分前 + + + {0}分前 + + + 1時間前 + + + {0}時間前 + + + 1日前 + + + {0}日前 + + + ブランチ名 + + + 新しいブランチに復元 + + + ブランチを作成 + + + ブランチを切り替え + + + プロジェクトを閉じてブランチ「{0}」で開き直すため、現在の取り消し履歴は消去されます。未保存の変更は先に保存され、安全スナップショットへ記録されます。続行しますか? + + + このプロジェクトは他のファイルとリポジトリを共有しています。ブランチを切り替えるとリポジトリ全体がチェックアウトされ、プロジェクト外のファイルも変更されます。 + + + fast-forward 更新のプル後にプロジェクトを閉じて開き直すため、現在の取り消し履歴は消去されます。未保存の変更は先に保存され、安全スナップショットへ記録されます。続行しますか? + + + ブランチとリモートの操作は親リポジトリ全体に適用されます: {0} + + + リモート URL + + + リモートを設定… + + + リモートの設定 + + + 最新です + + + ブランチを公開… + + + プル ↓{0} + + + プッシュ ↑{0} + + + リモートに接続しました。 + + + バージョンをプッシュしています… + + + バージョンをプルしています… + + + リモート操作が完了しました。 + + + リモート操作をキャンセルしました。 + + + 大きなメディアは Git LFS で保存されます。リモートホスティングでは、LFS のストレージ容量と帯域幅に上限が適用される場合があります。 + + + {0} は大きなファイルですが、Git LFS が有効ではありません。コミットするとリポジトリ履歴のサイズが恒久的に増える可能性があります。コミットは続行されます。 + + + このプロジェクトは既存の Git リポジトリ内にあります。 + + + このプロジェクトのフォルダーは、Beutl が管理していない既存の Git リポジトリです。このリポジトリでプロジェクトを管理しますか? Beutl 用の除外設定と属性設定が追加され、保存時と終了時にバージョンが記録されます。 + + + 親リポジトリを使用 + + + 管理しない + + + 認証に失敗しました。Git の資格情報ヘルパーまたは SSH エージェントを確認してください。 + + + ローカルとリモートの履歴が分岐しています。外部の Git ツールで解決してください。 + + + プルするにはリポジトリ全体をクリーンにする必要があります。プロジェクト外の変更をコミットまたはスタッシュしてから再試行してください。 + + + リモートに接続できませんでした。ネットワーク接続を確認して再試行してください。 + + + 古い Git リポジトリロックにより、バージョン管理操作が妨げられています。 + + + 古いロックを削除 + + + Git のリポジトリロックが作成から 10 分以上経過しており、Beutl が実行中の Git プロセスはありません。この古いロックを削除しますか? + + + 古い Git リポジトリロックを削除しました。バージョン管理操作を再試行してください。 + + + バージョン管理がプロジェクトファイルを更新しています。処理の完了後に再試行してください。 + {0} - {1} diff --git a/src/Beutl.Language/Strings.resx b/src/Beutl.Language/Strings.resx index af082be71d..9935a54255 100644 --- a/src/Beutl.Language/Strings.resx +++ b/src/Beutl.Language/Strings.resx @@ -998,6 +998,9 @@ The move fallback also failed; the timeline state may not match what you see. + + Save the project before adding clips. + About Beutl @@ -1540,6 +1543,331 @@ Example: GetProperty<double>("{GUID}.Opacity") {0}: {1} + + Version Control + + + Enable Version Control… + + + Enabling version control… + + + Saving the project and recording the first version. This can take a while for a large project. + + + Track this project with Git to record versions automatically when you save. + + + Download Git + + + Track history with Git + + + Commit Version… + + + Push + + + Pull + + + Restore + + + Restore to New Branch… + + + New Branch… + + + Switch + + + Version History + + + Changed Files + + + Commit message + + + Manual + + + Saved + + + Closed + + + Safety Snapshot + + + Restored + + + Recovered + + + Initialized + + + There is nothing new to record. + + + Commit + + + The version was recorded. + + + Git is required to track project history. + + + Git 2.23 or later is required. The installed version is {0}. + + + Install Git for Windows, then restart Beutl. + + + Install the Xcode Command Line Tools or Git with Homebrew, then restart Beutl. + + + Install Git with your distribution's package manager, then restart Beutl. + + + Version Control Error + + + This repository has unresolved conflicts. Resolve them with an external Git tool before continuing. + + + Conflict Markers Detected + + + The project contains unresolved conflict markers in: + +{0} + +Resolve the conflicts with an external Git tool before editing. The project may not open correctly. + + + Set Commit Identity + + + Name + + + Email + + + Automatic version-control snapshots are paused because this repository has no commit identity. Create a manual version to set a repository-local name and email, then save again. + + + The project will close and reopen, and the current undo history will be cleared. Unsaved changes are saved first and recorded in a safety snapshot. Continue? + + + The revision {0} does not contain the current project file, so it cannot be restored. + + + This repository already tracks Beutl's local state files. Until they stop being tracked, every pull will report the repository as having uncommitted changes. Stop tracking them? The files stay on disk. + + + This version control operation is unavailable while an export or a save is writing to the project. + + + The operation failed, and the original project state could not be recovered. Operation error: {0} Recovery error: {1} + + + The protected repository transition lost ownership or could not verify recovery. Use Recover Project in Version Control, or reopen the project from Recent Projects to recover it before loading. If Beutl cannot safely change the worktree, it preserves the checkpoint at a durable Git reference. + + + A protected pull for {0} did not finish cleanly at {1:g}. Recover the saved project state before loading the project? Any project state replaced by recovery will be reloaded, clearing its undo history, and changes that were never saved are discarded. + + + A protected pull did not finish cleanly. A saved project state is available for recovery. + + + Recover Project + + + The pull could not be completed, but the saved project state was recovered. + + + The saved project state was restored as local changes. A durable copy remains on branch '{0}'. + + + Beutl could not safely change the current worktree. The saved project state remains at Git reference '{0}'. Inspect that reference with Git. + + + Branch: {0} + + + Current branch + + + Branches + + + {0} ahead, {1} behind + + + {0} changed file(s) + + + Working tree clean + + + Load More + + + Diff + + + This project is not tracked with Git. + + + No versions have been recorded yet. + + + No versions yet — saving the project records one automatically + + + Select a version to see its changes + + + Select a file to see its diff + + + Just now + + + 1 minute ago + + + {0} minutes ago + + + 1 hour ago + + + {0} hours ago + + + 1 day ago + + + {0} days ago + + + Branch name + + + Restore to a New Branch + + + Create Branch + + + Switch Branch + + + The project will close and reopen on branch "{0}", and the current undo history will be cleared. Unsaved changes are saved first and recorded in a safety snapshot. Continue? + + + This project shares a repository with other files. Switching branches checks the whole repository out, so files outside the project change too. + + + The project will close and reopen after pulling fast-forward updates, and the current undo history will be cleared. Unsaved changes are saved first and recorded in a safety snapshot. Continue? + + + Branch and remote operations apply to the enclosing repository: {0} + + + Remote URL + + + Set remote… + + + Set Remote + + + Up to date + + + Publish branch… + + + Pull ↓{0} + + + Push ↑{0} + + + The remote was connected. + + + Pushing versions… + + + Pulling versions… + + + The remote operation completed. + + + The remote operation was canceled. + + + Large media is stored with Git LFS. Remote hosting quotas may apply to LFS storage and bandwidth. + + + {0} is large and Git LFS is not active. Committing it can permanently increase repository history size. The commit will continue. + + + This project is inside an existing Git repository. + + + This project directory is already a Git repository that Beutl does not manage. Track this project with it? Beutl will add its own ignore and attribute rules and record a version when you save or close. + + + Use Enclosing Repository + + + Leave Unmanaged + + + Authentication failed. Check your Git credential helper or SSH agent. + + + The local and remote histories have diverged. Resolve this with an external Git tool. + + + Pull requires the entire repository to be clean. Commit or stash changes outside the project, then try again. + + + The remote could not be reached. Check your network connection and try again. + + + A stale Git repository lock is blocking version control operations. + + + Remove Stale Lock + + + Git left a repository lock more than 10 minutes ago, and Beutl has no active Git process. Remove the stale lock? + + + The stale Git repository lock was removed. Retry the version control operation. + + + Project files are being updated by version control. Wait for the operation to finish, then try again. + {0} - {1} diff --git a/src/Beutl.ProjectSystem/ProjectSystem/Element.cs b/src/Beutl.ProjectSystem/ProjectSystem/Element.cs index b96cfdb2fd..42d604ad09 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/Element.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/Element.cs @@ -69,6 +69,8 @@ public Element() public event EventHandler? Edited; + internal bool WasMigratedFromOperation { get; private set; } + // 0以上 [Display(Name = nameof(Strings.StartTime), ResourceType = typeof(Strings))] public TimeSpan Start @@ -189,10 +191,10 @@ public override void Deserialize(ICoreSerializationContext context) } else if (context is IJsonSerializationContext jsonContext) { - EngineObject[] migrated = ElementMigration.MigrateFromOperation(jsonContext); - if (migrated.Length > 0) + if (ElementMigration.TryMigrateFromOperation(jsonContext, out EngineObject[] migrated)) { Objects.Replace(migrated); + WasMigratedFromOperation = true; } } } diff --git a/src/Beutl.ProjectSystem/ProjectSystem/ElementMigration.cs b/src/Beutl.ProjectSystem/ProjectSystem/ElementMigration.cs index 779edd0998..c4ceacaf55 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/ElementMigration.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/ElementMigration.cs @@ -39,13 +39,21 @@ internal static class ElementMigration new KeyValuePair("[Beutl.Operators].Source:PointLight3DOperator", typeof(PointLight3D)), new KeyValuePair("[Beutl.Operators].Source:SpotLight3DOperator", typeof(SpotLight3D))); - internal static EngineObject[] MigrateFromOperation(IJsonSerializationContext jsonContext) + internal static bool TryMigrateFromOperation( + IJsonSerializationContext jsonContext, + out EngineObject[] migrated) { if (jsonContext.GetNode("Operation") is not JsonObject operation) - return []; + { + migrated = []; + return false; + } if (operation["Children"] is not JsonArray children) - return []; + { + migrated = []; + return false; + } var results = new List(); foreach (JsonNode? child in children) @@ -57,7 +65,8 @@ internal static EngineObject[] MigrateFromOperation(IJsonSerializationContext js results.Add(engineObject); } - return results.ToArray(); + migrated = results.ToArray(); + return true; } private static EngineObject ExtractEngineObjectFromOperator(JsonObject operatorObj, diff --git a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs index 6668d59fc9..aad577edff 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs @@ -59,6 +59,12 @@ public class Scene : ProjectItem, INotifyEdited private TimeSpan _duration = TimeSpan.FromMinutes(5); private PixelSize _frameSize; + protected internal override bool HasMigratedPersistedContent + => WasTypeDiscriminatorAddedDuringRestore + || Children.Any(static element => + element.WasTypeDiscriminatorAddedDuringRestore + || element.WasMigratedFromOperation); + public Scene() : this(1920, 1080, string.Empty) { @@ -517,14 +523,14 @@ static void Process(JsonObject jobject, string jsonName, List list) { if (list.Count == 1) { - jobject[jsonName] = JsonValue.Create(list[0]); + jobject[jsonName] = JsonValue.Create(NormalizeElementPattern(list[0])); } else if (list.Count >= 2) { var jarray = new JsonArray(); foreach (string item in list) { - jarray.Add(JsonValue.Create(item)); + jarray.Add(JsonValue.Create(NormalizeElementPattern(item))); } jobject[jsonName] = jarray; @@ -575,6 +581,7 @@ static void Process(Func add, JsonNode node, List list) if (node is JsonValue jvalue && jvalue.TryGetValue(out string? pattern)) { + pattern = NormalizeElementPattern(pattern); list.Add(pattern); add(pattern); } @@ -584,6 +591,7 @@ static void Process(Func add, JsonNode node, List list) { if (item.TryGetValue(out pattern)) { + pattern = NormalizeElementPattern(pattern); list.Add(pattern); add(pattern); } @@ -689,7 +697,7 @@ private void UpdateInclude() string[] files = matcher.Execute(directory).Files.Select(x => x.Path).ToArray(); foreach (Element item in Children) { - string rel = Path.GetRelativePath(dirPath, item.Uri!.LocalPath); + string rel = NormalizeElementPattern(Path.GetRelativePath(dirPath, item.Uri!.LocalPath)); // 含まれていない場合追加 if (!files.Contains(rel)) @@ -715,7 +723,7 @@ ImmutableArray.Builder affectedRange foreach (Element item in e.OldItems.OfType()) { string itemPath = item.Uri!.LocalPath; - string rel = Path.GetRelativePath(dirPath, itemPath); + string rel = NormalizeElementPattern(Path.GetRelativePath(dirPath, itemPath)); if (!_excludeElements.Contains(rel) && File.Exists(itemPath)) { @@ -731,7 +739,7 @@ ImmutableArray.Builder affectedRange foreach (Element item in e.NewItems.OfType()) { string itemPath = item.Uri!.LocalPath; - string rel = Path.GetRelativePath(dirPath, itemPath); + string rel = NormalizeElementPattern(Path.GetRelativePath(dirPath, itemPath)); if (_excludeElements.Contains(rel) && File.Exists(itemPath)) { @@ -745,6 +753,15 @@ ImmutableArray.Builder affectedRange Edited?.Invoke(this, new ElementEditedEventArgs { AffectedRange = affectedRange.DrainToImmutable() }); } + private static string NormalizeElementPattern(string pattern) + { + // On Windows a backslash is a directory separator; on Unix it is a + // literal filename character and must survive serialization unchanged. + return OperatingSystem.IsWindows() + ? pattern.Replace('\\', '/') + : pattern; + } + private void Layers_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { // Only a layer that carries a compositional flag changes the rendered diff --git a/src/Beutl/AgentHost/AgentHostEndpoint.cs b/src/Beutl/AgentHost/AgentHostEndpoint.cs index 0d3bba2419..c5fef21a85 100644 --- a/src/Beutl/AgentHost/AgentHostEndpoint.cs +++ b/src/Beutl/AgentHost/AgentHostEndpoint.cs @@ -7,6 +7,7 @@ using Beutl.AgentToolkit.Tools; using Beutl.AgentToolkit.Workspace; using Beutl.Configuration; +using Beutl.Extensibility; using Beutl.Logging; using Beutl.Services; using Microsoft.AspNetCore.Builder; @@ -25,15 +26,22 @@ public sealed class AgentHostEndpoint : IAsyncDisposable { internal const int DefaultPort = 59737; - private static readonly TimeSpan s_shutdownTimeout = TimeSpan.FromSeconds(2); private static readonly ILogger s_logger = Log.CreateLogger(); private readonly ProjectService _projectService; private readonly EditorService _editorService; private readonly AiAgentConfig _config; private readonly int _preferredPort; + private readonly Func _beforeStopAsync; + private readonly Func _afterStartAsync; + private readonly Func _renderJobManagerFactory; + private readonly Func? _admissionProbe; + private readonly AdmissionGate _admissionGate = new(); private readonly object _lifecycleLock = new(); private bool _stopRequested; private WebApplication? _application; + private Task? _startTask; + private Task? _drainTask; + private Task? _prepareTask; public AgentHostEndpoint(ProjectService projectService, EditorService editorService) : this(projectService, editorService, GlobalConfiguration.Instance.AiAgentConfig) @@ -41,7 +49,16 @@ public AgentHostEndpoint(ProjectService projectService, EditorService editorServ } internal AgentHostEndpoint(ProjectService projectService, EditorService editorService, AiAgentConfig config) - : this(projectService, editorService, DefaultPort, ResolveToken(config), config) + : this( + projectService, + editorService, + DefaultPort, + ResolveToken(config), + config, + beforeStopAsync: null, + afterStartAsync: null, + renderJobManagerFactory: null, + admissionProbe: null) { } @@ -91,7 +108,36 @@ internal static string ResolveWorkspaceRoot(AiAgentConfig config) } internal AgentHostEndpoint(ProjectService projectService, EditorService editorService, int preferredPort, string token) - : this(projectService, editorService, preferredPort, token, GlobalConfiguration.Instance.AiAgentConfig) + : this( + projectService, + editorService, + preferredPort, + token, + GlobalConfiguration.Instance.AiAgentConfig, + static _ => Task.CompletedTask, + admissionProbe: null) + { + } + + internal AgentHostEndpoint( + ProjectService projectService, + EditorService editorService, + int preferredPort, + string token, + Func beforeStopAsync, + Func? afterStartAsync = null, + Func? renderJobManagerFactory = null, + Func? admissionProbe = null) + : this( + projectService, + editorService, + preferredPort, + token, + GlobalConfiguration.Instance.AiAgentConfig, + beforeStopAsync, + afterStartAsync, + renderJobManagerFactory, + admissionProbe) { } @@ -100,7 +146,11 @@ private AgentHostEndpoint( EditorService editorService, int preferredPort, string token, - AiAgentConfig config) + AiAgentConfig config, + Func? beforeStopAsync = null, + Func? afterStartAsync = null, + Func? renderJobManagerFactory = null, + Func? admissionProbe = null) { ArgumentNullException.ThrowIfNull(config); @@ -118,6 +168,10 @@ private AgentHostEndpoint( _editorService = editorService; _config = config; _preferredPort = preferredPort; + _beforeStopAsync = beforeStopAsync ?? (static _ => Task.CompletedTask); + _afterStartAsync = afterStartAsync ?? (static _ => Task.CompletedTask); + _renderJobManagerFactory = renderJobManagerFactory ?? (static () => new RenderJobManager()); + _admissionProbe = admissionProbe; Token = token; } @@ -125,14 +179,83 @@ private AgentHostEndpoint( public Uri? EndpointUri { get; private set; } - public bool IsRunning => _application is not null; + public bool IsRunning + { + get + { + lock (_lifecycleLock) + { + return !_stopRequested && _application is not null; + } + } + } - public async Task StartAsync(CancellationToken cancellationToken = default) + public Task StartAsync(CancellationToken cancellationToken = default) { + TaskCompletionSource completion; lock (_lifecycleLock) { // A stop requested before (or during) startup must win: never start after RequestStop. - if (_application is not null || _stopRequested) + if (_stopRequested) + { + return Task.CompletedTask; + } + + // Retain the single startup task so a concurrent stop can join startup before reporting + // that the endpoint is fully drained. + if (_startTask is not null) + { + return _startTask; + } + + completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _startTask = completion.Task; + } + + _ = CompleteStartAsync(completion, cancellationToken); + return completion.Task; + } + + private async Task CompleteStartAsync(TaskCompletionSource completion, CancellationToken cancellationToken) + { + try + { + await StartCoreAsync(cancellationToken).ConfigureAwait(false); + completion.TrySetResult(); + } + catch (OperationCanceledException ex) + { + ResetFailedStartAttempt(completion.Task); + completion.TrySetCanceled(ex.CancellationToken); + } + catch (Exception ex) + { + ResetFailedStartAttempt(completion.Task); + completion.TrySetException(ex); + } + } + + private void ResetFailedStartAttempt(Task startTask) + { + lock (_lifecycleLock) + { + // Preserve the task when a concurrent stop already captured it for draining. Otherwise + // a transient cancellation or startup failure must not make this endpoint permanently + // unable to retry. + if (!_stopRequested && ReferenceEquals(_startTask, startTask)) + { + _startTask = null; + } + } + } + + private async Task StartCoreAsync(CancellationToken cancellationToken) + { + lock (_lifecycleLock) + { + // RequestStop may win after StartAsync publishes its retained task but before this + // runner begins. In that case there is no host to create or drain. + if (_stopRequested) { return; } @@ -146,6 +269,7 @@ public async Task StartAsync(CancellationToken cancellationToken = default) try { await app.StartAsync(cancellationToken).ConfigureAwait(false); + await _afterStartAsync(cancellationToken).ConfigureAwait(false); string address = app.Services .GetRequiredService() @@ -160,28 +284,32 @@ public async Task StartAsync(CancellationToken cancellationToken = default) lock (_lifecycleLock) { stopRequested = _stopRequested; + // The canonical drain takes ownership after the retained startup task + // completes. Keep the app reachable even when stop won during startup. + _application = app; if (!stopRequested) { - _application = app; - // Publish EndpointUri only after the stop check: TakeApplication already + // Publish EndpointUri only after the stop check: RequestStopCore already // cleared it (while still null), so setting it before this check would leave // a dead URL visible to the settings page after a stop-during-startup race. EndpointUri = endpointUri; } } - // RequestStop ran while app.StartAsync was in flight (so it couldn't see/take - // _application): stop the just-started host here instead of leaving it running. - if (stopRequested) - { - await StopAndDisposeAsync(app, cancellationToken).ConfigureAwait(false); - } - return; } catch (Exception ex) when (IsAddressInUse(ex)) { await app.DisposeAsync().ConfigureAwait(false); + + lock (_lifecycleLock) + { + if (_stopRequested) + { + return; + } + } + if (port >= IPEndPoint.MaxPort) { throw; @@ -214,28 +342,155 @@ public void StartInBackground() }); } - public async Task StopAsync(CancellationToken cancellationToken = default) + public Task StopAsync(CancellationToken cancellationToken = default) + { + Task drain = RequestStopCore(); + return cancellationToken.CanBeCanceled + ? drain.WaitAsync(cancellationToken) + : drain; + } + + public void RequestStop() + { + _ = ObserveDrainFailureAsync(RequestStopCore()); + } + + public ValueTask DisposeAsync() { - WebApplication? app = TakeApplication(); + return new ValueTask(RequestStopCore()); + } - if (app is not null) + /// + /// Reversibly pauses authenticated MCP ingress and drains detached render jobs. Disposing the + /// returned scope resumes ingress unless was called. + /// + internal Task BeginShutdownDrainAsync( + CancellationToken cancellationToken = default) + { + TaskCompletionSource completion; + Task? startTask; + lock (_lifecycleLock) { - await StopAndDisposeAsync(app, cancellationToken).ConfigureAwait(false); + if (_prepareTask is not null) + { + throw new InvalidOperationException("Agent host shutdown preparation is already in progress."); + } + + if (_stopRequested) + { + return Task.FromResult(new AgentHostShutdownScope(this)); + } + + startTask = _startTask; + completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + _prepareTask = completion.Task; } + + _ = CompletePrepareAsync(completion, startTask, cancellationToken); + return completion.Task; } - public void RequestStop() + private async Task CompletePrepareAsync( + TaskCompletionSource completion, + Task? startTask, + CancellationToken cancellationToken) { - WebApplication? app = TakeApplication(); - if (app is not null) + try + { + completion.TrySetResult(await PrepareCoreAsync(startTask, cancellationToken).ConfigureAwait(false)); + } + catch (Exception ex) { - _ = StopAndDisposeWithTimeoutAsync(app); + lock (_lifecycleLock) + { + if (ReferenceEquals(_prepareTask, completion.Task)) + { + _prepareTask = null; + } + } + + if (ex is OperationCanceledException canceled) + { + completion.TrySetCanceled(canceled.CancellationToken); + } + else + { + completion.TrySetException(ex); + } } } - public async ValueTask DisposeAsync() + private async Task PrepareCoreAsync( + Task? startTask, + CancellationToken cancellationToken) { - await StopAsync().ConfigureAwait(false); + try + { + if (startTask is not null) + { + try + { + await startTask.ConfigureAwait(false); + } + catch when (_stopRequested) + { + } + } + + await _admissionGate.PauseAndDrainAsync(cancellationToken).ConfigureAwait(false); + + WebApplication? app; + lock (_lifecycleLock) + { + app = _application; + } + + if (app is not null) + { + await app.Services + .GetRequiredService() + .CancelAndDrainAsync() + .ConfigureAwait(false); + } + + var scope = new AgentHostShutdownScope(this); + lock (_lifecycleLock) + { + if (_stopRequested) + { + scope.Commit(); + } + } + + return scope; + } + catch + { + lock (_lifecycleLock) + { + if (!_stopRequested) + { + _admissionGate.Resume(); + _prepareTask = null; + } + } + + throw; + } + } + + private void ReleaseShutdownScope(AgentHostShutdownScope scope, bool committed) + { + lock (_lifecycleLock) + { + _prepareTask = null; + + if (!committed && !_stopRequested) + { + _admissionGate.Resume(); + } + } } private WebApplication CreateApplication(int port) @@ -261,6 +516,7 @@ private WebApplication CreateApplication(int port) .AddSingleton(_ => new CreativeMemoryStore(workspaceRoot)) .AddSingleton() .AddSingleton(_ => new WorkspaceGuard(workspaceRoot)) + .AddSingleton(_ => _editorService) .AddSingleton() .AddSingleton() .AddSingleton() @@ -269,7 +525,7 @@ private WebApplication CreateApplication(int port) .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton(); + .AddSingleton(_ => _renderJobManagerFactory()); builder.Services .AddMcpServer() @@ -283,57 +539,263 @@ private WebApplication CreateApplication(int port) .WithTools(); WebApplication app = builder.Build(); + // Give the background-job manager the same explicit lifetime as this host. Resolving it + // here also prevents shutdown from constructing a never-used manager only to dispose it. + _ = app.Services.GetRequiredService(); app.Use(RequireToken); + app.Use(AdmissionMiddleware); app.MapMcp("/mcp"); return app; } - // Latch _stopRequested and take the app in the same critical section StartAsync uses to publish - // it, so a stop during an in-flight startup is never dropped (StartAsync re-checks the latch - // before publishing and stops the host itself if it lost the race). - private WebApplication? TakeApplication() + // Latch ingress and publish one canonical, failure-bearing drain task in the same critical + // section StartAsync uses. Caller cancellation only limits StopAsync's wait; it never abandons + // the retained cleanup that every later stop/dispose call joins. + private Task RequestStopCore() { - WebApplication? app; + TaskCompletionSource completion; + Task? startTask; + Task? prepareTask; lock (_lifecycleLock) { _stopRequested = true; - app = _application; - _application = null; + EndpointUri = null; + _admissionGate.Pause(); + + if (_drainTask is not null) + { + return _drainTask; + } + + startTask = _startTask; + prepareTask = _prepareTask; + completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _drainTask = completion.Task; } - EndpointUri = null; - return app; + _ = CompleteDrainAsync(completion, startTask, prepareTask); + return completion.Task; } - private static async Task StopAndDisposeWithTimeoutAsync(WebApplication app) + private async Task CompleteDrainAsync( + TaskCompletionSource completion, + Task? startTask, + Task? prepareTask) { - using var cts = new CancellationTokenSource(s_shutdownTimeout); - try { - await StopAndDisposeAsync(app, cts.Token).ConfigureAwait(false); + await DrainCoreAsync(startTask, prepareTask).ConfigureAwait(false); + completion.TrySetResult(); + } + catch (OperationCanceledException ex) + { + completion.TrySetCanceled(ex.CancellationToken); + } + catch (Exception ex) + { + completion.TrySetException(ex); + } + } + + private async Task DrainCoreAsync( + Task? startTask, + Task? prepareTask) + { + if (startTask is not null) + { + try + { + await startTask.ConfigureAwait(false); + } + catch (Exception ex) + { + // StartAsync owns reporting startup failures. Its cleanup completes before the + // startup task faults, so observing it here is sufficient for lifecycle quiescence. + _ = ex; + } + } + + if (prepareTask is not null) + { + try + { + await prepareTask.ConfigureAwait(false); + } + catch when (_stopRequested) + { + } + } + + await _admissionGate.PauseAndDrainAsync(CancellationToken.None).ConfigureAwait(false); + + WebApplication? app; + lock (_lifecycleLock) + { + app = _application; + _application = null; } - catch (OperationCanceledException) + + if (app is null) { + return; } - catch (ObjectDisposedException) + + await StopAndDisposeAsync(app, CancellationToken.None).ConfigureAwait(false); + } + + private static async Task ObserveDrainFailureAsync(Task drain) + { + try { + await drain.ConfigureAwait(false); } catch (Exception ex) { - _ = ex; + s_logger.LogError(ex, "The agent host endpoint failed to drain."); } } - private static async Task StopAndDisposeAsync(WebApplication app, CancellationToken cancellationToken) + private async Task StopAndDisposeAsync(WebApplication app, CancellationToken cancellationToken) { try { + await _beforeStopAsync(cancellationToken).ConfigureAwait(false); await app.StopAsync(cancellationToken).ConfigureAwait(false); } finally { - await app.DisposeAsync().ConfigureAwait(false); + try + { + // Background jobs outlive their initiating MCP request. Cancel and await every + // terminal path before the host releases the project/editor services they lease. + await app.Services + .GetRequiredService() + .DisposeAsync() + .ConfigureAwait(false); + } + finally + { + await app.DisposeAsync().ConfigureAwait(false); + } + } + } + + internal sealed class AgentHostShutdownScope : IDisposable, IAsyncDisposable + { + private readonly AgentHostEndpoint _owner; + private int _released; + private bool _committed; + + internal AgentHostShutdownScope(AgentHostEndpoint owner) + { + _owner = owner; + } + + internal void Commit() => _committed = true; + + public void Dispose() + { + if (Interlocked.Exchange(ref _released, 1) == 0) + { + _owner.ReleaseShutdownScope(this, _committed); + } + } + + public ValueTask DisposeAsync() + { + Dispose(); + return ValueTask.CompletedTask; + } + } + + private sealed class AdmissionGate + { + private readonly object _sync = new(); + private bool _paused; + private int _active; + private TaskCompletionSource? _drained; + + public Lease? TryEnter() + { + lock (_sync) + { + if (_paused) + { + return null; + } + + _active++; + var lease = new Lease(this); + return lease; + } + } + + public void Pause() + { + lock (_sync) + { + _paused = true; + if (_active == 0) + { + _drained?.TrySetResult(); + _drained = null; + } + } + } + + public Task PauseAndDrainAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Task drained; + lock (_sync) + { + _paused = true; + if (_active == 0) + { + return Task.CompletedTask; + } + + _drained ??= new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + drained = _drained.Task; + } + + return cancellationToken.CanBeCanceled ? drained.WaitAsync(cancellationToken) : drained; + } + + public void Resume() + { + lock (_sync) + { + _paused = false; + } + } + + private void Release(Lease lease) + { + lock (_sync) + { + if (lease.Released) + { + return; + } + + lease.Released = true; + _active--; + if (_active == 0) + { + _drained?.TrySetResult(); + _drained = null; + } + + } + } + + public sealed class Lease : IDisposable + { + private readonly AdmissionGate _owner; + internal Lease(AdmissionGate owner) => _owner = owner; + internal bool Released { get; set; } + public void Dispose() => _owner.Release(this); } } @@ -373,6 +835,30 @@ private async Task RequireToken(HttpContext context, RequestDelegate next) await next(context).ConfigureAwait(false); } + private async Task AdmissionMiddleware(HttpContext context, RequestDelegate next) + { + AdmissionGate.Lease? lease = _admissionGate.TryEnter(); + if (lease is null) + { + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + context.Response.Headers.RetryAfter = "1"; + return; + } + + try + { + if (_admissionProbe is { } probe) + { + await probe().ConfigureAwait(false); + } + await next(context).ConfigureAwait(false); + } + finally + { + lease.Dispose(); + } + } + // Constant-time compare: the token drives the editing surface even on loopback. private static bool FixedTimeTokenEquals(string provided, string expected) { diff --git a/src/Beutl/AgentHost/EditViewModelLiveBinding.cs b/src/Beutl/AgentHost/EditViewModelLiveBinding.cs index caa1cc694f..f09e64af07 100644 --- a/src/Beutl/AgentHost/EditViewModelLiveBinding.cs +++ b/src/Beutl/AgentHost/EditViewModelLiveBinding.cs @@ -12,7 +12,11 @@ public sealed class EditViewModelLiveBinding(EditViewModel editViewModel) : ILiv public HistoryManager? ActiveHistory => editViewModel.HistoryManager; - public bool IsAlive => editViewModel.Scene is not null; + // A version-control transition suspends the editors from before its pre-transition save until + // the project closes. An edit accepted in that window would only reach the in-memory scene and + // be discarded by the close, so the session counts as unavailable for exactly as long as the + // editor is disabled. + public bool IsAlive => editViewModel.Scene is not null && editViewModel.IsEnabled.Value; public void Invoke(Action action) { diff --git a/src/Beutl/AgentHost/EditorProjectSessionGateway.cs b/src/Beutl/AgentHost/EditorProjectSessionGateway.cs index afdba1c86d..4a4f978f3e 100644 --- a/src/Beutl/AgentHost/EditorProjectSessionGateway.cs +++ b/src/Beutl/AgentHost/EditorProjectSessionGateway.cs @@ -3,6 +3,7 @@ using Beutl.AgentToolkit.Reconciliation; using Beutl.AgentToolkit.Sessions; using Beutl.AgentToolkit.Workspace; +using Beutl.Editor.VersionControl; using Beutl.ProjectSystem; using Beutl.Services; using Beutl.ViewModels; @@ -65,7 +66,12 @@ await Dispatcher.UIThread.InvokeAsync(() => }); Project project = ProjectOperations.CreateProject(options); - ProjectOperations.Save(project); + using (IProjectFileWriteLease fileWrite = await editorService.BeginProjectFileWriteAsync( + cancellationToken).ConfigureAwait(false)) + { + ProjectOperations.Save(project); + } + return await OpenProjectAsync(fullPath, cancellationToken).ConfigureAwait(false); } @@ -92,6 +98,18 @@ public async ValueTask AddSceneAsync(IEditingSession activeS // configured root. Enforce the boundary before mutating the live project so a rejected // write leaves no unsaved scene behind in the UI. workspace.ResolveForWrite(project.Uri!.LocalPath); + // This runs on the UI thread and cannot park waiting for the workspace, so a reserved + // one is reported rather than written into. + using IProjectFileWriteLease? fileWrite = editorService.TryBeginProjectFileWrite(); + if (fileWrite is null) + { + throw new ReconcileException(new ToolError( + ErrorCode.WorkspaceBusy, + "The Beutl editor has the project workspace reserved for a save, an export, or a version control operation; the project cannot be written right now.", + project.Uri!.LocalPath, + "Retry once that operation finishes.")); + } + ProjectUriState uriState = ProjectOperations.CaptureUriState(project); Scene scene = ProjectOperations.AddScene(project, options); try diff --git a/src/Beutl/Pages/SettingsPages/EditorSettingsPage.axaml b/src/Beutl/Pages/SettingsPages/EditorSettingsPage.axaml index 625e6cf890..826be262ea 100644 --- a/src/Beutl/Pages/SettingsPages/EditorSettingsPage.axaml +++ b/src/Beutl/Pages/SettingsPages/EditorSettingsPage.axaml @@ -97,6 +97,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Beutl/Services/EditorService.cs b/src/Beutl/Services/EditorService.cs index 2571195cbe..428eb11ece 100644 --- a/src/Beutl/Services/EditorService.cs +++ b/src/Beutl/Services/EditorService.cs @@ -1,8 +1,12 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; using System.Security.Cryptography; +using Avalonia.Threading; using Beutl.Api.Services; using Beutl.Configuration; +using Beutl.Editor; +using Beutl.Editor.VersionControl; +using Beutl.Serialization; using Reactive.Bindings; namespace Beutl.Services; @@ -67,23 +71,363 @@ public async ValueTask DisposeAsync() } } -public sealed class EditorService +public sealed class EditorService : IOutputOperationLeaseProvider { private readonly CoreList _tabItems; private readonly ExtensionProvider _extensionProvider; + private readonly Action _serializeProject; + private readonly ReactivePropertySlim + _projectVersionControlService = new(); + private readonly object _workspaceOperationSync = new(); + private readonly SemaphoreSlim _projectFileWriteGate = new(1, 1); + private TaskCompletionSource? _worktreeMutationCompletion; + private int _activeOutputOperations; + private int _activeProjectFileWrites; + private bool _worktreeMutationActive; public EditorService(ExtensionProvider extensionProvider) + : this( + extensionProvider, + static (project, uri) => CoreSerializer.StoreToUri(project, uri)) + { + } + + internal EditorService( + ExtensionProvider extensionProvider, + Action serializeProject) { ArgumentNullException.ThrowIfNull(extensionProvider); + ArgumentNullException.ThrowIfNull(serializeProject); _extensionProvider = extensionProvider; + _serializeProject = serializeProject; _tabItems = new() { ResetBehavior = ResetBehavior.Remove }; + ProjectVersionControlService = _projectVersionControlService + .ToReadOnlyReactivePropertySlim(); } public ICoreList TabItems => _tabItems; public IReactiveProperty SelectedTabItem { get; } = new ReactivePropertySlim(); + internal IReadOnlyReactiveProperty + ProjectVersionControlService + { get; } + + internal IProjectVersionControlCoordinator? ProjectVersionControlCoordinator { get; set; } + + internal bool IsWorktreeMutationActive + { + get + { + lock (_workspaceOperationSync) + { + return _worktreeMutationActive; + } + } + } + + internal void PublishProjectVersionControlService( + IProjectVersionControlService? service) + { + _projectVersionControlService.Value = service; + } + + internal IDisposable? TryBeginOutputOperation() + { + lock (_workspaceOperationSync) + { + if (_worktreeMutationActive) + { + return null; + } + + _activeOutputOperations++; + return new WorkspaceOperationLease(this, WorkspaceOperationKind.Output); + } + } + + IDisposable? IOutputOperationLeaseProvider.TryBeginOutputOperation() + { + return TryBeginOutputOperation(); + } + + /// + /// Reserves the workspace for an operation that reads the whole project directory, such as a + /// package export. + /// + /// + /// An output lease alone only keeps version-control transitions out; a save writing the project + /// file and its sidecars would still run underneath, and the copy could take those files from + /// different save points. The project-file write reservation is therefore held for the same + /// interval, which serializes the read against every writer through the existing gate. Taking + /// the output lease first is what makes the wait bounded: no worktree mutation can start while + /// it is held, so this can only wait for a save that is already running. + /// + internal async Task TryBeginProjectDirectoryReadAsync( + CancellationToken cancellationToken) + { + IDisposable? outputOperation = TryBeginOutputOperation(); + if (outputOperation is null) + { + return null; + } + + try + { + IProjectFileWriteLease fileWrite = await BeginProjectFileWriteAsync(cancellationToken); + return new ProjectDirectoryReadLease(fileWrite, outputOperation); + } + catch + { + outputOperation.Dispose(); + throw; + } + } + + internal async ValueTask BeginProjectFileWriteAsync( + CancellationToken cancellationToken) + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + Task waitForWorktreeMutation; + lock (_workspaceOperationSync) + { + waitForWorktreeMutation = _worktreeMutationActive + ? _worktreeMutationCompletion?.Task ?? Task.CompletedTask + : Task.CompletedTask; + } + + // The gate is taken only once the workspace already looks free. Waiting for a worktree + // mutation while holding it would park auto-save and editor teardown behind this writer. + await waitForWorktreeMutation.WaitAsync(cancellationToken); + await _projectFileWriteGate.WaitAsync(cancellationToken); + lock (_workspaceOperationSync) + { + if (!_worktreeMutationActive) + { + _activeProjectFileWrites++; + return new WorkspaceOperationLease( + this, + WorkspaceOperationKind.ProjectFileWrite); + } + } + + _projectFileWriteGate.Release(); + } + } + + internal IProjectFileWriteLease? TryBeginProjectFileWrite() + { + if (!_projectFileWriteGate.Wait(0)) + { + return null; + } + + lock (_workspaceOperationSync) + { + if (!_worktreeMutationActive) + { + _activeProjectFileWrites++; + return new WorkspaceOperationLease( + this, + WorkspaceOperationKind.ProjectFileWrite); + } + } + + _projectFileWriteGate.Release(); + return null; + } + + /// + /// Reserves the workspace for a worktree mutation, optionally taking over a finished + /// project-file write so the workspace is never left unreserved between the two. + /// + /// + /// A project-file write to fold into this mutation. It is released whether or not the mutation + /// starts, so the caller must have finished writing. The caller still owns it and must dispose + /// it, which is a no-op once it has been taken over. + /// + internal IDisposable? TryBeginWorktreeMutation(IProjectFileWriteLease? completedWrite = null) + { + WorkspaceOperationLease? handoff = null; + if (completedWrite is not null) + { + if (completedWrite is not WorkspaceOperationLease + { + Kind: WorkspaceOperationKind.ProjectFileWrite + } lease + || !ReferenceEquals(lease.Owner, this)) + { + throw new ArgumentException( + "The lease was not issued by this service for a project-file write.", + nameof(completedWrite)); + } + + handoff = lease; + } + + IDisposable? mutation = null; + lock (_workspaceOperationSync) + { + if (handoff is not null && handoff.TryTakeOver()) + { + _activeProjectFileWrites--; + // Released under the lock so the count and the gate never disagree about whether the + // workspace is reserved. + _projectFileWriteGate.Release(); + } + + if (!_worktreeMutationActive + && _activeOutputOperations == 0 + && _activeProjectFileWrites == 0) + { + _worktreeMutationActive = true; + _worktreeMutationCompletion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + mutation = new WorkspaceOperationLease( + this, + WorkspaceOperationKind.WorktreeMutation); + } + } + + return mutation; + } + + internal async Task SaveProjectFilesAsync( + Project project, + CancellationToken cancellationToken) + { + if (!Dispatcher.UIThread.CheckAccess()) + { + return await Dispatcher.UIThread.InvokeAsync( + () => SaveProjectFilesCoreAsync(project, cancellationToken)); + } + + return await SaveProjectFilesCoreAsync(project, cancellationToken); + } + + private async Task SaveProjectFilesCoreAsync( + Project project, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(project); + cancellationToken.ThrowIfCancellationRequested(); + Uri projectUri = project.Uri + ?? throw new InvalidOperationException( + "The project must have a file URI before it can be saved."); + EditorTabItem[] tabItems = TabItems.ToArray(); + using IDisposable suspension = SuspendEditors(); + await Task.Run( + () => _serializeProject(project, projectUri), + cancellationToken); + + foreach (EditorTabItem item in tabItems) + { + cancellationToken.ThrowIfCancellationRequested(); + if (item.Commands.Value is { } commands && !await commands.OnSave()) + { + return false; + } + } + + return true; + } + + /// + /// Disables every open editor until the returned handle is disposed. + /// + /// + /// A version-control transition holds this from before its pre-transition save until the + /// project is closed. Releasing it earlier would let the user edit while the cycle awaits Git, + /// and those edits would land after the safety snapshot and be discarded by the close. + /// Suspensions nest: an inner one records the already-disabled state and restores it. + /// + internal IDisposable SuspendEditors() + { + return new EditorSuspension(TabItems.ToArray()); + } + + internal async Task SwitchEditorExtensionAsync(EditorExtension extension) + { + ArgumentNullException.ThrowIfNull(extension); + // Callers offer the extension only for the selected tab's file type, so the swap must stay + // bound to the tab that was selected when it was offered. + EditorTabItem? targetTab = SelectedTabItem.Value; + if (targetTab is null) + { + return; + } + + // The lease must end before the swap: the outgoing context's teardown takes its own. + using (await BeginProjectFileWriteAsync(CancellationToken.None)) + { + if (targetTab.Commands.Value is { } commands) + { + await commands.OnSave(); + } + } + + // Waiting for the lease can span a version-control transition that disposes every tab. + if (!ReferenceEquals(SelectedTabItem.Value, targetTab) + || targetTab.Context.Value is not { } currentContext) + { + return; + } + + if (!extension.TryCreateContext( + currentContext.Object, + new EditorContextServices(this, _extensionProvider), + out IEditorContext? context)) + { + NotificationService.ShowInformation( + title: MessageStrings.ContextNotCreated, + message: string.Format( + format: MessageStrings.FailedToOpenFileWithExtension, + arg0: extension.DisplayName, + arg1: targetTab.FileName.Value)); + return; + } + + // Installed before the outgoing context is torn down, so a failed teardown cannot leave the + // tab bound to a half-disposed editor. + targetTab.Context.Value = context; + // DisposeAsync, not Dispose: IEditorContext.Dispose has an empty default implementation and + // EditViewModel overrides only DisposeAsync, so Dispose would leak the outgoing editor. + await currentContext.DisposeAsync(); + } + + private void EndWorkspaceOperation(WorkspaceOperationKind kind) + { + TaskCompletionSource? completedWorktreeMutation = null; + bool releaseProjectFileWrite = false; + lock (_workspaceOperationSync) + { + switch (kind) + { + case WorkspaceOperationKind.Output when _activeOutputOperations > 0: + _activeOutputOperations--; + break; + case WorkspaceOperationKind.ProjectFileWrite when _activeProjectFileWrites > 0: + _activeProjectFileWrites--; + releaseProjectFileWrite = true; + break; + case WorkspaceOperationKind.WorktreeMutation: + _worktreeMutationActive = false; + completedWorktreeMutation = _worktreeMutationCompletion; + _worktreeMutationCompletion = null; + break; + } + } + + completedWorktreeMutation?.TrySetResult(); + if (releaseProjectFileWrite) + { + _projectFileWriteGate.Release(); + } + } + public bool TryGetTabItem(CoreObject obj, [NotNullWhen(true)] out EditorTabItem? result) { result = TabItems.FirstOrDefault(i => i.Context.Value?.Object == obj); @@ -129,4 +473,98 @@ public async ValueTask CloseTabItem(EditorTabItem item) TabItems.Remove(item); await item.DisposeAsync(); } + + private sealed class EditorSuspension : IDisposable + { + private readonly EditorTabItem[] _tabItems; + private readonly bool[] _enabledStates; + private int _disposed; + + public EditorSuspension(EditorTabItem[] tabItems) + { + _tabItems = tabItems; + _enabledStates = new bool[tabItems.Length]; + for (int i = 0; i < tabItems.Length; i++) + { + if (tabItems[i].Context.Value is not { } context) + { + continue; + } + + _enabledStates[i] = context.IsEnabled.Value; + context.IsEnabled.Value = false; + } + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + for (int i = 0; i < _tabItems.Length; i++) + { + // A transition closes the project before releasing its suspension, which nulls the + // context of every tab it captured; those tabs no longer exist to re-enable. + if (_tabItems[i].Context.Value is { } context) + { + context.IsEnabled.Value = _enabledStates[i]; + } + } + } + } + + private sealed class ProjectDirectoryReadLease( + IProjectFileWriteLease fileWrite, + IDisposable outputOperation) : IDisposable + { + private int _disposed; + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + // The write reservation goes first: releasing the output lease before it would let a + // worktree mutation observe the workspace as free while this still holds the gate. + fileWrite.Dispose(); + outputOperation.Dispose(); + } + } + + private sealed class WorkspaceOperationLease( + EditorService owner, + WorkspaceOperationKind kind) : IProjectFileWriteLease + { + private int _disposed; + + public EditorService Owner => owner; + + public WorkspaceOperationKind Kind => kind; + + // Retires the lease without ending the operation it reserves, so the caller can transfer + // that reservation to another lease instead of releasing and racing to reacquire it. + public bool TryTakeOver() + { + return Interlocked.Exchange(ref _disposed, 1) == 0; + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 0) + { + owner.EndWorkspaceOperation(kind); + } + } + } + + private enum WorkspaceOperationKind + { + Output, + ProjectFileWrite, + WorktreeMutation, + } } diff --git a/src/Beutl/Services/OutputService.cs b/src/Beutl/Services/OutputService.cs index e225a75c15..52383551d1 100644 --- a/src/Beutl/Services/OutputService.cs +++ b/src/Beutl/Services/OutputService.cs @@ -1,6 +1,7 @@ using System.Text.Json.Nodes; using Beutl.Api.Services; using Beutl.Editor; +using Beutl.Language; using Beutl.Logging; using Beutl.Models; using Beutl.ViewModels; @@ -13,6 +14,12 @@ public sealed class OutputProfileItem : IDisposable { private readonly ILogger _logger = Log.CreateLogger(); private readonly EditorService _editorService; + private readonly object _outputOperationSync = new(); + private IDisposable? _outputOperation; + private int _eventHandlersInProgress; + private bool _disposeRequested; + private bool _contextDisposed; + private bool _outputReportedFinished; public OutputProfileItem(IOutputContext context, IEditorContext editorContext, EditorService editorService) { @@ -32,40 +39,189 @@ public OutputProfileItem(IOutputContext context, IEditorContext editorContext, E private void OnStarted(object? sender, EventArgs e) { - _logger.LogDebug("Output started for file: {File}", Context.Object.Uri); + bool reservationLost = false; + lock (_outputOperationSync) + { + if (_contextDisposed) + { + return; + } + + _outputReportedFinished = false; + _eventHandlersInProgress++; + + // Every output context reserves the workspace here, not just the ones that remember to + // do it themselves, so a version-control worktree mutation cannot run under an encode + // started by a third-party output extension. + if (_outputOperation is null) + { + _outputOperation = _editorService.TryBeginOutputOperation(); + reservationLost = _outputOperation is null; + } + } + + if (reservationLost) + { + _logger.LogWarning( + "Could not reserve the workspace for the output of {File}; the worktree is being changed.", + Context.Object.Uri); + // A third-party context that starts before raising Started cannot be stopped from here, + // so the user is told the output is running while the worktree changes underneath it + // rather than only the log knowing. + NotificationService.ShowWarning( + Strings.Output, + Strings.VersionControl_WorktreeOperationInProgress); + } - if (_editorService.TryGetTabItem(Context.Object, out EditorTabItem? tabItem)) + try { - tabItem.Context.Value.IsEnabled.Value = false; - _logger.LogDebug("Tab item disabled for file: {File}", Context.Object.Uri); + _logger.LogDebug("Output started for file: {File}", Context.Object.Uri); + + if (_editorService.TryGetTabItem(Context.Object, out EditorTabItem? tabItem)) + { + tabItem.Context.Value.IsEnabled.Value = false; + _logger.LogDebug("Tab item disabled for file: {File}", Context.Object.Uri); + } + else + { + _logger.LogWarning("Tab item not found for file: {File}", Context.Object.Uri); + } } - else + finally { - _logger.LogWarning("Tab item not found for file: {File}", Context.Object.Uri); + CompleteEventHandler(); } } private void OnFinished(object? sender, EventArgs e) { - _logger.LogDebug("Output finished for file: {File}", Context.Object.Uri); + IDisposable? completedOperation; + lock (_outputOperationSync) + { + if (_contextDisposed) + { + return; + } + + _eventHandlersInProgress++; + _outputReportedFinished = true; + completedOperation = _outputOperation; + _outputOperation = null; + } - if (_editorService.TryGetTabItem(Context.Object, out EditorTabItem? tabItem)) + completedOperation?.Dispose(); + + try { - tabItem.Context.Value.IsEnabled.Value = true; - _logger.LogDebug("Tab item enabled for file: {File}", Context.Object.Uri); + _logger.LogDebug("Output finished for file: {File}", Context.Object.Uri); + + if (_editorService.TryGetTabItem(Context.Object, out EditorTabItem? tabItem)) + { + tabItem.Context.Value.IsEnabled.Value = true; + _logger.LogDebug("Tab item enabled for file: {File}", Context.Object.Uri); + } + else + { + _logger.LogWarning("Tab item not found for file: {File}", Context.Object.Uri); + } } - else + finally { - _logger.LogWarning("Tab item not found for file: {File}", Context.Object.Uri); + CompleteEventHandler(); } } public void Dispose() { + bool disposeContext; + lock (_outputOperationSync) + { + if (_disposeRequested) + { + return; + } + + _disposeRequested = true; + disposeContext = TryMarkContextDisposed(); + } + + if (disposeContext) + { + DisposeContext(); + } + } + + internal bool TryDisposeIfIdle() + { + bool disposeContext; + lock (_outputOperationSync) + { + if (_disposeRequested) + { + return _contextDisposed; + } + + if (_eventHandlersInProgress > 0 + || Context.IsEncoding.Value) + { + return false; + } + + _disposeRequested = true; + disposeContext = TryMarkContextDisposed(); + } + + if (disposeContext) + { + DisposeContext(); + } + + return true; + } + + private void CompleteEventHandler() + { + bool disposeContext; + lock (_outputOperationSync) + { + _eventHandlersInProgress--; + disposeContext = TryMarkContextDisposed(); + } + + if (disposeContext) + { + DisposeContext(); + } + } + + private bool TryMarkContextDisposed() + { + if (!_disposeRequested + || _contextDisposed + || _eventHandlersInProgress > 0 + || (!_outputReportedFinished && Context.IsEncoding.Value)) + { + return false; + } + + _contextDisposed = true; + return true; + } + + private void DisposeContext() + { + IDisposable? outputOperation; + lock (_outputOperationSync) + { + outputOperation = _outputOperation; + _outputOperation = null; + } + _logger.LogInformation("Disposing OutputProfileItem for file: {File}", Context.Object.Uri); Context.Started -= OnStarted; Context.Finished -= OnFinished; Context.Dispose(); + outputOperation?.Dispose(); } public static JsonNode ToJson(OutputProfileItem item) @@ -98,7 +254,10 @@ public static JsonNode ToJson(OutputProfileItem item) if (contextJson != null && extension != null && File.Exists(file) - && extension.TryCreateContext(editorContext, out IOutputContext? context)) + && extension.TryCreateContext( + editorContext, + editorService, + out IOutputContext? context)) { context.ReadFromJson(contextJson.AsObject()); logger.LogInformation("OutputProfileItem created from JSON. File: {File}, Context: {Context}", file, @@ -140,7 +299,10 @@ public sealed class OutputService(EditViewModel editViewModel) : IDisposable public void AddItem(string file, OutputExtension extension) { - if (!extension.TryCreateContext(editViewModel, out IOutputContext? context)) + if (!extension.TryCreateContext( + editViewModel, + _editorService, + out IOutputContext? context)) { _logger.LogError("Failed to create context for file: {File}", file); throw new Exception("Failed to create context"); diff --git a/src/Beutl/Services/PrimitiveImpls/MainViewExtension.cs b/src/Beutl/Services/PrimitiveImpls/MainViewExtension.cs index 57cdcd7739..82388df8fc 100644 --- a/src/Beutl/Services/PrimitiveImpls/MainViewExtension.cs +++ b/src/Beutl/Services/PrimitiveImpls/MainViewExtension.cs @@ -46,6 +46,8 @@ public class MainViewExtension : ViewExtension new ContextCommandKeyGesture("Ctrl+Shift+S"), new ContextCommandKeyGesture("Cmd+Shift+S", OSPlatform.OSX), ]), + new("EnableVersionControl", Strings.VersionControl_Enable, "", []), + new("CommitVersion", Strings.VersionControl_Commit, "", []), new("CloseProject", Strings.CloseProject, Strings.CloseProject_Description, [ new ContextCommandKeyGesture("Ctrl+Shift+F4"), diff --git a/src/Beutl/Services/PrimitiveImpls/SceneOutputExtension.cs b/src/Beutl/Services/PrimitiveImpls/SceneOutputExtension.cs index dae8ca4a2e..544ab7fc27 100644 --- a/src/Beutl/Services/PrimitiveImpls/SceneOutputExtension.cs +++ b/src/Beutl/Services/PrimitiveImpls/SceneOutputExtension.cs @@ -29,11 +29,14 @@ public override FilePickerFileType GetFilePickerFileType() }; } - public override bool TryCreateContext(IEditorContext editorContext, [NotNullWhen(true)] out IOutputContext? context) + public override bool TryCreateContext( + IEditorContext editorContext, + IOutputOperationLeaseProvider outputOperations, + [NotNullWhen(true)] out IOutputContext? context) { if (editorContext is EditViewModel editViewModel) { - context = new OutputViewModel(editViewModel); + context = new OutputViewModel(editViewModel, outputOperations); return true; } else diff --git a/src/Beutl/Services/PrimitiveImpls/VersionControlTabExtension.cs b/src/Beutl/Services/PrimitiveImpls/VersionControlTabExtension.cs new file mode 100644 index 0000000000..22f7173812 --- /dev/null +++ b/src/Beutl/Services/PrimitiveImpls/VersionControlTabExtension.cs @@ -0,0 +1,63 @@ +using System.Diagnostics.CodeAnalysis; +using Avalonia.Controls; +using Beutl.Editor.Components.VersionControlTab.ViewModels; +using Beutl.Editor.Components.VersionControlTab.Views; +using Beutl.Editor.VersionControl; +using Reactive.Bindings; + +namespace Beutl.Services.PrimitiveImpls; + +[PrimitiveImpl] +public sealed class VersionControlTabExtension : ToolTabExtension +{ + public static readonly VersionControlTabExtension Instance = new(); + + public override string Name => "Version Control"; + + public override string DisplayName => Strings.VersionControl; + + public override string? Header => Strings.VersionControl; + + public override bool CanMultiple => false; + + public override DockAnchor DefaultAnchor => DockAnchor.Right; + + public override int DefaultOrder => 110; + + public override bool OpenByDefault => false; + + public override bool TryCreateContent( + IEditorContext editorContext, + [NotNullWhen(true)] out Control? control) + { + if (SupportsVersionControl(editorContext)) + { + control = new VersionControlTabView(); + return true; + } + + control = null; + return false; + } + + public override bool TryCreateContext( + IEditorContext editorContext, + [NotNullWhen(true)] out IToolContext? context) + { + if (SupportsVersionControl(editorContext)) + { + context = new VersionControlTabViewModel(this, editorContext); + return true; + } + + context = null; + return false; + } + + private static bool SupportsVersionControl(IEditorContext editorContext) + => editorContext.GetService( + typeof(IReadOnlyReactiveProperty)) + is IReadOnlyReactiveProperty + && editorContext.GetService(typeof(IProjectVersionControlCoordinator)) + is IProjectVersionControlCoordinator; +} diff --git a/src/Beutl/Services/ProjectCloseAbortedException.cs b/src/Beutl/Services/ProjectCloseAbortedException.cs new file mode 100644 index 0000000000..186166f31c --- /dev/null +++ b/src/Beutl/Services/ProjectCloseAbortedException.cs @@ -0,0 +1,8 @@ +namespace Beutl.Services; + +// Raised when a close abandons itself to keep unsaved edits alive, which only the pre-close save +// does. It derives from InvalidOperationException so existing close-failure handling is unchanged; +// the shutdown path recognizes the type and leaves the application open instead of disposing the +// editors the abort was protecting. +internal sealed class ProjectCloseAbortedException(string message) + : InvalidOperationException(message); diff --git a/src/Beutl/Services/ProjectService.cs b/src/Beutl/Services/ProjectService.cs index 6e758c2a6c..d61b1db563 100644 --- a/src/Beutl/Services/ProjectService.cs +++ b/src/Beutl/Services/ProjectService.cs @@ -1,4 +1,5 @@ -using System.Reactive.Subjects; +using System.Reactive.Linq; +using System.Reactive.Subjects; using System.Text.Json.Nodes; using Beutl.Configuration; using Beutl.Editor; @@ -17,18 +18,70 @@ namespace Beutl.Services; public sealed class ProjectService { private readonly Subject<(Project? New, Project? Old)> _projectObservable = new(); + private readonly IObservable<(Project? New, Project? Old)> _safeProjectObservable; private readonly ReadOnlyReactivePropertySlim _isOpened; private readonly BeutlApplication _app = BeutlApplication.Current; private readonly ILogger _logger = Log.CreateLogger(); + private readonly SemaphoreSlim _transitionGate = new(1, 1); + private readonly object _openAttemptSync = new(); + private readonly object _transitionSync = new(); + private ProjectOpenAttempt? _currentOpenAttempt; + private ProjectTransitionContext? _currentTransition; + private long _nextOpenAttemptId; + private long _nextTransitionId; + private int _shutdownRequested; public ProjectService() { + _safeProjectObservable = Observable.Create<(Project? New, Project? Old)>(observer => + _projectObservable.Subscribe(change => + { + try + { + observer.OnNext(change); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "A project-state observer failed while publishing the committed transition."); + } + })); CurrentProject = _app.GetObservable(BeutlApplication.ProjectProperty) .ToReadOnlyReactivePropertySlim(); _isOpened = CurrentProject.Select(v => v != null).ToReadOnlyReactivePropertySlim(); } - public IObservable<(Project? New, Project? Old)> ProjectObservable => _projectObservable; + public IObservable<(Project? New, Project? Old)> ProjectObservable => _safeProjectObservable; + + /// + /// Raised before , while the editors are still open. Anything that has to + /// read or persist live editor state has to run here: is where the + /// editor host clears and disposes the tabs. + /// + internal event Func? ClosingPreparing; + + internal event Func? Closing; + + internal event Func? ClosingFinalizing; + + internal event Func>? + OpeningPreflight; + + internal event Func? Opening; + + internal event Func? Opened; + + internal ProjectTransitionContext? CurrentTransition + { + get + { + lock (_transitionSync) + { + return _currentTransition; + } + } + } public IReadOnlyReactiveProperty CurrentProject { get; } @@ -50,12 +103,317 @@ public ProjectService() public async Task OpenProject(string file) { + ArgumentException.ThrowIfNullOrWhiteSpace(file); + ProjectOpenAttempt attempt = BeginOpenAttempt(file); + try + { + try + { + await WaitForExistingTransitionAsync(attempt); + } + catch (OperationCanceledException) when (attempt.IsCancellationRequested) + { + return; + } + + IReadOnlyList preparations; + try + { + preparations = await NotifyOpeningPreflightAsync(attempt); + } + catch (OperationCanceledException) when (attempt.IsCancellationRequested) + { + return; + } + + // A missing file is worth a transition only when a preparation can bring it back: an + // interrupted pull leaves it missing and its recovery is one of these preparations. + // Deciding before the transition also keeps a plainly deleted project from taking one. + if (preparations.Count == 0 && !File.Exists(file)) + { + _logger.LogInformation( + "Skipping project open: file is unavailable. File: {File}", + file); + NotificationService.ShowInformation(Strings.File, MessageStrings.FileDoesNotExist); + return; + } + + ProjectTransitionScope transition; + try + { + transition = await BeginTransitionAsync( + ProjectTransitionPurpose.Normal, + attempt, + allowDuringShutdown: false, + attempt.CancellationToken); + } + catch (OperationCanceledException) when (attempt.IsCancellationRequested) + { + return; + } + + await using (transition) + { + if (!attempt.TryBeginApply()) + { + return; + } + + foreach (ProjectOpenPreparation preparation in preparations) + { + ProjectOpenPreparationResult result = await preparation.ApplyAsync( + transition.Context, + CancellationToken.None); + if (result == ProjectOpenPreparationResult.Abort) + { + return; + } + } + + await OpenProjectCoreAsync(file, transition.Context); + } + } + finally + { + CompleteOpenAttempt(attempt); + } + } + + private async Task WaitForExistingTransitionAsync(ProjectOpenAttempt attempt) + { + await _transitionGate.WaitAsync(attempt.CancellationToken); + try + { + attempt.CancellationToken.ThrowIfCancellationRequested(); + } + finally + { + _transitionGate.Release(); + } + } + + public async Task CloseProject(CancellationToken cancellationToken = default) + { + await using ProjectTransitionScope transition = await BeginTransitionAsync( + ProjectTransitionPurpose.Normal, + this, + allowDuringShutdown: false, + cancellationToken); + await CloseProjectCoreAsync(transition.Context, cancellationToken); + } + + public async Task CreateProject(int width, int height, int framerate, int samplerate, string name, string location) + { + await using ProjectTransitionScope transition = await BeginTransitionAsync( + ProjectTransitionPurpose.Normal, + this, + allowDuringShutdown: false, + CancellationToken.None); + return await CreateProjectCoreAsync( + width, + height, + framerate, + samplerate, + name, + location, + transition.Context); + } + + internal ValueTask BeginVersionControlTransitionAsync( + object owner, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(owner); + return BeginTransitionAsync( + ProjectTransitionPurpose.VersionControlMutation, + owner, + allowDuringShutdown: false, + cancellationToken); + } + + internal ValueTask BeginShutdownTransitionAsync( + object owner, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(owner); + Interlocked.Exchange(ref _shutdownRequested, 1); + return BeginTransitionAsync( + ProjectTransitionPurpose.Shutdown, + owner, + allowDuringShutdown: true, + cancellationToken); + } + + internal void RequestShutdown() + { + Interlocked.Exchange(ref _shutdownRequested, 1); + CancelPendingOpenAttemptExcept(owner: null); + } + + /// + /// Clears the shutdown request. The application never needs this — shutdown is terminal there — + /// but the headless suite shares one across the whole assembly, so a + /// test that exercises shutdown would otherwise reject every later test's project transition. + /// + internal void ClearShutdownRequest() + { + Interlocked.Exchange(ref _shutdownRequested, 0); + } + + private async ValueTask BeginTransitionAsync( + ProjectTransitionPurpose purpose, + object owner, + bool allowDuringShutdown, + CancellationToken cancellationToken) + { + CancelPendingOpenAttemptExcept(owner); + if (!allowDuringShutdown && Volatile.Read(ref _shutdownRequested) != 0) + { + throw new InvalidOperationException( + "Project transitions cannot start after application shutdown has begun."); + } + + await _transitionGate.WaitAsync(cancellationToken); + CancelPendingOpenAttemptExcept(owner); + if (cancellationToken.IsCancellationRequested) + { + _transitionGate.Release(); + cancellationToken.ThrowIfCancellationRequested(); + } + + if (!allowDuringShutdown && Volatile.Read(ref _shutdownRequested) != 0) + { + _transitionGate.Release(); + throw new InvalidOperationException( + "Project transitions cannot start after application shutdown has begun."); + } + + var context = new ProjectTransitionContext( + Interlocked.Increment(ref _nextTransitionId), + purpose, + owner); + lock (_transitionSync) + { + _currentTransition = context; + } + + return new ProjectTransitionScope(this, context); + } + + private ProjectOpenAttempt BeginOpenAttempt(string file) + { + var attempt = new ProjectOpenAttempt( + Interlocked.Increment(ref _nextOpenAttemptId), + file); + ProjectOpenAttempt? previous; + lock (_openAttemptSync) + { + previous = _currentOpenAttempt; + _currentOpenAttempt = attempt; + } + + CancelOpenAttempt(previous); + return attempt; + } + + private async Task> NotifyOpeningPreflightAsync( + ProjectOpenAttempt attempt) + { + if (OpeningPreflight is not { } openingPreflight) + { + return []; + } + + var preparations = new List(); + foreach (Func> handler + in openingPreflight.GetInvocationList()) + { + attempt.CancellationToken.ThrowIfCancellationRequested(); + ProjectOpenPreparation? preparation = await handler( + attempt, + attempt.CancellationToken); + if (preparation is not null) + { + preparations.Add(preparation); + } + } + + return preparations; + } + + private void CancelPendingOpenAttemptExcept(object? owner) + { + ProjectOpenAttempt? attempt; + lock (_openAttemptSync) + { + attempt = _currentOpenAttempt; + } + + if (owner is ProjectOpenAttempt openingAttempt) + { + if (!ReferenceEquals(attempt, openingAttempt)) + { + CancelOpenAttempt(openingAttempt); + } + + return; + } + + if (!ReferenceEquals(attempt, owner)) + { + CancelOpenAttempt(attempt); + } + } + + private void CancelOpenAttempt(ProjectOpenAttempt? attempt) + { + try + { + attempt?.CancelIfPending(); + } + catch (Exception ex) + { + _logger.LogError(ex, "A project-open cancellation callback failed."); + } + } + + private void CompleteOpenAttempt(ProjectOpenAttempt attempt) + { + lock (_openAttemptSync) + { + if (ReferenceEquals(_currentOpenAttempt, attempt)) + { + _currentOpenAttempt = null; + } + } + + attempt.Complete(); + } + + private async Task OpenProjectCoreAsync(string file, ProjectTransitionContext transition) + { + VerifyTransition(transition); await App.WaitLoadingExtensions(); using Activity? activity = Telemetry.StartActivity(); try { - CloseProject(); + if (Opening is { } opening) + { + foreach (Func handler in opening.GetInvocationList()) + { + await handler(file); + } + } + + if (!File.Exists(file)) + { + _logger.LogInformation("Skipping project open: file is unavailable. File: {File}", file); + NotificationService.ShowInformation(Strings.File, MessageStrings.FileDoesNotExist); + return; + } + + await CloseProjectCoreAsync(transition, CancellationToken.None); (NuGetVersion appVersion, NuGetVersion minVersion) = await GetProjectVersion(file); activity?.SetTag(nameof(appVersion), appVersion.ToString()); @@ -75,12 +433,11 @@ public async Task OpenProject(string file) var project = CoreSerializer.RestoreFromUri(UriHelper.CreateFromPath(file)); - _app.Project = project; - // 値を発行 - _projectObservable.OnNext((New: project, null)); + await ActivateProjectAsync(project); - AddToRecentProjects(file); + TryAddToRecentProjects(file); _logger.LogInformation("Opened project. File: {File}, AppVersion: {AppVersion}, MinVersion: {MinVersion}", file, appVersion, minVersion); + PublishProjectChange((New: project, null)); } catch (Exception ex) { @@ -90,20 +447,61 @@ public async Task OpenProject(string file) } } - public void CloseProject() + private async Task CloseProjectCoreAsync( + ProjectTransitionContext transition, + CancellationToken cancellationToken) + { + VerifyTransition(transition); + if (_app.Project is not { } closingProject) + { + return; + } + + var closeContext = new ProjectCloseContext(); + try + { + await NotifyClosingPreparingAsync(closeContext, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + await NotifyClosingAsync(closeContext, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + await NotifyClosingFinalizingAsync(closeContext); + CloseProjectImmediately(); + } + finally + { + bool projectClosed = !ReferenceEquals(_app.Project, closingProject); + await closeContext.CompleteAsync(projectClosed, _logger); + } + } + + internal void CloseProjectImmediately() { if (_app.Project is { } project) { - // 値を発行 - _projectObservable.OnNext((New: null, project)); _app.Project = null; - GlobalConfiguration.Instance.ViewConfig.LastOpenedProjectFile = null; + try + { + GlobalConfiguration.Instance.ViewConfig.LastOpenedProjectFile = null; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to clear the last-opened project setting."); + } _logger.LogInformation("Closed project. Project: {Project}", project.Uri); + PublishProjectChange((New: null, project)); } } - public async Task CreateProject(int width, int height, int framerate, int samplerate, string name, string location) + private async Task CreateProjectCoreAsync( + int width, + int height, + int framerate, + int samplerate, + string name, + string location, + ProjectTransitionContext transition) { + VerifyTransition(transition); await App.WaitLoadingExtensions(); using Activity? activity = Telemetry.StartActivity(); @@ -113,7 +511,7 @@ public void CloseProject() activity?.SetTag(nameof(samplerate), samplerate); try { - CloseProject(); + await CloseProjectCoreAsync(transition, CancellationToken.None); location = Path.Combine(location, name); var scene = new Scene(width, height, name) @@ -148,12 +546,11 @@ public void CloseProject() } }); - // 値を発行 - _projectObservable.OnNext((New: project, null)); - _app.Project = project; + await ActivateProjectAsync(project); - AddToRecentProjects(project.Uri.LocalPath); + TryAddToRecentProjects(project.Uri.LocalPath); _logger.LogInformation("Created new project. Name: {Name}, Location: {Location}, Width: {Width}, Height: {Height}, Framerate: {Framerate}, Samplerate: {Samplerate}", name, location, width, height, framerate, samplerate); + PublishProjectChange((New: project, null)); return project; } @@ -167,11 +564,398 @@ public void CloseProject() } } - private static void AddToRecentProjects(string file) + private void TryAddToRecentProjects(string file) + { + try + { + ViewConfig viewConfig = GlobalConfiguration.Instance.ViewConfig; + viewConfig.UpdateRecentProject(file); + viewConfig.UpdateRecentFile(file); + viewConfig.LastOpenedProjectFile = file; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to update recent-project settings. File: {File}", file); + } + } + + private async Task NotifyOpenedAsync(Project project) + { + if (Opened is { } opened) + { + foreach (Func handler in opened.GetInvocationList()) + { + await handler(project); + } + } + } + + private async Task ActivateProjectAsync(Project project) { - ViewConfig viewConfig = GlobalConfiguration.Instance.ViewConfig; - viewConfig.UpdateRecentProject(file); - viewConfig.UpdateRecentFile(file); - viewConfig.LastOpenedProjectFile = file; + _app.Project = project; + try + { + await NotifyOpenedAsync(project); + } + catch + { + await RollBackFailedActivationAsync(project); + throw; + } + } + + private async Task RollBackFailedActivationAsync(Project project) + { + if (!ReferenceEquals(_app.Project, project)) + { + return; + } + + var closeContext = new ProjectCloseContext(); + try + { + foreach (Func handler + in EnumerateRollbackCloseHandlers()) + { + try + { + await handler(closeContext, CancellationToken.None); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "A project-closing handler failed while rolling back project activation."); + } + } + + await NotifyClosingFinalizingAsync(closeContext); + + if (ReferenceEquals(_app.Project, project)) + { + _app.Project = null; + } + } + finally + { + bool projectClosed = !ReferenceEquals(_app.Project, project); + await closeContext.CompleteAsync(projectClosed, _logger); + } + } + + private IEnumerable> + EnumerateRollbackCloseHandlers() + { + if (ClosingPreparing is { } closingPreparing) + { + foreach (Func handler + in closingPreparing.GetInvocationList()) + { + yield return handler; + } + } + + if (Closing is { } closing) + { + foreach (Func handler + in closing.GetInvocationList()) + { + yield return handler; + } + } + } + + private async Task NotifyClosingPreparingAsync( + ProjectCloseContext closeContext, + CancellationToken cancellationToken) + { + if (ClosingPreparing is { } closingPreparing) + { + foreach (Func handler + in closingPreparing.GetInvocationList()) + { + await handler(closeContext, cancellationToken); + } + } + } + + private async Task NotifyClosingAsync( + ProjectCloseContext closeContext, + CancellationToken cancellationToken) + { + if (Closing is { } closing) + { + foreach (Func handler + in closing.GetInvocationList()) + { + await handler(closeContext, cancellationToken); + } + } + } + + private async Task NotifyClosingFinalizingAsync(ProjectCloseContext closeContext) + { + if (ClosingFinalizing is { } closingFinalizing) + { + foreach (Func handler + in closingFinalizing.GetInvocationList()) + { + try + { + await handler(closeContext, CancellationToken.None); + } + catch (Exception ex) + { + _logger.LogError(ex, "A project-close finalizer failed."); + } + } + } + } + + private void PublishProjectChange((Project? New, Project? Old) change) + { + try + { + _projectObservable.OnNext(change); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Unable to publish a committed project-state transition."); + } + } + + private void VerifyTransition(ProjectTransitionContext transition) + { + lock (_transitionSync) + { + if (!ReferenceEquals(_currentTransition, transition)) + { + throw new InvalidOperationException("The project transition is no longer active."); + } + } + } + + private void EndTransition(ProjectTransitionContext transition) + { + lock (_transitionSync) + { + if (!ReferenceEquals(_currentTransition, transition)) + { + return; + } + + _currentTransition = null; + } + + _transitionGate.Release(); + } + + internal sealed class ProjectCloseContext + { + private readonly object _gate = new(); + private readonly List> _completions = []; + private bool _completed; + + internal void RegisterCompletion(Func completion) + { + ArgumentNullException.ThrowIfNull(completion); + lock (_gate) + { + if (_completed) + { + throw new InvalidOperationException( + "The project-close transition has already completed."); + } + + _completions.Add(completion); + } + } + + internal async Task CompleteAsync(bool projectClosed, ILogger logger) + { + Func[] completions; + lock (_gate) + { + if (_completed) + { + return; + } + + _completed = true; + completions = _completions.ToArray(); + _completions.Clear(); + } + + foreach (Func completion in completions) + { + try + { + await completion(projectClosed); + } + catch (Exception ex) + { + logger.LogError(ex, "A project-close completion callback failed."); + } + } + } + } + + internal sealed class ProjectOpenAttempt + { + private readonly CancellationTokenSource _cancellation = new(); + private readonly object _gate = new(); + private ProjectOpenAttemptState _state; + private bool _cancellationInProgress; + private bool _disposeCancellationWhenCancelCompletes; + + internal ProjectOpenAttempt(long id, string projectFile) + { + Id = id; + ProjectFile = projectFile; + } + + internal long Id { get; } + + internal string ProjectFile { get; } + + internal CancellationToken CancellationToken => _cancellation.Token; + + internal bool IsCancellationRequested => _cancellation.IsCancellationRequested; + + internal bool TryBeginApply() + { + lock (_gate) + { + if (_state != ProjectOpenAttemptState.Pending + || _cancellation.IsCancellationRequested) + { + return false; + } + + _state = ProjectOpenAttemptState.Applying; + return true; + } + } + + internal void CancelIfPending() + { + bool cancel; + lock (_gate) + { + cancel = _state == ProjectOpenAttemptState.Pending; + if (cancel) + { + _state = ProjectOpenAttemptState.Cancelled; + _cancellationInProgress = true; + } + } + + if (cancel) + { + bool disposeCancellation; + try + { + _cancellation.Cancel(); + } + finally + { + lock (_gate) + { + _cancellationInProgress = false; + disposeCancellation = _disposeCancellationWhenCancelCompletes; + } + + if (disposeCancellation) + { + _cancellation.Dispose(); + } + } + } + } + + internal void Complete() + { + bool disposeCancellation; + lock (_gate) + { + _state = ProjectOpenAttemptState.Completed; + disposeCancellation = !_cancellationInProgress; + _disposeCancellationWhenCancelCompletes = !disposeCancellation; + } + + if (disposeCancellation) + { + _cancellation.Dispose(); + } + } + } + + internal abstract class ProjectOpenPreparation + { + internal abstract Task ApplyAsync( + ProjectTransitionContext transition, + CancellationToken cancellationToken); + } + + internal sealed class ProjectTransitionScope : IAsyncDisposable + { + private ProjectService? _owner; + + internal ProjectTransitionScope(ProjectService owner, ProjectTransitionContext context) + { + _owner = owner; + Context = context; + } + + internal ProjectTransitionContext Context { get; } + + internal Task CloseProjectAsync(CancellationToken cancellationToken = default) + { + ProjectService owner = _owner + ?? throw new ObjectDisposedException(nameof(ProjectTransitionScope)); + return owner.CloseProjectCoreAsync(Context, cancellationToken); + } + + internal Task OpenProjectAsync(string file) + { + ProjectService owner = _owner + ?? throw new ObjectDisposedException(nameof(ProjectTransitionScope)); + return owner.OpenProjectCoreAsync(file, Context); + } + + public ValueTask DisposeAsync() + { + ProjectService? owner = Interlocked.Exchange(ref _owner, null); + owner?.EndTransition(Context); + return ValueTask.CompletedTask; + } + } + + private enum ProjectOpenAttemptState + { + Pending, + Applying, + Cancelled, + Completed, } } + +internal enum ProjectOpenPreparationResult +{ + Proceed, + Abort, +} + +internal enum ProjectTransitionPurpose +{ + Normal, + VersionControlMutation, + Shutdown, +} + +internal sealed record ProjectTransitionContext( + long Id, + ProjectTransitionPurpose Purpose, + object Owner); diff --git a/src/Beutl/Services/ProxyMediaServices.cs b/src/Beutl/Services/ProxyMediaServices.cs index 7819332a36..e07391938d 100644 --- a/src/Beutl/Services/ProxyMediaServices.cs +++ b/src/Beutl/Services/ProxyMediaServices.cs @@ -2,6 +2,7 @@ using Beutl.Configuration; using Beutl.Editor; +using Beutl.Extensibility; using Beutl.Logging; using Beutl.Media.Decoding; using Beutl.Media.Proxy; @@ -34,6 +35,7 @@ internal sealed class ProxyMediaServices : IAsyncDisposable // Tracks the disposal of queues replaced by a store-root change so shutdown can await them; each new // change chains onto the previous so DisposeAsync awaiting the latest awaits them all. private Task _replacedQueueDisposal = Task.CompletedTask; + private readonly ProxyOutputOperationGate _outputOperationGate; // The live config, so a rejected store-root rebuild can revert the persisted path to the last-good one. private ProxyStoreConfig? _config; // Monotonically increasing base for each rebuilt resolver's source versions, so a store-root swap @@ -44,12 +46,14 @@ private ProxyMediaServices( ProxyStore store, ProxyResolver resolver, ProxyJobQueue queue, - ProxyEvictionService evictionService) + ProxyEvictionService evictionService, + ProxyOutputOperationGate outputOperationGate) { Store = store; Resolver = resolver; Queue = queue; EvictionService = evictionService; + _outputOperationGate = outputOperationGate; StoreFacade = new StableProxyStoreFacade(store); ResolverFacade = new StableProxyResolverFacade(resolver); QueueFacade = new StableProxyQueueFacade(queue); @@ -91,8 +95,9 @@ public static ProxyMediaServices Initialize(GlobalConfiguration configuration) return existing; ProxyStoreConfig config = configuration.ProxyStoreConfig; - var (store, resolver, queue, eviction) = BuildServicesWithFallback(config); - var services = new ProxyMediaServices(store, resolver, queue, eviction) { _config = config }; + var outputOperationGate = new ProxyOutputOperationGate(); + var (store, resolver, queue, eviction) = BuildServicesWithFallback(config, outputOperationGate); + var services = new ProxyMediaServices(store, resolver, queue, eviction, outputOperationGate) { _config = config }; s_disposing = false; Current = services; DecoderRegistry.ProxyResolver = resolver; @@ -113,8 +118,18 @@ public static ProxyMediaServices Initialize(GlobalConfiguration configuration) return services; } + internal void BindWorkspaceOperations(EditorService editorService) + { + ArgumentNullException.ThrowIfNull(editorService); + _outputOperationGate.Bind(editorService.TryBeginOutputOperation); + } + private static (ProxyStore Store, ProxyResolver Resolver, ProxyJobQueue Queue, ProxyEvictionService Eviction) - BuildServices(string storeRootPath, long maxTotalBytes, long resolverVersionOffset) + BuildServices( + string storeRootPath, + long maxTotalBytes, + long resolverVersionOffset, + ProxyOutputOperationGate outputOperationGate) { var store = new ProxyStore(storeRootPath); var resolver = new ProxyResolver(store, resolverVersionOffset); @@ -129,7 +144,10 @@ private static (ProxyStore Store, ProxyResolver Resolver, ProxyJobQueue Queue, P minFreeDiskBytes: DefaultMinFreeDiskBytes, openProjectSourceProvider: CollectOpenProjectSources, isGenerationActive: (source, preset) => queue.IsGenerating(source, preset)); - queue = new ProxyJobQueue(ResolveGeneratorFactory(store, eviction), store); + queue = new ProxyJobQueue( + ResolveGeneratorFactory(store, eviction), + store, + outputOperationGate.TryBeginOutputOperation); return (store, resolver, queue, eviction); } @@ -140,7 +158,7 @@ private static StringComparison StorePathComparison : StringComparison.Ordinal; private static (ProxyStore Store, ProxyResolver Resolver, ProxyJobQueue Queue, ProxyEvictionService Eviction) - BuildServicesWithFallback(ProxyStoreConfig config) + BuildServicesWithFallback(ProxyStoreConfig config, ProxyOutputOperationGate outputOperationGate) { // Capture the resolved path once, before the try: the recovery path below must not re-read the // accessor (its normalization already degrades a malformed value to the default, but reading it @@ -148,7 +166,7 @@ private static (ProxyStore Store, ProxyResolver Resolver, ProxyJobQueue Queue, P string storeRootPath = config.StoreRootPath; try { - return BuildServices(storeRootPath, config.MaxTotalBytes, resolverVersionOffset: 0); + return BuildServices(storeRootPath, config.MaxTotalBytes, resolverVersionOffset: 0, outputOperationGate); } catch (Exception ex) { @@ -164,7 +182,7 @@ private static (ProxyStore Store, ProxyResolver Resolver, ProxyJobQueue Queue, P "Proxy media", "The proxy store location could not be opened. Using the default location instead."); config.StoreRootPath = ProxyStoreConfig.DefaultStoreRootPath; - return BuildServices(config.StoreRootPath, config.MaxTotalBytes, resolverVersionOffset: 0); + return BuildServices(config.StoreRootPath, config.MaxTotalBytes, resolverVersionOffset: 0, outputOperationGate); } } @@ -229,7 +247,11 @@ private void ReinitializeStore(string storeRootPath, long maxTotalBytes) ProxyEvictionService newEviction; try { - (newStore, newResolver, newQueue, newEviction) = BuildServices(storeRootPath, maxTotalBytes, versionOffset); + (newStore, newResolver, newQueue, newEviction) = BuildServices( + storeRootPath, + maxTotalBytes, + versionOffset, + _outputOperationGate); } catch (Exception ex) { @@ -459,6 +481,26 @@ private static IReadOnlySet CollectOpenProjectSources() private static Project? s_openProjectSourcesProject; private static IReadOnlySet? s_openProjectSources; + internal sealed class ProxyOutputOperationGate + { + private Func? _tryBeginOutputOperation; + + public void Bind(Func tryBeginOutputOperation) + { + ArgumentNullException.ThrowIfNull(tryBeginOutputOperation); + Volatile.Write(ref _tryBeginOutputOperation, tryBeginOutputOperation); + } + + public IDisposable? TryBeginOutputOperation() + { + Func? tryBeginOutputOperation = + Volatile.Read(ref _tryBeginOutputOperation); + return tryBeginOutputOperation is null + ? StandaloneOutputOperationLeaseProvider.Instance.TryBeginOutputOperation() + : tryBeginOutputOperation(); + } + } + private static string FormatBytes(long bytes) { string[] units = ["B", "KB", "MB", "GB", "TB"]; diff --git a/src/Beutl/Services/StartupTasks/LoadPrimitiveExtensionTask.cs b/src/Beutl/Services/StartupTasks/LoadPrimitiveExtensionTask.cs index 487d7148e0..4f9f9e262c 100644 --- a/src/Beutl/Services/StartupTasks/LoadPrimitiveExtensionTask.cs +++ b/src/Beutl/Services/StartupTasks/LoadPrimitiveExtensionTask.cs @@ -58,6 +58,7 @@ public sealed class LoadPrimitiveExtensionTask : StartupTask ScriptEditorExtension.Instance, FileBrowserTabExtension.Instance, HistoryTabExtension.Instance, + VersionControlTabExtension.Instance, DockLayoutTabExtension.Instance, TerminalTabExtension.Instance, DarkBorderThemeExtension.Instance diff --git a/src/Beutl/Services/VersionControlCoordinator.cs b/src/Beutl/Services/VersionControlCoordinator.cs new file mode 100644 index 0000000000..75d5491d33 --- /dev/null +++ b/src/Beutl/Services/VersionControlCoordinator.cs @@ -0,0 +1,6877 @@ +using System.Globalization; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Threading; +using Avalonia.VisualTree; +using Beutl.Configuration; +using Beutl.Editor; +using Beutl.Editor.Components.VersionControl.Views; +using Beutl.Editor.VersionControl; +using Beutl.Logging; +using Microsoft.Extensions.Logging; +using Reactive.Bindings; + +namespace Beutl.Services; + +public sealed class VersionControlCoordinator : + IProjectVersionControlCoordinator, + IProjectVersionControlInitializer, + IProjectVersionControlSession, + IDisposable, + IAsyncDisposable +{ + private const string SaveSnapshotMessage = "beutl: snapshot on save"; + private const string CloseSnapshotMessage = "beutl: snapshot on close"; + private const string RestoreSafetySnapshotMessage = "beutl: safety snapshot before restore"; + private const string SwitchSafetySnapshotMessage = "beutl: safety snapshot before switch"; + private const string PullSafetySnapshotMessage = "beutl: safety snapshot before pull"; + private const string RestoreRecoveryMessage = + "beutl: recover original project state after failed restore"; + + private readonly ProjectService _projectService; + private readonly EditorService _editorService; + private readonly VersionControlConfig _config; + private readonly GitInstallationLocator _installationLocator; + private readonly Func? _serviceFactory; + private readonly IDisposable _projectSubscription; + private readonly Dispatcher _dispatcher; + private readonly CancellationTokenSource _lifetimeCancellation = new(); + private readonly ILogger _logger = Log.CreateLogger(); + private readonly object _stateGate = new(); + private readonly SemaphoreSlim _lifecycleGate = new(1, 1); + private readonly SemaphoreSlim _lockRecoveryGate = new(1, 1); + private readonly SemaphoreSlim _operationCloseGate = new(1, 1); + private readonly ReactivePropertySlim _isGitAvailable = new(); + private readonly ReactivePropertySlim _isTracked = new(); + private readonly Queue _publicationQueue = new(); + private readonly Dictionary + _preparedCloseBarriers = new(); + private readonly Dictionary> + _candidateServiceUsers = new(ReferenceEqualityComparer.Instance); + private readonly HashSet _managedServices = new( + ReferenceEqualityComparer.Instance); + private readonly HashSet _offeredPendingRecoveryIds = new(StringComparer.Ordinal); + // Ordinal, because GetOpeningRecoveryKey already resolved the casing the filesystem merges: + // a case-insensitive comparer on top of that would fold two genuinely distinct directories + // together on a case-sensitive volume. + private readonly Dictionary _openingPullRecoveries = + new(StringComparer.Ordinal); + private readonly TaskCompletionSource _propertiesDisposedCompletion = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _asyncDisposalCompletion = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private CoordinatorState _state = CoordinatorState.Empty; + private ActivationContext? _activation; + private TaskCompletionSource? _activationSetupsQuiesced; + private TaskCompletionSource? _availabilityQuiesced; + private TaskCompletionSource? _closeBarriersQuiesced; + private TaskCompletionSource? _configurationActivationQuiesced; + private TaskCompletionSource? _lifecycleQuiesced; + private TaskCompletionSource? _lockRecoveryQuiesced; + private TaskCompletionSource? _notificationsQuiesced; + private TaskCompletionSource? _pendingRecoveryOffersQuiesced; + private TaskCompletionSource? _operationsQuiesced; + private TaskCompletionSource? _publicationDrainQuiesced; + private TaskCompletionSource? _retirementsQuiesced; + private CancellationTokenSource? _operationEpochCancellation = new(); + private CancellationTokenSource? _projectServiceEpochCancellation = new(); + private PendingRecoveryOfferContext? _pendingRecoveryOffer; + private PendingOpeningRepositoryDecision? _pendingOpeningRepositoryDecision; + private string? _deferredSaveSnapshotRoot; + private CancellationTokenSource? _configurationActivationCancellation; + private ConfigurationActivationRequest? _pendingConfigurationActivation; + private long _nextActivationRevision; + private long _latestActivationRevision; + private long _nextStateRevision; + private long _nextConfigurationActivationRevision; + private long _lastPublishedRevision; + private int _availabilityRevision; + private int _activationSetupUsers; + private int _availabilityUsers; + private int _closeBarrierUsers; + private int _lifecycleUsers; + private int _lockRecoveryUsers; + private int _notificationUsers; + private int _pendingRecoveryOfferUsers; + private int _operationUsers; + private int _retirementUsers; + private int _asyncDisposalStarted; + private Project? _lastProjectNotification; + private bool _hasProjectNotification; + private string? _observedGitExecutablePath; + private bool _observedUseLfsWhenAvailable; + private bool _publicationDrainScheduled; + private bool _publicationDrainRunning; + private bool _disposePropertiesRequested; + private bool _configurationActivationActive; + private bool _operationCloseBarrierActive; + private bool _repositoryHygieneConfigurationDirty; + private bool _propertiesDisposed; + private volatile bool _disposed; + + public VersionControlCoordinator( + ProjectService projectService, + EditorService editorService) + : this( + projectService, + editorService, + GlobalConfiguration.Instance.VersionControlConfig, + installationLocator: null, + serviceFactory: null) + { + } + + internal VersionControlCoordinator( + ProjectService projectService, + EditorService editorService, + VersionControlConfig config, + GitInstallationLocator? installationLocator, + Func? serviceFactory = null) + { + _projectService = projectService ?? throw new ArgumentNullException(nameof(projectService)); + _editorService = editorService ?? throw new ArgumentNullException(nameof(editorService)); + _config = config ?? throw new ArgumentNullException(nameof(config)); + _observedGitExecutablePath = NormalizeGitExecutablePath(config.GitExecutablePath); + _observedUseLfsWhenAvailable = config.UseLfsWhenAvailable; + _installationLocator = installationLocator ?? new GitInstallationLocator(config); + _serviceFactory = serviceFactory; + _dispatcher = Dispatcher.UIThread; + ConfirmRestoreAsync = ShowRestoreConfirmationAsync; + ConfirmSwitchBranchAsync = ShowSwitchBranchConfirmationAsync; + ConfirmPullAsync = ShowPullConfirmationAsync; + ConfirmPendingPullRecoveryAsync = ShowPendingPullRecoveryConfirmationAsync; + ConfirmUseEnclosingRepositoryAsync = ShowEnclosingRepositoryConfirmationAsync; + ConfirmAdoptExistingRepositoryAsync = ShowAdoptExistingRepositoryConfirmationAsync; + ConfirmRemoveStaleLockAsync = ShowStaleLockConfirmationAsync; + ConfirmUntrackReservedPathsAsync = ShowUntrackReservedPathsConfirmationAsync; + WarnConflictMarkersAsync = ShowConflictMarkerWarningAsync; + RequestIdentityAsync = static _ => Task.FromResult(null); + PresentPolicyNoticeAsync = ShowPolicyNoticeAsync; + _config.ConfigurationChanged += OnVersionControlConfigChanged; + _projectService.OpeningPreflight += PrepareProjectOpeningAsync; + _projectService.Opening += InspectProjectOpeningAsync; + _projectService.ClosingPreparing += PrepareProjectClosingAsync; + _projectService.ClosingFinalizing += NotifyProjectClosingAsync; + _projectSubscription = _projectService.ProjectObservable.Subscribe( + change => OnProjectChanged(change.New)); + _editorService.ProjectVersionControlCoordinator = this; + ObserveCurrentProjectSnapshot(); + StartAvailabilityRefresh(); + } + + public IProjectVersionControlService? CurrentService + { + get + { + lock (_stateGate) + { + return _state.VisibleService; + } + } + } + + public IReadOnlyReactiveProperty IsGitAvailable => _isGitAvailable; + + public IReadOnlyReactiveProperty IsTracked => _isTracked; + + public event EventHandler? PendingPullRecoveriesChanged; + + internal Func> ConfirmRestoreAsync { get; set; } + + internal Func> ConfirmSwitchBranchAsync { get; set; } + + internal Func> ConfirmPullAsync { get; set; } + + internal Func> + ConfirmPendingPullRecoveryAsync + { get; set; } + + internal Func> + ConfirmUseEnclosingRepositoryAsync + { get; set; } + + internal Func> + ConfirmAdoptExistingRepositoryAsync + { get; set; } + + internal Func> + ConfirmRemoveStaleLockAsync + { get; set; } + + internal Func, CancellationToken, Task> + ConfirmUntrackReservedPathsAsync + { get; set; } + + internal Func WarnConflictMarkersAsync { get; set; } + + internal Func> RequestIdentityAsync { get; set; } + + internal Func PresentPolicyNoticeAsync + { + get; + set; + } + + public Task GetAvailabilityAsync( + CancellationToken cancellationToken = default) + { + lock (_stateGate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + _availabilityUsers++; + } + + return GetAvailabilityTrackedAsync(cancellationToken); + } + + private async Task GetAvailabilityTrackedAsync( + CancellationToken cancellationToken) + { + using var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + _lifetimeCancellation.Token); + try + { + int revision = Interlocked.Increment(ref _availabilityRevision); + GitAvailability availability = await _installationLocator.LocateAsync( + linkedCancellation.Token); + linkedCancellation.Token.ThrowIfCancellationRequested(); + bool schedulePublication = false; + lock (_stateGate) + { + if (!_disposed && revision == Volatile.Read(ref _availabilityRevision)) + { + schedulePublication = TransitionStateLocked( + _state with + { + IsGitAvailable = availability.State == GitAvailabilityState.Installed, + }); + } + } + + SchedulePublicationDrain(schedulePublication); + + return availability; + } + finally + { + FinishAvailabilityOperation(); + } + } + + public async Task InitializeCurrentProjectAsync( + Project expectedProject, + Func> requestIdentityAsync, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(expectedProject); + ArgumentNullException.ThrowIfNull(requestIdentityAsync); + using NonTransactionalOperationLease operation = + await BeginNonTransactionalOperationAsync(cancellationToken); + CancellationToken operationCancellation = operation.CancellationToken; + + string projectRoot = GetProjectRoot(expectedProject); + Task activationTask; + lock (_stateGate) + { + Project currentProject = _projectService.CurrentProject.Value + ?? throw new InvalidOperationException("No project is open."); + if (!ReferenceEquals(currentProject, expectedProject)) + { + throw new InvalidOperationException( + "The requested project is no longer the open project."); + } + + activationTask = _activation is { ProjectRoot: var activationRoot } activation + && string.Equals(activationRoot, projectRoot, PathComparison) + ? activation.Completion + : Task.CompletedTask; + } + + await activationTask.WaitAsync(operationCancellation); + + IProjectVersionControlBackend service; + lock (_stateGate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + Project currentProject = _projectService.CurrentProject.Value + ?? throw new InvalidOperationException( + "The project was closed while version control was activating."); + string currentRoot = GetProjectRoot(currentProject); + if (!ReferenceEquals(currentProject, expectedProject) + || !string.Equals(currentRoot, projectRoot, PathComparison) + || _state.ProjectRoot is not { } stateRoot + || !string.Equals(stateRoot, projectRoot, PathComparison)) + { + throw new InvalidOperationException( + "The open project changed while version control was activating."); + } + + service = _state.OwnedService + ?? throw new InvalidOperationException( + "The version control service is not available."); + } + + RepositoryInfo? targetRepository = service.Repository + ?? await SelectRepositoryForInitializationAsync( + service, + projectRoot, + operationCancellation); + if (targetRepository is null) + { + return false; + } + + var options = new InitOptions(targetRepository, _config.UseLfsWhenAvailable); + // InitializeAsync writes the hygiene files and stages the first revision, so it needs the + // same workspace reservation as every later snapshot: without it an export or an auto-save + // can still be writing and the initial commit captures a half-written tree. + using IDisposable? initializationMutation = TryBeginWorktreeMutation(); + if (initializationMutation is null) + { + return false; + } + + // The initial revision has to record what the user sees, so in-memory edits reach disk + // first: with autosave off the tree still holds element files the scene does not include. + if (!await TrySaveOpenProjectAsync(expectedProject, operationCancellation)) + { + PublishNotification(() => + NotificationService.ShowWarning( + Strings.VersionControl, + MessageStrings.OperationFailed)); + return false; + } + + try + { + try + { + await service.InitializeAsync(options, operationCancellation); + } + catch (GitIdentityRequiredException) + { + GitIdentity? identity = await requestIdentityAsync(operationCancellation); + if (identity is null) + { + return false; + } + + operationCancellation.ThrowIfCancellationRequested(); + // The editor remains live while the identity prompt is open. Re-save under the + // worktree mutation reservation (which excludes directory readers and all other + // writers) so edits made during the prompt are included in the retry's initial + // revision rather than committing stale disk state. + if (!await TrySaveOpenProjectAsync(expectedProject, operationCancellation)) + { + PublishNotification(() => + NotificationService.ShowWarning( + Strings.VersionControl, + MessageStrings.OperationFailed)); + return false; + } + + await service.InitializeAsync( + options with { Identity = identity }, + operationCancellation); + } + } + catch (VersionControlConflictedException ex) + { + PublishNotification(() => + NotificationService.ShowWarning(Strings.VersionControl, ex.Guidance)); + return false; + } + + // Runs after initialization, once the repository exists and is attached. Already-tracked + // .beutl/*.tmp entries leave the repository permanently dirty for the pull precondition, but + // a repository may be sharing them on purpose, so untracking them is the user's call. + IReadOnlyList reservedPaths = await service.GetTrackedReservedPathsAsync( + operationCancellation); + if (reservedPaths.Count > 0 + && await ConfirmUntrackReservedPathsAsync(reservedPaths, operationCancellation)) + { + await service.UntrackReservedPathsAsync(reservedPaths, operationCancellation); + } + + bool schedulePublication; + lock (_stateGate) + { + Project currentProject = _projectService.CurrentProject.Value + ?? throw new InvalidOperationException( + "The project was closed while version control was being initialized."); + if (_disposed + || !ReferenceEquals(currentProject, expectedProject) + || !ReferenceEquals(_state.OwnedService, service) + || _state.ProjectRoot is not { } stateRoot + || !string.Equals(stateRoot, projectRoot, PathComparison)) + { + throw new InvalidOperationException( + "The open project changed while version control was being initialized."); + } + + schedulePublication = TransitionStateLocked( + _state with { IsTracked = service.Repository is not null }); + } + + SchedulePublicationDrain(schedulePublication); + + return true; + } + + public async Task NotifySavedAsync( + IProjectFileWriteLease? completedWrite = null, + CancellationToken cancellationToken = default) + { + using NonTransactionalOperationLease operation = + await BeginNonTransactionalOperationAsync(cancellationToken); + await CommitSnapshotAsync( + _config.AutoCommitOnSave, + SaveSnapshotMessage, + SnapshotKind.Save, + completedWrite, + operation.CancellationToken); + } + + private async Task PrepareProjectClosingAsync( + ProjectService.ProjectCloseContext closeContext, + CancellationToken cancellationToken) + { + if (IsInternalVersionControlTransition()) + { + AdvanceProjectServiceEpoch(); + return; + } + + CancelPendingPullRecoveryOffer(); + NonTransactionalCloseBarrier? closeBarrier = + await TryBeginNonTransactionalCloseBarrierAsync(cancellationToken) + .ConfigureAwait(false); + if (closeBarrier is null) + { + return; + } + + AdvanceProjectServiceEpoch(); + + bool completionRegistered = false; + IDisposable? editorSuspension = null; + try + { + if (_config.AutoCommitOnClose + && GetOwnedBackend()?.Repository is not null + && _projectService.CurrentProject.Value is not null) + { + editorSuspension = await SuspendEditorsAsync(cancellationToken); + } + + lock (_stateGate) + { + _preparedCloseBarriers.Add(closeContext, closeBarrier); + } + + closeContext.RegisterCompletion( + async projectClosed => + { + try + { + await ReleaseEditorSuspensionAsync(editorSuspension); + } + finally + { + await CompletePreparedCloseBarrierAsync( + closeContext, + closeBarrier, + projectClosed); + } + }); + completionRegistered = true; + } + finally + { + if (!completionRegistered) + { + lock (_stateGate) + { + _preparedCloseBarriers.Remove(closeContext); + } + + try + { + await ReleaseEditorSuspensionAsync(editorSuspension); + } + finally + { + await closeBarrier.CompleteAsync(projectClosed: false).ConfigureAwait(false); + } + } + } + + await TrySaveForCloseSnapshotAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task SuspendEditorsAsync(CancellationToken cancellationToken) + { + if (_dispatcher.CheckAccess()) + { + return _editorService.SuspendEditors(); + } + + return await _dispatcher.InvokeAsync( + () => _editorService.SuspendEditors(), + DispatcherPriority.Normal, + cancellationToken); + } + + private async Task ReleaseEditorSuspensionAsync(IDisposable? suspension) + { + if (suspension is null) + { + return; + } + + if (_dispatcher.CheckAccess()) + { + suspension.Dispose(); + return; + } + + await _dispatcher.InvokeAsync(suspension.Dispose); + } + + // The close snapshot runs from ClosingFinalizing, by which point the editor host has disposed + // every tab, so in-memory edits can only reach disk from this earlier ClosingPreparing phase. + private async Task TrySaveForCloseSnapshotAsync(CancellationToken cancellationToken) + { + if (!_config.AutoCommitOnClose + || GetOwnedBackend()?.Repository is null + || _projectService.CurrentProject.Value is not { } project) + { + return; + } + + // Aborting the close is the only way to keep the edits: continuing would dispose the tabs + // and let the close snapshot record the half-saved project as the version to come back to. + using IProjectFileWriteLease closeWrite = + await _editorService.BeginProjectFileWriteAsync(cancellationToken).ConfigureAwait(false); + if (!await TrySaveOpenProjectAsync(project, cancellationToken).ConfigureAwait(false)) + { + PublishNotification(() => + NotificationService.ShowWarning( + Strings.VersionControl, + MessageStrings.OperationFailed)); + throw new ProjectCloseAbortedException(MessageStrings.OperationFailed); + } + } + + private async Task CompletePreparedCloseBarrierAsync( + ProjectService.ProjectCloseContext closeContext, + NonTransactionalCloseBarrier closeBarrier, + bool projectClosed) + { + lock (_stateGate) + { + _preparedCloseBarriers.Remove(closeContext); + } + + await closeBarrier.CompleteAsync(projectClosed).ConfigureAwait(false); + } + + private async Task NotifyProjectClosingAsync( + ProjectService.ProjectCloseContext closeContext, + CancellationToken cancellationToken) + { + if (IsInternalVersionControlTransition()) + { + return; + } + + NonTransactionalCloseBarrier? closeBarrier; + lock (_stateGate) + { + _preparedCloseBarriers.TryGetValue(closeContext, out closeBarrier); + } + + if (closeBarrier is not null) + { + await NotifyClosingCoreAsync(cancellationToken).ConfigureAwait(false); + } + } + + private async Task NotifyClosingCoreAsync(CancellationToken closeCancellation) + { + ActivationContext? activation; + string? projectRoot; + long activationRevision; + lock (_stateGate) + { + if (_disposed) + { + return; + } + + activation = _activation; + projectRoot = _state.ProjectRoot; + activationRevision = _latestActivationRevision; + } + + if (activation is not null) + { + await activation.Completion.WaitAsync(closeCancellation).ConfigureAwait(false); + } + + closeCancellation.ThrowIfCancellationRequested(); + try + { + IProjectVersionControlBackend service; + bool finalSnapshotRequested; + lock (_stateGate) + { + if (_disposed + || projectRoot is null + || activationRevision != _latestActivationRevision + || _state.ProjectRoot is not { } currentRoot + || !string.Equals(currentRoot, projectRoot, PathComparison)) + { + return; + } + + IProjectVersionControlBackend? ownedService = _state.OwnedService; + if (ownedService is null) + { + return; + } + + service = ownedService; + finalSnapshotRequested = _config.AutoCommitOnClose; + } + + bool snapshotRequiresReservation = + finalSnapshotRequested && service.Repository is not null; + using IDisposable? snapshotMutation = snapshotRequiresReservation + ? TryBeginWorktreeMutation() + : null; + bool snapshotReserved = !snapshotRequiresReservation || snapshotMutation is not null; + if (!snapshotReserved) + { + _logger.LogInformation( + "Skipped the {SnapshotKind} project snapshot because the workspace is reserved.", + SnapshotKind.Close); + } + + ProjectVersionControlFinalSnapshot? finalSnapshot; + bool schedulePublication; + lock (_stateGate) + { + if (_disposed + || projectRoot is null + || activationRevision != _latestActivationRevision + || _state.ProjectRoot is not { } currentRoot + || !string.Equals(currentRoot, projectRoot, PathComparison) + || !ReferenceEquals(_state.OwnedService, service)) + { + return; + } + + finalSnapshot = + finalSnapshotRequested && snapshotReserved + ? new ProjectVersionControlFinalSnapshot( + CloseSnapshotMessage, + SnapshotKind.Close) + : null; + + bool visibilityHidden = ReferenceEquals(_state.VisibleService, service); + schedulePublication = visibilityHidden + && TransitionStateLocked( + _state with + { + VisibleService = null, + IsTracked = false, + }); + } + + SchedulePublicationDrain(schedulePublication); + await FlushPublicationDrainAsync().ConfigureAwait(false); + closeCancellation.ThrowIfCancellationRequested(); + + try + { + await service.RetireAsync(finalSnapshot).ConfigureAwait(false); + } + finally + { + DetachRetiredService(service); + } + } + catch (OperationCanceledException) when (closeCancellation.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to retire version control while closing the project."); + } + } + + public Task RestoreAsync( + string sha, + CancellationToken cancellationToken = default) + { + GitRevisionValidator.ValidateCommitId(sha, nameof(sha)); + CancelPendingPullRecoveryOffer(); + return RunRestoreCycleAsync(sha, branchName: null, cancellationToken); + } + + public Task RestoreToNewBranchAsync( + string sha, + string branchName, + CancellationToken cancellationToken = default) + { + GitRevisionValidator.ValidateCommitId(sha, nameof(sha)); + ArgumentException.ThrowIfNullOrWhiteSpace(branchName); + CancelPendingPullRecoveryOffer(); + return RunRestoreCycleAsync(sha, branchName, cancellationToken); + } + + public async Task CommitManualAsync( + string message, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(message); + using NonTransactionalOperationLease operation = + await BeginNonTransactionalOperationAsync(cancellationToken); + using IDisposable? worktreeMutation = TryBeginWorktreeMutation(); + if (worktreeMutation is null) + { + throw new InvalidOperationException(Strings.VersionControl_WorkspaceBusy); + } + + CancellationToken operationCancellation = operation.CancellationToken; + IProjectVersionControlBackend service = GetTrackedBackend(); + // A manual version has to record what the user sees, so in-memory edits reach disk first. + if (_projectService.CurrentProject.Value is { } project + && !await TrySaveOpenProjectAsync(project, operationCancellation)) + { + throw new InvalidOperationException(MessageStrings.OperationFailed); + } + + try + { + return await service.CommitAllAsync( + message.Trim(), + SnapshotKind.Manual, + operationCancellation); + } + catch (GitIdentityRequiredException) + { + GitIdentity? identity = await RequestIdentityAsync(operationCancellation); + if (identity is null) + { + throw; + } + + operationCancellation.ThrowIfCancellationRequested(); + await service.SetLocalIdentityAsync(identity, operationCancellation); + // The editor stays live while the identity prompt is open, so the save above can be + // stale by now; without a second one the retry reports a manual version as created + // without the edits the user made while typing their name and email. + if (_projectService.CurrentProject.Value is { } identifiedProject + && !await TrySaveOpenProjectAsync(identifiedProject, operationCancellation)) + { + throw new InvalidOperationException(MessageStrings.OperationFailed); + } + + return await service.CommitAllAsync( + message.Trim(), + SnapshotKind.Manual, + operationCancellation); + } + } + + public Task CreateBranchAsync( + string branchName, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(branchName); + CancelPendingPullRecoveryOffer(); + return RunBranchCycleAsync(branchName.Trim(), create: true, cancellationToken); + } + + public Task SwitchBranchAsync( + string branchName, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(branchName); + CancelPendingPullRecoveryOffer(); + return RunBranchCycleAsync(branchName.Trim(), create: false, cancellationToken); + } + + public async Task SetRemoteAsync( + string url, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(url); + using NonTransactionalOperationLease operation = + await BeginNonTransactionalOperationAsync(cancellationToken); + await GetTrackedBackend().SetRemoteAsync(url.Trim(), operation.CancellationToken); + } + + public async Task SetLocalIdentityAsync( + GitIdentity identity, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(identity); + using NonTransactionalOperationLease operation = + await BeginNonTransactionalOperationAsync(cancellationToken); + await GetTrackedBackend().SetLocalIdentityAsync(identity, operation.CancellationToken); + } + + public async Task PushAsync( + IProgress? progress, + CancellationToken cancellationToken = default) + { + using NonTransactionalOperationLease operation = + await BeginNonTransactionalOperationAsync(cancellationToken); + return await GetTrackedBackend().PushAsync(progress, operation.CancellationToken); + } + + public Task PullAsync(CancellationToken cancellationToken = default) + { + CancelPendingPullRecoveryOffer(); + return RunPullCycleAsync(cancellationToken); + } + + public async Task> GetPendingPullRecoveriesAsync( + CancellationToken cancellationToken = default) + { + using NonTransactionalOperationLease operation = + await BeginNonTransactionalOperationAsync(cancellationToken); + IProjectVersionControlBackend service = GetTrackedBackend(); + IReadOnlyList recoveries = + await service.ExecuteExclusiveAsync( + transaction => transaction.GetPendingPullRecoveriesAsync( + operation.CancellationToken), + operation.CancellationToken); + ReconcileOfferedPendingRecoveryIds(recoveries); + return recoveries.Select(ToRecoveryInfo).ToArray(); + } + + public Task RecoverPendingPullAsync( + string recoveryId, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(recoveryId); + CancelPendingPullRecoveryOffer(); + return RunPendingPullRecoveryCycleAsync( + recoveryId, + requireConfirmation: true, + cancellationToken); + } + + public void Dispose() + { + BeginDisposal(); + StartDisposalCompletion(); + } + + public ValueTask DisposeAsync() + { + BeginDisposal(); + StartDisposalCompletion(); + return new ValueTask(_asyncDisposalCompletion.Task); + } + + private void StartDisposalCompletion() + { + if (Interlocked.CompareExchange(ref _asyncDisposalStarted, 1, 0) == 0) + { + _ = CompleteDisposalAsync(); + _ = ObserveDisposalCompletionAsync(); + } + } + + private async Task ObserveDisposalCompletionAsync() + { + try + { + await _asyncDisposalCompletion.Task.ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to complete version-control coordinator disposal."); + } + } + + private void BeginDisposal() + { + bool clearProjectState; + CancellationTokenSource? configurationActivationCancellation; + CancellationTokenSource? projectServiceEpochCancellation; + lock (_stateGate) + { + if (_disposed) + { + return; + } + + _disposed = true; + _pendingConfigurationActivation = null; + _pendingOpeningRepositoryDecision = null; + _openingPullRecoveries.Clear(); + configurationActivationCancellation = _configurationActivationCancellation; + projectServiceEpochCancellation = _projectServiceEpochCancellation; + _projectServiceEpochCancellation = null; + clearProjectState = _closeBarrierUsers == 0 + && _lifecycleUsers == 0 + && _operationUsers == 0; + } + + try + { + _lifetimeCancellation.Cancel(); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "A cancellation callback failed while disposing version control."); + } + CancelConfigurationActivation(configurationActivationCancellation); + CancelProjectServiceEpoch(projectServiceEpochCancellation); + _config.ConfigurationChanged -= OnVersionControlConfigChanged; + _projectService.OpeningPreflight -= PrepareProjectOpeningAsync; + _projectService.Opening -= InspectProjectOpeningAsync; + _projectService.ClosingPreparing -= PrepareProjectClosingAsync; + _projectService.ClosingFinalizing -= NotifyProjectClosingAsync; + _projectSubscription.Dispose(); + if (ReferenceEquals(_editorService.ProjectVersionControlCoordinator, this)) + { + _editorService.ProjectVersionControlCoordinator = null; + } + + if (clearProjectState) + { + ClearProjectState(); + } + else + { + SetVisibleService(null); + } + + DisposePublishedProperties(); + } + + private async Task CompleteDisposalAsync() + { + try + { + await WaitForAvailabilityQuiescenceAsync().ConfigureAwait(false); + await WaitForOperationQuiescenceAsync().ConfigureAwait(false); + await WaitForCloseBarrierQuiescenceAsync().ConfigureAwait(false); + await WaitForLifecycleQuiescenceAsync().ConfigureAwait(false); + await WaitForActivationSetupQuiescenceAsync().ConfigureAwait(false); + await WaitForPendingRecoveryOfferQuiescenceAsync().ConfigureAwait(false); + ClearProjectState(); + await WaitForLockRecoveryQuiescenceAsync().ConfigureAwait(false); + await WaitForNotificationQuiescenceAsync().ConfigureAwait(false); + await FlushPublicationDrainAsync(); + await _propertiesDisposedCompletion.Task.ConfigureAwait(false); + await WaitForRetirementQuiescenceAsync().ConfigureAwait(false); + DisposeOperationEpochCancellation(); + _lifetimeCancellation.Dispose(); + _asyncDisposalCompletion.TrySetResult(); + } + catch (Exception ex) + { + _asyncDisposalCompletion.TrySetException(ex); + } + } + + private void DisposeOperationEpochCancellation() + { + CancellationTokenSource? operationEpochCancellation; + lock (_stateGate) + { + operationEpochCancellation = _operationEpochCancellation; + _operationEpochCancellation = null; + } + + operationEpochCancellation?.Dispose(); + } + + private Task WaitForAvailabilityQuiescenceAsync() + { + lock (_stateGate) + { + if (_availabilityUsers == 0) + { + return Task.CompletedTask; + } + + return (_availabilityQuiesced ??= CreateCompletionSource()).Task; + } + } + + private Task WaitForActivationSetupQuiescenceAsync() + { + lock (_stateGate) + { + if (_activationSetupUsers == 0) + { + return Task.CompletedTask; + } + + return (_activationSetupsQuiesced ??= CreateCompletionSource()).Task; + } + } + + private Task WaitForLifecycleQuiescenceAsync() + { + lock (_stateGate) + { + if (_lifecycleUsers == 0) + { + return Task.CompletedTask; + } + + return (_lifecycleQuiesced ??= CreateCompletionSource()).Task; + } + } + + private Task WaitForCloseBarrierQuiescenceAsync() + { + lock (_stateGate) + { + if (_closeBarrierUsers == 0) + { + return Task.CompletedTask; + } + + return (_closeBarriersQuiesced ??= CreateCompletionSource()).Task; + } + } + + private Task WaitForOperationQuiescenceAsync() + { + lock (_stateGate) + { + if (_operationUsers == 0) + { + return Task.CompletedTask; + } + + return (_operationsQuiesced ??= CreateCompletionSource()).Task; + } + } + + private Task WaitForLockRecoveryQuiescenceAsync() + { + lock (_stateGate) + { + if (_lockRecoveryUsers == 0) + { + return Task.CompletedTask; + } + + return (_lockRecoveryQuiesced ??= CreateCompletionSource()).Task; + } + } + + private Task WaitForPendingRecoveryOfferQuiescenceAsync() + { + lock (_stateGate) + { + if (_pendingRecoveryOfferUsers == 0) + { + return Task.CompletedTask; + } + + return (_pendingRecoveryOffersQuiesced ??= CreateCompletionSource()).Task; + } + } + + private Task WaitForRetirementQuiescenceAsync() + { + lock (_stateGate) + { + if (_retirementUsers == 0) + { + return Task.CompletedTask; + } + + return (_retirementsQuiesced ??= CreateCompletionSource()).Task; + } + } + + private Task WaitForNotificationQuiescenceAsync() + { + lock (_stateGate) + { + if (_notificationUsers == 0) + { + return Task.CompletedTask; + } + + return (_notificationsQuiesced ??= CreateCompletionSource()).Task; + } + } + + private async Task FlushPublicationDrainAsync() + { + Task? runningDrain; + lock (_stateGate) + { + runningDrain = _publicationDrainRunning + ? (_publicationDrainQuiesced ??= CreateCompletionSource()).Task + : null; + } + + if (runningDrain is not null) + { + await runningDrain.ConfigureAwait(false); + return; + } + + if (_dispatcher.CheckAccess()) + { + DrainStatePublications(); + } + else + { + await _dispatcher.InvokeAsync(DrainStatePublications); + } + } + + private static TaskCompletionSource CreateCompletionSource() + { + return new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + private async Task RunBranchCycleAsync( + string branchName, + bool create, + CancellationToken cancellationToken) + { + await BeginLifecycleOperationAsync(cancellationToken); + bool gateEntered = false; + try + { + await _lifecycleGate.WaitAsync(cancellationToken); + gateEntered = true; + ThrowIfLifecycleOperationUnavailable(); + await using ProjectService.ProjectTransitionScope transition = + await _projectService.BeginVersionControlTransitionAsync(this, cancellationToken); + ThrowIfLifecycleOperationUnavailable(); + using IDisposable? worktreeMutation = TryBeginWorktreeMutation(); + if (worktreeMutation is null) + { + return false; + } + + Project project = GetOpenProject(); + string projectFile = GetProjectFile(project); + IProjectVersionControlBackend ownedService = GetTrackedBackend(); + return await ownedService.ExecuteExclusiveAsync( + async service => + { + if (create + && !await CanCreateBranchAsync( + service, + branchName, + cancellationToken)) + { + return false; + } + + if (!create + && !await LocalBranchExistsAsync( + service, + branchName, + cancellationToken)) + { + return false; + } + + WorkspaceStatus status = await service.GetStatusAsync(cancellationToken); + if (!EnsureRepositoryIsNotConflicted(status)) + { + return false; + } + + if (!create + && string.Equals(status.Branch, branchName, StringComparison.Ordinal)) + { + return true; + } + + if (!await ConfirmSwitchBranchAsync(branchName, cancellationToken)) + { + return false; + } + + cancellationToken.ThrowIfCancellationRequested(); + if (create + && !await CanCreateBranchAsync( + service, + branchName, + CancellationToken.None)) + { + return false; + } + + // Held until the project is closed further down: the awaits between this save + // and the close run real Git commands, and an edit made in that window would + // miss the safety snapshot and be discarded when the editors close. + using IDisposable editorSuspension = _editorService.SuspendEditors(); + if (!await TrySaveOpenProjectAsync(project, CancellationToken.None)) + { + PublishNotification(() => + NotificationService.ShowError( + Strings.VersionControl, + MessageStrings.OperationFailed)); + return false; + } + + status = await service.GetStatusAsync(CancellationToken.None); + if (!EnsureRepositoryIsNotConflicted(status)) + { + return false; + } + + CheckedOutBranchTip originalTip = await service.GetCheckedOutBranchTipAsync( + CancellationToken.None); + if (!status.IsClean) + { + CommitResult? result = await CommitSafetySnapshotAsync( + service, + SwitchSafetySnapshotMessage, + CancellationToken.None); + if (result is null) + { + return false; + } + + CheckedOutBranchTip committedTip = await service.GetCheckedOutBranchTipAsync( + CancellationToken.None); + originalTip = GetExpectedTipAfterCommitAll( + originalTip, + result, + committedTip); + if (!BranchTipsEqual(committedTip, originalTip)) + { + throw new InvalidOperationException( + "The branch ref changed while the switch safety snapshot was committed."); + } + } + + if (create + && !await CanCreateBranchAsync( + service, + branchName, + CancellationToken.None)) + { + return false; + } + + if (!create + && !await LocalBranchExistsAsync( + service, + branchName, + CancellationToken.None)) + { + return false; + } + + if (!create) + { + // The switch below runs uncancellable with the project closed, and its LFS + // smudge filter would download missing objects there - a stalled endpoint + // would strand the closed project. Pull them in first, while the operation + // is still cancellable and the project is still open. + await service.PrefetchBranchLfsObjectsAsync(branchName, cancellationToken); + } + + CheckedOutBranchTip expectedResultTip = originalTip; + bool projectClosed = false; + try + { + await CloseProjectForOperationAsync(transition, CancellationToken.None); + projectClosed = true; + try + { + if (create) + { + await service.CreateBranchAsync( + branchName, + originalTip.Commit, + CancellationToken.None); + } + else + { + await service.SwitchBranchAsync( + branchName, + CancellationToken.None); + } + } + catch + { + expectedResultTip = await service.GetCheckedOutBranchTipAsync( + CancellationToken.None); + throw; + } + + expectedResultTip = await service.GetCheckedOutBranchTipAsync( + CancellationToken.None); + await ReopenProjectAsync(transition, projectFile); + return true; + } + catch (Exception ex) + { + Exception? recoveryFailure = projectClosed + ? await TryRestoreOriginalStateAsync( + service, + originalTip, + expectedResultTip, + RecoveryKind.Branch, + transition, + projectFile) + : null; + return HandleCycleFailure( + ex, + recoveryFailure, + $"branch '{branchName}'", + cancellationToken); + } + finally + { + FinishInternalTransition(); + } + }, + cancellationToken); + } + finally + { + FinishLifecycleOperation(gateEntered); + } + } + + private static async Task LocalBranchExistsAsync( + IProjectVersionControlTransaction service, + string branchName, + CancellationToken cancellationToken) + { + IReadOnlyList branches = await service.GetBranchesAsync(cancellationToken); + return branches.Any(branch => + string.Equals(branch.Name, branchName, StringComparison.Ordinal)); + } + + private static Task CanCreateBranchAsync( + IProjectVersionControlTransaction service, + string branchName, + CancellationToken cancellationToken) + => service.CanCreateBranchAsync(branchName, cancellationToken); + + private async Task RunPullCycleAsync(CancellationToken cancellationToken) + { + CancellationTokenSource? confirmationCancellation = null; + try + { + confirmationCancellation = + CreateProjectServiceEpochCancellation(cancellationToken); + RemoteOpResult? preliminaryResult = + await RunPullPreflightCycleAsync(confirmationCancellation.Token); + if (preliminaryResult is not null) + { + return preliminaryResult; + } + + if (!await ConfirmPullAsync(confirmationCancellation.Token).ConfigureAwait(false)) + { + return new RemoteOpResult.Failed(string.Empty); + } + } + catch (OperationCanceledException) + when (confirmationCancellation?.IsCancellationRequested == true + && !cancellationToken.IsCancellationRequested) + { + return new RemoteOpResult.Failed( + "The open project changed while the pull was awaiting confirmation."); + } + catch (ObjectDisposedException ex) + { + _logger.LogInformation( + ex, + "Skipped pull because its project/service epoch was unavailable before confirmation."); + return new RemoteOpResult.Failed( + "The open project changed while the pull was being prepared."); + } + catch (InvalidOperationException ex) + { + _logger.LogInformation( + ex, + "Skipped pull because the project lifecycle changed before confirmation."); + return new RemoteOpResult.Failed( + "The open project changed while the pull was being prepared."); + } + finally + { + confirmationCancellation?.Dispose(); + } + + PullMutationOutcome outcome; + try + { + outcome = await RunPullMutationCycleAsync(cancellationToken); + } + catch (ObjectDisposedException ex) + { + _logger.LogInformation( + ex, + "Skipped pull because its backend retired after confirmation."); + return new RemoteOpResult.Failed( + "The open project changed while the pull was being prepared."); + } + catch (InvalidOperationException ex) + { + _logger.LogInformation( + ex, + "Skipped pull because the project lifecycle changed after confirmation."); + return new RemoteOpResult.Failed( + "The open project changed while the pull was being prepared."); + } + + if (outcome.Recovery is not null) + { + await OfferUncertainPullRecoveryAsync( + outcome.Recovery, + outcome.ProjectFile) + .ConfigureAwait(false); + } + + return outcome.Result; + } + + private async Task RunPullPreflightCycleAsync( + CancellationToken cancellationToken) + { + await BeginLifecycleOperationAsync(cancellationToken); + bool gateEntered = false; + try + { + await _lifecycleGate.WaitAsync(cancellationToken); + gateEntered = true; + ThrowIfLifecycleOperationUnavailable(); + IProjectVersionControlBackend ownedService = GetTrackedBackend(); + return await ownedService.ExecuteExclusiveAsync( + async service => + { + WorkspaceStatus status = await service.GetStatusAsync(cancellationToken); + if (!EnsureRepositoryIsNotConflicted(status)) + { + return new RemoteOpResult.Failed( + Strings.VersionControl_ConflictGuidance); + } + + CheckedOutBranchTip originalHead = + await service.GetCheckedOutBranchTipAsync(cancellationToken); + PullPreflightResult preflight = await service.PreflightPullAsync( + originalHead, + cancellationToken); + return preflight.Result is RemoteOpResult.Success + && preflight.RequiresTransition + ? null + : preflight.Result; + }, + cancellationToken); + } + finally + { + FinishLifecycleOperation(gateEntered); + } + } + + private async Task RunPullMutationCycleAsync( + CancellationToken cancellationToken) + { + await BeginLifecycleOperationAsync(cancellationToken); + bool gateEntered = false; + try + { + await _lifecycleGate.WaitAsync(cancellationToken); + gateEntered = true; + ThrowIfLifecycleOperationUnavailable(); + Project project = GetOpenProject(); + string projectFile = GetProjectFile(project); + IProjectVersionControlBackend ownedService = GetTrackedBackend(); + cancellationToken.ThrowIfCancellationRequested(); + await using ProjectService.ProjectTransitionScope transition = + await _projectService.BeginVersionControlTransitionAsync( + this, + cancellationToken); + ThrowIfLifecycleOperationUnavailable(); + using IDisposable? worktreeMutation = TryBeginWorktreeMutation(); + if (worktreeMutation is null) + { + return new PullMutationOutcome( + new RemoteOpResult.Failed(Strings.VersionControl_WorkspaceBusy), + null, + projectFile); + } + + try + { + if (!ReferenceEquals(_projectService.CurrentProject.Value, project) + || !ReferenceEquals(GetOwnedBackend(), ownedService)) + { + return new PullMutationOutcome( + new RemoteOpResult.Failed( + "The open project changed while the pull was being prepared."), + null, + projectFile); + } + + PendingPullRecovery? recoveryToOffer = null; + RemoteOpResult result = await ownedService.ExecuteExclusiveAsync( + async service => + { + // Held until the project is closed further down, so an edit made while the + // preflight and checkpoint awaits run cannot miss the safety checkpoint. + using IDisposable editorSuspension = _editorService.SuspendEditors(); + if (!await TrySaveOpenProjectAsync(project, cancellationToken)) + { + return new RemoteOpResult.Failed( + "The open project could not be saved before pulling."); + } + + WorkspaceStatus status = await service.GetStatusAsync(cancellationToken); + if (!EnsureRepositoryIsNotConflicted(status)) + { + return new RemoteOpResult.Failed( + Strings.VersionControl_ConflictGuidance); + } + + CheckedOutBranchTip originalHead = + await service.GetCheckedOutBranchTipAsync(cancellationToken); + PullPreflightResult preflight = await service.PreflightPullAsync( + originalHead, + cancellationToken); + if (preflight.Result is not RemoteOpResult.Success + || !preflight.RequiresTransition) + { + return preflight.Result; + } + + ProjectCheckpoint? checkpoint = status.IsClean + ? null + : await service.CreateProjectCheckpointAsync( + PullSafetySnapshotMessage, + CancellationToken.None); + bool projectClosed = false; + CheckedOutBranchTip expectedCurrentHead = originalHead; + PendingPullRecovery? pendingRecovery = null; + PullTransitionState pullTransitionState = PullTransitionState.Unchanged; + // Same reason as the restore and branch-switch paths: the fast-forward + // checkout runs uncancellable with the project closed, so the objects its + // LFS smudge filter needs are pulled in here, while this is still + // cancellable and the project is still open. The preflight's fetch moved + // the remote-tracking ref and not the local branch, so the prefetch has to + // name the fetched commit - a branch name would resolve to the pre-pull tip + // and miss exactly the objects the checkout is about to need. + if (preflight.UpstreamCommit is { } upstreamCommit) + { + await service.PrefetchCommitLfsObjectsAsync( + upstreamCommit, + cancellationToken); + } + try + { + await CloseProjectForOperationAsync(transition, CancellationToken.None); + projectClosed = true; + FastForwardPullResult pull = await service.PullFastForwardAsync( + originalHead, + checkpoint, + projectFile, + cancellationToken); + + RemoteOpResult result = pull.Result; + expectedCurrentHead = pull.Tip; + pendingRecovery = pull.Recovery; + if (pendingRecovery is not null) + { + PublishPendingPullRecoveriesChanged(); + } + pullTransitionState = pull.TransitionState; + if (pullTransitionState is PullTransitionState.OwnershipLost + or PullTransitionState.RecoveryFailed) + { + recoveryToOffer = pendingRecovery; + return new RemoteOpResult.Failed( + Strings.VersionControl_PullTransitionUncertain); + } + + if (result is not RemoteOpResult.Success) + { + Exception? recoveryFailure = await TryRecoverPullAsync( + service, + originalHead, + expectedCurrentHead, + checkpoint, + transition, + projectFile); + if (recoveryFailure is not null) + { + _logger.LogError( + recoveryFailure, + "Failed to recover a pull after {PullError}.", + GetRemoteOperationError(result)); + recoveryToOffer = pendingRecovery; + return new RemoteOpResult.Failed( + Strings.VersionControl_PullTransitionUncertain); + } + + await TryCompletePullRecoveryAsync( + service, + pendingRecovery, + checkpoint); + return result; + } + + CheckedOutBranchTip verifiedHead = + await service.GetCheckedOutBranchTipAsync(CancellationToken.None); + if (!BranchTipsEqual(verifiedHead, expectedCurrentHead)) + { + throw new InvalidOperationException( + "The repository ref changed before the pulled project could be reopened."); + } + + await ReopenProjectAsync(transition, projectFile); + await TryCompletePullRecoveryAsync( + service, + pendingRecovery, + checkpoint); + return new RemoteOpResult.Success(); + } + catch (Exception ex) + { + if (projectClosed + && pullTransitionState is PullTransitionState.OwnershipLost + or PullTransitionState.RecoveryFailed) + { + _logger.LogError( + ex, + "The pull transition became uncertain after the project was closed."); + recoveryToOffer = pendingRecovery; + return new RemoteOpResult.Failed( + Strings.VersionControl_PullTransitionUncertain); + } + + Exception? recoveryFailure = projectClosed + ? await TryRecoverPullAsync( + service, + originalHead, + expectedCurrentHead, + checkpoint, + transition, + projectFile) + : null; + if (ex is OperationCanceledException + && cancellationToken.IsCancellationRequested) + { + throw; + } + + if (projectClosed && recoveryFailure is null) + { + await TryCompletePullRecoveryAsync( + service, + pendingRecovery, + checkpoint); + } + + if (recoveryFailure is not null) + { + _logger.LogError( + recoveryFailure, + "Failed to recover a pull after {PullError}.", + GetErrorText(ex)); + recoveryToOffer = pendingRecovery; + return new RemoteOpResult.Failed( + Strings.VersionControl_PullTransitionUncertain); + } + + _logger.LogError(ex, "Failed to pull project versions."); + return new RemoteOpResult.Failed(GetErrorText(ex)); + } + }, + cancellationToken); + return new PullMutationOutcome(result, recoveryToOffer, projectFile); + } + finally + { + FinishInternalTransition(); + } + } + finally + { + FinishLifecycleOperation(gateEntered); + } + } + + private async Task OfferUncertainPullRecoveryAsync( + PendingPullRecovery recovery, + string projectFile) + { + bool recovered; + using (NonTransactionalOperationLease? operation = + TryBeginNonTransactionalOperation(CancellationToken.None)) + { + if (operation is null) + { + return; + } + + recovered = await TryRecoverPendingPullBeforeOpeningAsync( + projectFile, + operation.CancellationToken, + recovery.Id) + .ConfigureAwait(false); + } + + if (!recovered || !File.Exists(projectFile)) + { + return; + } + + try + { + await _projectService.OpenProject(projectFile).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "The pending pull state {RecoveryId} was recovered, but its project could not be opened.", + recovery.Id); + } + } + + private async Task RunPendingPullRecoveryCycleAsync( + string recoveryId, + bool requireConfirmation, + CancellationToken cancellationToken, + PendingPullRecovery? confirmedRecovery = null) + { + CancellationTokenSource? confirmationCancellation = null; + CancellationToken lookupCancellation = default; + try + { + if (requireConfirmation) + { + confirmationCancellation = + CreateProjectServiceEpochCancellation(cancellationToken); + using (NonTransactionalOperationLease operation = + await BeginNonTransactionalOperationAsync( + confirmationCancellation.Token)) + { + lookupCancellation = operation.CancellationToken; + Project project = GetOpenProject(); + string projectFile = GetProjectFile(project); + IProjectVersionControlBackend service = GetTrackedBackend(); + confirmedRecovery = await service.ExecuteExclusiveAsync( + async transaction => + (await transaction.GetPendingPullRecoveriesAsync( + operation.CancellationToken)) + .SingleOrDefault(candidate => string.Equals( + candidate.Id, + recoveryId, + StringComparison.Ordinal)), + operation.CancellationToken); + if (confirmedRecovery is null + || !RecoveryProjectPathsEqual( + projectFile, + confirmedRecovery.ProjectFile)) + { + return new ProjectRecoveryResult.NotFoundOrChanged(); + } + } + + if (!await ConfirmPendingPullRecoveryAsync( + ToRecoveryInfo(confirmedRecovery), + confirmationCancellation.Token) + .ConfigureAwait(false)) + { + return new ProjectRecoveryResult.Declined(); + } + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (OperationCanceledException) + when (confirmationCancellation?.IsCancellationRequested == true + || lookupCancellation.IsCancellationRequested + || _lifetimeCancellation.IsCancellationRequested) + { + return new ProjectRecoveryResult.Unavailable(); + } + catch (ObjectDisposedException ex) + { + _logger.LogInformation( + ex, + "Skipped pending pull recovery because its backend retired before confirmation."); + return new ProjectRecoveryResult.Unavailable(); + } + catch (InvalidOperationException ex) + { + _logger.LogInformation( + ex, + "Skipped pending pull recovery because it became unavailable before confirmation."); + return new ProjectRecoveryResult.Unavailable(); + } + finally + { + confirmationCancellation?.Dispose(); + } + + try + { + return await RunPendingPullRecoveryMutationCycleAsync( + recoveryId, + confirmedRecovery, + cancellationToken) + .ConfigureAwait(false); + } + catch (ObjectDisposedException ex) + { + _logger.LogInformation( + ex, + "Skipped pending pull recovery because its backend retired after confirmation."); + return new ProjectRecoveryResult.Unavailable(); + } + catch (InvalidOperationException ex) + { + _logger.LogInformation( + ex, + "Skipped pending pull recovery because the project lifecycle changed after confirmation."); + return new ProjectRecoveryResult.Unavailable(); + } + } + + private async Task RunPendingPullRecoveryMutationCycleAsync( + string recoveryId, + PendingPullRecovery? confirmedRecovery, + CancellationToken cancellationToken) + { + await BeginLifecycleOperationAsync(cancellationToken); + bool gateEntered = false; + try + { + await _lifecycleGate.WaitAsync(cancellationToken); + gateEntered = true; + ThrowIfLifecycleOperationUnavailable(); + Project project = GetOpenProject(); + IProjectVersionControlBackend ownedService = GetTrackedBackend(); + PendingPullRecovery? offeredRecovery = await ownedService.ExecuteExclusiveAsync( + async service => (await service.GetPendingPullRecoveriesAsync(cancellationToken)) + .SingleOrDefault(candidate => string.Equals( + candidate.Id, + recoveryId, + StringComparison.Ordinal)), + cancellationToken); + if (offeredRecovery is null) + { + return new ProjectRecoveryResult.NotFoundOrChanged(); + } + + string openProjectFile = GetProjectFile(project); + if (!RecoveryProjectPathsEqual( + openProjectFile, + offeredRecovery.ProjectFile) + || confirmedRecovery is not null + && !PendingPullRecoveriesMatch(confirmedRecovery, offeredRecovery)) + { + return new ProjectRecoveryResult.NotFoundOrChanged(); + } + + cancellationToken.ThrowIfCancellationRequested(); + await using ProjectService.ProjectTransitionScope transition = + await _projectService.BeginVersionControlTransitionAsync( + this, + cancellationToken); + ThrowIfLifecycleOperationUnavailable(); + if (!ReferenceEquals(_projectService.CurrentProject.Value, project) + || !ReferenceEquals(GetOwnedBackend(), ownedService)) + { + return new ProjectRecoveryResult.Unavailable(); + } + + using IDisposable? worktreeMutation = TryBeginWorktreeMutation(); + if (worktreeMutation is null) + { + return new ProjectRecoveryResult.Unavailable(); + } + + try + { + return await ownedService.ExecuteExclusiveAsync( + async service => + { + PendingPullRecovery? recovery = + (await service.GetPendingPullRecoveriesAsync(cancellationToken)) + .SingleOrDefault(candidate => string.Equals( + candidate.Id, + recoveryId, + StringComparison.Ordinal)); + if (recovery is null + || !PendingPullRecoveriesMatch(offeredRecovery, recovery) + || !RecoveryProjectPathsEqual( + openProjectFile, + recovery.ProjectFile)) + { + return new ProjectRecoveryResult.NotFoundOrChanged(); + } + + try + { + await CloseProjectForOperationAsync( + transition, + CancellationToken.None); + PendingPullRecoveryOutcome outcome = + await service.RecoverPendingPullRecoveryAsync( + recovery, + CancellationToken.None); + await ReopenProjectAsync(transition, openProjectFile); + await service.CompletePendingPullRecoveryAsync( + recovery, + CancellationToken.None); + CompletePendingPullRecoveryPublication(recovery.Id); + PublishRecoveryOutcomeNotification(recovery, outcome); + return ToProjectRecoveryResult(recovery, outcome); + } + catch (PendingPullRecoveryPreservedException ex) + { + PublishPreservedRecoveryBranchNotification(ex.RecoveryReference); + return new ProjectRecoveryResult.FailedPreserved( + ex.RecoveryReference); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Failed to recover pending pull state {RecoveryId}; its retained-reference state could not be verified.", + recovery.Id); + PublishNotification(() => + NotificationService.ShowError( + Strings.VersionControl_ErrorTitle, + string.Format( + Strings.VersionControl_RecoveryFailed, + Strings.VersionControl_PullTransitionUncertain, + GetErrorText(ex)))); + return new ProjectRecoveryResult.FailedUncertain(); + } + }, + cancellationToken); + } + finally + { + FinishInternalTransition(); + } + } + finally + { + FinishLifecycleOperation(gateEntered); + } + } + + private static ProjectRecoveryInfo ToRecoveryInfo(PendingPullRecovery recovery) + { + return new ProjectRecoveryInfo( + recovery.Id, + Path.GetFileName(recovery.ProjectFile), + recovery.CreatedAt); + } + + private static ProjectRecoveryResult ToProjectRecoveryResult( + PendingPullRecovery recovery, + PendingPullRecoveryOutcome outcome) + { + return outcome switch + { + PendingPullRecoveryOutcome.RestoredOriginal + => new ProjectRecoveryResult.RestoredOriginal(), + PendingPullRecoveryOutcome.ReappliedCheckpoint + => new ProjectRecoveryResult.ReappliedCheckpoint( + recovery.RecoveryBranchName), + _ => throw new ArgumentOutOfRangeException(nameof(outcome)), + }; + } + + private void PublishRecoveryOutcomeNotification( + PendingPullRecovery recovery, + PendingPullRecoveryOutcome outcome) + { + PublishNotification(() => NotificationService.ShowInformation( + Strings.VersionControl, + outcome == PendingPullRecoveryOutcome.ReappliedCheckpoint + ? string.Format( + Strings.VersionControl_CheckpointReappliedOnRecoveryBranch, + recovery.RecoveryBranchName) + : Strings.VersionControl_PullRecovered)); + } + + private void PublishPreservedRecoveryBranchNotification(string recoveryReference) + { + PublishNotification(() => NotificationService.ShowWarning( + Strings.VersionControl, + string.Format( + Strings.VersionControl_CheckpointPreservedOnRecoveryBranch, + recoveryReference))); + } + + private static bool PendingPullRecoveriesMatch( + PendingPullRecovery expected, + PendingPullRecovery actual, + RepositoryInfo? repository = null) + { + return string.Equals(expected.Id, actual.Id, StringComparison.Ordinal) + && string.Equals( + expected.DescriptorRef, + actual.DescriptorRef, + StringComparison.Ordinal) + && string.Equals( + expected.DescriptorObject, + actual.DescriptorObject, + StringComparison.OrdinalIgnoreCase) + && (repository is null + ? RecoveryProjectPathsEqual( + expected.ProjectFile, + actual.ProjectFile) + : RecoveryProjectPathsEqual( + repository, + expected.ProjectFile, + actual.ProjectFile)); + } + + private static bool PathsEqual(string left, string right) + { + return RepositoryPathComparer.AreEquivalent(left, right); + } + + private static bool RepositoriesEqual(RepositoryInfo left, RepositoryInfo right) + { + return left.IsNestedInForeignRepo == right.IsNestedInForeignRepo + && PathsEqual(left.RepoRoot, right.RepoRoot) + && PathsEqual(left.ProjectRoot, right.ProjectRoot); + } + + private static bool RecoveryProjectPathsEqual(string left, string right) + { + string lexicalLeft = Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)); + string lexicalRight = Path.TrimEndingDirectorySeparator(Path.GetFullPath(right)); + if (string.Equals(lexicalLeft, lexicalRight, PathComparison)) + { + return true; + } + + try + { + return PathsEqual(left, right); + } + catch (Exception ex) + when (ex is IOException + or UnauthorizedAccessException + or NotSupportedException + or ArgumentException) + { + return false; + } + } + + private static bool RecoveryProjectPathsEqual( + RepositoryInfo repository, + string left, + string right) + { + if (TryGetRecoveryRelativePath(repository, left, out string? leftRelative) + && TryGetRecoveryRelativePath(repository, right, out string? rightRelative) + && string.Equals(leftRelative, rightRelative, PathComparison)) + { + return true; + } + + return RecoveryProjectPathsEqual(left, right); + } + + private static bool TryGetRecoveryRelativePath( + RepositoryInfo repository, + string path, + out string? relativePath) + { + string fullPath = Path.GetFullPath(path); + string? ancestor = Path.GetDirectoryName(fullPath); + while (ancestor is not null) + { + try + { + if (RepositoryPathComparer.AreEquivalent( + ancestor, + repository.ProjectRoot)) + { + relativePath = Path.GetRelativePath(ancestor, fullPath); + return relativePath != ".." + && !relativePath.StartsWith( + $"..{Path.DirectorySeparatorChar}", + StringComparison.Ordinal) + && !Path.IsPathRooted(relativePath); + } + } + catch (Exception ex) + when (ex is IOException + or UnauthorizedAccessException + or NotSupportedException + or ArgumentException) + { + // A mutated child link must not prevent finding a safe lexical root ancestor. + } + + ancestor = Path.GetDirectoryName(ancestor); + } + + relativePath = null; + return false; + } + + // Canonical, not merely fully qualified: RepositoryInfo decides identity on symlink- and + // casing-resolved paths, so a marker keyed lexically is missed when the same project is reopened + // through a different alias, and the recovery is then re-offered or the open aborted. + internal static string GetOpeningRecoveryKey(string projectFile) + { + try + { + return Path.TrimEndingDirectorySeparator( + RepositoryPathComparer.ResolveCanonicalPath(projectFile)); + } + catch (IOException) + { + // A path that cannot be canonicalized at all - a symbolic-link cycle, or a component + // that cannot be read - keeps its lexical key rather than failing the open. Aliases of + // such a path no longer collapse, which is exactly the behaviour before canonical keys. + return Path.TrimEndingDirectorySeparator(Path.GetFullPath(projectFile)); + } + } + + private static void EnsurePendingRecoveryPathIsSafeForOpen( + RepositoryInfo? repository, + PendingPullRecovery? recovery, + string projectFile) + { + if (repository is not null && recovery is not null) + { + EnsureProjectFileIsPhysicallyContained(repository, projectFile); + } + } + + private async Task TryRecoverPullAsync( + IProjectVersionControlTransaction service, + CheckedOutBranchTip originalHead, + CheckedOutBranchTip expectedCurrentHead, + ProjectCheckpoint? checkpoint, + ProjectService.ProjectTransitionScope transition, + string projectFile) + { + try + { + CheckedOutBranchTip actualHead = await service.GetCheckedOutBranchTipAsync(CancellationToken.None); + if (!BranchTipsEqual(actualHead, expectedCurrentHead)) + { + throw new InvalidOperationException( + "The repository HEAD changed while the pull was being recovered."); + } + + if (!BranchTipsEqual(actualHead, originalHead)) + { + BranchTipRollbackResult rollback = await service.TryRollbackBranchTipAsync( + expectedCurrentHead, + originalHead, + CancellationToken.None); + switch (rollback) + { + case BranchTipRollbackResult.RolledBack: + break; + case BranchTipRollbackResult.RefChanged changed: + throw new InvalidOperationException( + $"The repository ref changed to '{changed.ActualCommit}' while the pull was being recovered."); + case BranchTipRollbackResult.UnsafeRepositoryState: + throw new InvalidOperationException( + "The repository contains changes that prevent a safe pull rollback."); + } + } + + if (checkpoint is not null) + { + await service.RestoreProjectCheckpointAsync( + checkpoint, + CancellationToken.None); + } + + CheckedOutBranchTip recoveredHead = await service.GetCheckedOutBranchTipAsync( + CancellationToken.None); + if (!BranchTipsEqual(recoveredHead, originalHead)) + { + throw new InvalidOperationException( + "The repository ref changed after the pull state was restored."); + } + + await ReopenProjectAsync(transition, projectFile); + return null; + } + catch (Exception recoveryException) + { + return recoveryException; + } + } + + private async Task TryDeleteCheckpointAsync( + IProjectVersionControlTransaction service, + ProjectCheckpoint? checkpoint) + { + if (checkpoint is null) + { + return; + } + + try + { + await service.DeleteProjectCheckpointAsync( + checkpoint, + CancellationToken.None); + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Failed to delete completed project checkpoint {CheckpointRef}.", + checkpoint.RefName); + } + } + + private async Task TryCompletePullRecoveryAsync( + IProjectVersionControlTransaction service, + PendingPullRecovery? recovery, + ProjectCheckpoint? checkpoint) + { + if (recovery is null) + { + await TryDeleteCheckpointAsync(service, checkpoint); + return; + } + + try + { + await service.CompletePendingPullRecoveryAsync( + recovery, + CancellationToken.None); + CompletePendingPullRecoveryPublication(recovery.Id); + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Failed to delete completed pending pull recovery {RecoveryId}.", + recovery.Id); + } + } + + private static bool BranchTipsEqual(CheckedOutBranchTip left, CheckedOutBranchTip right) + { + return string.Equals(left.RefName, right.RefName, StringComparison.Ordinal) + && string.Equals(left.Commit, right.Commit, StringComparison.OrdinalIgnoreCase); + } + + private static CheckedOutBranchTip GetExpectedTipAfterCommit( + CheckedOutBranchTip previousTip, + CommitResult result) + { + return result switch + { + CommitResult.Committed { Revision: CommitRevision.Known revision } + => new CheckedOutBranchTip( + previousTip.RefName, + revision.Sha), + CommitResult.NoChanges or CommitResult.SkippedNoIdentity => previousTip, + _ => throw new ArgumentOutOfRangeException(nameof(result)), + }; + } + + private static CheckedOutBranchTip GetExpectedTipAfterCommitAll( + CheckedOutBranchTip previousTip, + CommitResult result, + CheckedOutBranchTip observedTip) + { + if (result is CommitResult.Committed { Revision: CommitRevision.Unavailable }) + { + if (!string.Equals( + observedTip.RefName, + previousTip.RefName, + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "The checked-out branch changed while the snapshot commit revision was resolved."); + } + + return observedTip; + } + + return GetExpectedTipAfterCommit(previousTip, result); + } + + private static string GetRemoteOperationError(RemoteOpResult result) + { + return result switch + { + RemoteOpResult.AuthFailed failed => failed.Guidance, + RemoteOpResult.Failed failed => failed.Stderr, + RemoteOpResult.Diverged => Strings.VersionControl_Diverged, + RemoteOpResult.Offline => Strings.VersionControl_Offline, + RemoteOpResult.RepositoryDirty => Strings.VersionControl_RepositoryDirty, + RemoteOpResult.Success => string.Empty, + _ => throw new ArgumentOutOfRangeException(nameof(result)), + }; + } + + private async Task RunRestoreCycleAsync( + string sha, + string? branchName, + CancellationToken cancellationToken) + { + await BeginLifecycleOperationAsync(cancellationToken); + bool gateEntered = false; + try + { + await _lifecycleGate.WaitAsync(cancellationToken); + gateEntered = true; + ThrowIfLifecycleOperationUnavailable(); + await using ProjectService.ProjectTransitionScope transition = + await _projectService.BeginVersionControlTransitionAsync(this, cancellationToken); + ThrowIfLifecycleOperationUnavailable(); + using IDisposable? worktreeMutation = TryBeginWorktreeMutation(); + if (worktreeMutation is null) + { + return false; + } + + Project project = _projectService.CurrentProject.Value + ?? throw new InvalidOperationException("No project is open."); + string projectFile = project.Uri?.LocalPath + ?? throw new InvalidOperationException( + "The project has no file path."); + IProjectVersionControlBackend ownedService = GetTrackedBackend(); + if (ownedService.Repository is null) + { + throw new InvalidOperationException( + "The open project is not tracked with Git."); + } + + return await ownedService.ExecuteExclusiveAsync( + async service => + { + if (branchName is not null + && !await CanCreateBranchAsync( + service, + branchName, + cancellationToken)) + { + return false; + } + + if (!await service.RevisionContainsProjectFileAsync( + sha, + projectFile, + cancellationToken)) + { + PublishNotification(() => + NotificationService.ShowWarning( + Strings.VersionControl, + string.Format( + System.Globalization.CultureInfo.CurrentCulture, + Strings.VersionControl_RevisionMissingProject, + GetShortSha(sha)))); + return false; + } + + WorkspaceStatus status = await service.GetStatusAsync(cancellationToken); + if (status.HasConflicts) + { + PublishNotification(() => + NotificationService.ShowWarning( + Strings.VersionControl, + Strings.VersionControl_ConflictGuidance)); + return false; + } + + if (!await ConfirmRestoreAsync(cancellationToken)) + { + return false; + } + + cancellationToken.ThrowIfCancellationRequested(); + if (branchName is not null + && !await CanCreateBranchAsync( + service, + branchName, + CancellationToken.None)) + { + return false; + } + + // Held until the project is closed further down: the awaits between this save + // and the close run real Git commands, and an edit made in that window would + // miss the safety snapshot and be discarded when the editors close. + using IDisposable editorSuspension = _editorService.SuspendEditors(); + if (!await TrySaveOpenProjectAsync(project, CancellationToken.None)) + { + PublishNotification(() => + NotificationService.ShowError( + Strings.VersionControl, + MessageStrings.OperationFailed)); + return false; + } + + status = await service.GetStatusAsync(CancellationToken.None); + if (status.HasConflicts) + { + PublishNotification(() => + NotificationService.ShowWarning( + Strings.VersionControl, + Strings.VersionControl_ConflictGuidance)); + return false; + } + + CheckedOutBranchTip originalTip = + await service.GetCheckedOutBranchTipAsync(CancellationToken.None); + if (!status.IsClean) + { + CommitResult? result = await CommitSafetySnapshotAsync( + service, + RestoreSafetySnapshotMessage, + CancellationToken.None); + if (result is null) + { + return false; + } + + CheckedOutBranchTip committedTip = await service.GetCheckedOutBranchTipAsync( + CancellationToken.None); + originalTip = GetExpectedTipAfterCommitAll( + originalTip, + result, + committedTip); + if (!BranchTipsEqual(committedTip, originalTip)) + { + throw new InvalidOperationException( + "The branch ref changed while the restore safety snapshot was committed."); + } + } + + if (branchName is not null + && !await CanCreateBranchAsync( + service, + branchName, + CancellationToken.None)) + { + return false; + } + + CheckedOutBranchTip expectedResultTip = originalTip; + bool projectClosed = false; + // The checkout below runs uncancellable with the project closed, and its LFS + // smudge filter would download missing objects there - a stalled endpoint would + // strand the closed project. Pull them in first, while the operation is still + // cancellable and the project is still open. + await service.PrefetchCommitLfsObjectsAsync(sha, cancellationToken); + try + { + await CloseProjectForOperationAsync(transition, CancellationToken.None); + projectClosed = true; + + if (branchName is null) + { + CommitResult restoreResult = await service.CommitProjectTreeAsync( + originalTip, + sha, + $"beutl: restore project state from {GetShortSha(sha)}", + SnapshotKind.Restore, + CancellationToken.None); + + expectedResultTip = GetExpectedTipAfterCommit( + originalTip, + restoreResult); + EnsureAutomaticSnapshotWasNotSkipped(restoreResult); + } + else + { + CheckedOutBranchTip branchTip; + try + { + // Branching at the selected commit would check that whole tree out, + // so in an enclosing repository it would roll back files outside the + // project and could overwrite ignored ones. Branch from the current + // tip instead and apply only the project tree on top of it. + await service.CreateBranchAsync( + branchName, + originalTip.Commit, + CancellationToken.None); + branchTip = await service.GetCheckedOutBranchTipAsync( + CancellationToken.None); + expectedResultTip = branchTip; + } + catch + { + expectedResultTip = await service.GetCheckedOutBranchTipAsync( + CancellationToken.None); + throw; + } + + CommitResult restoreResult = await service.CommitProjectTreeAsync( + branchTip, + sha, + $"beutl: restore project state from {GetShortSha(sha)}", + SnapshotKind.Restore, + CancellationToken.None); + + expectedResultTip = GetExpectedTipAfterCommit( + branchTip, + restoreResult); + EnsureAutomaticSnapshotWasNotSkipped(restoreResult); + } + + await ReopenProjectAsync(transition, projectFile); + return true; + } + catch (Exception ex) + { + Exception? recoveryFailure = null; + if (projectClosed) + { + recoveryFailure = await TryRestoreOriginalStateAsync( + service, + originalTip, + expectedResultTip, + branchName is null ? RecoveryKind.Restore : RecoveryKind.Branch, + transition, + projectFile); + } + + if (recoveryFailure is not null) + { + var combined = new AggregateException( + "The version-control operation and recovery both failed.", + ex, + recoveryFailure); + _logger.LogError( + combined, + "Failed to restore project version {Commit}, and the original state could not be recovered.", + sha); + PublishNotification(() => + NotificationService.ShowError( + Strings.VersionControl_ErrorTitle, + string.Format( + Strings.VersionControl_RecoveryFailed, + GetErrorText(ex), + GetErrorText(recoveryFailure)))); + return false; + } + + if (ex is OperationCanceledException + && cancellationToken.IsCancellationRequested) + { + throw; + } + + _logger.LogError(ex, "Failed to restore project version {Commit}.", sha); + PublishNotification(() => + NotificationService.ShowError( + Strings.VersionControl_ErrorTitle, + ex is GitOperationException { Stderr.Length: > 0 } gitException + ? gitException.Stderr + : ex.Message)); + return false; + } + finally + { + FinishInternalTransition(); + } + }, + cancellationToken); + } + finally + { + FinishLifecycleOperation(gateEntered); + } + } + + private async Task TryRestoreOriginalStateAsync( + IProjectVersionControlTransaction service, + CheckedOutBranchTip originalTip, + CheckedOutBranchTip expectedResultTip, + RecoveryKind recoveryKind, + ProjectService.ProjectTransitionScope transition, + string projectFile) + { + try + { + CheckedOutBranchTip actualTip = await service.GetCheckedOutBranchTipAsync( + CancellationToken.None); + if (!BranchTipsEqual(actualTip, expectedResultTip)) + { + throw new InvalidOperationException( + "The checked-out branch changed before the operation could be recovered."); + } + + if (recoveryKind == RecoveryKind.Branch) + { + if (!BranchTipsEqual(actualTip, originalTip)) + { + await service.SwitchBranchAsync( + GetLocalBranchName(originalTip.RefName), + CancellationToken.None); + CheckedOutBranchTip restoredTip = await service.GetCheckedOutBranchTipAsync( + CancellationToken.None); + if (!BranchTipsEqual(restoredTip, originalTip)) + { + throw new InvalidOperationException( + "The original branch ref changed while the branch operation was being recovered."); + } + } + } + else + { + if (!string.Equals( + actualTip.RefName, + originalTip.RefName, + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "The restore operation is no longer on its original branch."); + } + + CommitResult recovery = await service.CommitProjectTreeAsync( + expectedResultTip, + originalTip.Commit, + RestoreRecoveryMessage, + SnapshotKind.Recovery, + CancellationToken.None); + EnsureAutomaticSnapshotWasNotSkipped(recovery); + CheckedOutBranchTip expectedRecoveryTip = GetExpectedTipAfterCommit( + expectedResultTip, + recovery); + CheckedOutBranchTip verifiedRecoveryTip = await service.GetCheckedOutBranchTipAsync( + CancellationToken.None); + if (!BranchTipsEqual(verifiedRecoveryTip, expectedRecoveryTip)) + { + throw new InvalidOperationException( + "The branch ref changed while the restore operation was being recovered."); + } + } + } + catch (Exception recoveryException) + { + return recoveryException; + } + + try + { + await ReopenProjectAsync(transition, projectFile); + return null; + } + catch (Exception reopenException) + { + return reopenException; + } + } + + private static string GetLocalBranchName(string refName) + { + const string Prefix = "refs/heads/"; + if (!refName.StartsWith(Prefix, StringComparison.Ordinal) + || refName.Length == Prefix.Length) + { + throw new ArgumentException("A local branch ref is required.", nameof(refName)); + } + + return refName[Prefix.Length..]; + } + + private void EnsureProjectReopened(string projectFile) + { + string? reopenedPath = _projectService.CurrentProject.Value?.Uri?.LocalPath; + if (!string.Equals( + Path.GetFullPath(reopenedPath ?? string.Empty), + Path.GetFullPath(projectFile), + PathComparison)) + { + throw new InvalidOperationException( + "The project could not be reopened after restoring files."); + } + } + + private IProjectVersionControlBackend GetTrackedBackend() + { + ObjectDisposedException.ThrowIf(_disposed, this); + IProjectVersionControlBackend service = GetOperationReadyBackend() + ?? throw new InvalidOperationException( + "Version control is not available."); + if (service.Repository is null) + { + throw new InvalidOperationException( + "The open project is not tracked with Git."); + } + + return service; + } + + private IProjectVersionControlBackend? GetOperationReadyBackend() + { + lock (_stateGate) + { + return ReferenceEquals(_state.OwnedService, _state.VisibleService) + ? _state.OwnedService + : null; + } + } + + private IProjectVersionControlBackend? GetOwnedBackend() + { + lock (_stateGate) + { + return _state.OwnedService; + } + } + + private bool IsInternalVersionControlTransition() + { + return _projectService.CurrentTransition is + { + Purpose: ProjectTransitionPurpose.VersionControlMutation, + Owner: var owner, + } + && ReferenceEquals(owner, this); + } + + private Project GetOpenProject() + { + return _projectService.CurrentProject.Value + ?? throw new InvalidOperationException("No project is open."); + } + + private static string GetProjectFile(Project project) + { + return project.Uri?.LocalPath + ?? throw new InvalidOperationException("The project has no file path."); + } + + private IDisposable? TryBeginWorktreeMutation( + IProjectFileWriteLease? completedWrite = null) + { + IDisposable? mutation = _editorService.TryBeginWorktreeMutation(completedWrite); + if (mutation is not null) + { + return mutation; + } + + PublishNotification(() => + NotificationService.ShowWarning( + Strings.VersionControl, + Strings.VersionControl_WorkspaceBusy)); + return null; + } + + private async Task TrySaveOpenProjectAsync( + Project project, + CancellationToken cancellationToken) + { + try + { + return await _editorService.SaveProjectFilesAsync(project, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + // OnSave runs third-party editor code and real file I/O, and this runs outside the + // cycle's own error handling, so a throw here must not escape as a raw message. + _logger.LogError( + ex, + "Failed to save the open project before changing version-controlled files."); + return false; + } + } + + // Returns null when the repository has no commit identity, which is a supported degraded mode: + // the caller must abandon the operation rather than proceed without the safety snapshot. + private async Task CommitSafetySnapshotAsync( + IProjectVersionControlTransaction service, + string message, + CancellationToken cancellationToken) + { + CommitResult result = await service.CommitAllAsync( + message, + SnapshotKind.Safety, + cancellationToken); + if (result is CommitResult.SkippedNoIdentity) + { + PublishNotification(() => + NotificationService.ShowWarning( + Strings.VersionControl, + Strings.VersionControl_MissingIdentityNotice)); + return null; + } + + return result; + } + + private bool EnsureRepositoryIsNotConflicted(WorkspaceStatus status) + { + if (!status.HasConflicts) + { + return true; + } + + PublishNotification(() => + NotificationService.ShowWarning( + Strings.VersionControl, + Strings.VersionControl_ConflictGuidance)); + return false; + } + + private async Task CloseProjectForOperationAsync( + ProjectService.ProjectTransitionScope transition, + CancellationToken cancellationToken) + { + await transition.CloseProjectAsync(cancellationToken); + + if (_projectService.CurrentProject.Value is not null) + { + throw new InvalidOperationException( + "The project could not be closed before changing version-controlled files."); + } + } + + private async Task ReopenProjectAsync( + ProjectService.ProjectTransitionScope transition, + string projectFile) + { + RepositoryInfo repository = GetOwnedBackend()?.Repository + ?? throw new InvalidOperationException( + "The repository is unavailable before reopening the project."); + EnsureProjectFileIsPhysicallyContained(repository, projectFile); + await transition.OpenProjectAsync(projectFile); + EnsureProjectReopened(projectFile); + } + + private static void EnsureProjectFileIsPhysicallyContained( + RepositoryInfo repository, + string projectFile) + { + EnsureProjectFileIsPhysicallyContained(repository.ProjectRoot, projectFile); + } + + private static void EnsureProjectFileIsPhysicallyContained( + string projectRoot, + string projectFile) + { + if (!IsProjectFileContained(projectRoot, projectFile)) + { + throw new InvalidOperationException( + $"The project file '{projectFile}' resolves outside the version-controlled project root."); + } + } + + private static bool IsProjectFileContained(string projectRoot, string projectFile) + { + try + { + return RepositoryPathComparer.IsContainedWithin(projectRoot, projectFile); + } + catch (Exception ex) + when (ex is IOException + or UnauthorizedAccessException + or NotSupportedException + or ArgumentException) + { + // Only when the path cannot be canonicalized at all - a symbolic-link cycle, or a + // component that cannot be read. A resolvable path that lands outside has already + // returned false above, so this fallback cannot turn an escape into containment. + } + + string fullPath = Path.GetFullPath(projectFile); + string? ancestor = Path.GetDirectoryName(fullPath); + while (ancestor is not null) + { + try + { + if (RepositoryPathComparer.AreEquivalent(ancestor, projectRoot)) + { + string relative = Path.GetRelativePath(ancestor, fullPath); + return relative != ".." + && !relative.StartsWith( + $"..{Path.DirectorySeparatorChar}", + StringComparison.Ordinal) + && !Path.IsPathRooted(relative); + } + } + catch (Exception ex) + when (ex is IOException + or UnauthorizedAccessException + or NotSupportedException + or ArgumentException) + { + // An unresolvable child must not stop the walk from reaching a resolvable ancestor. + } + + ancestor = Path.GetDirectoryName(ancestor); + } + + return false; + } + + private bool HandleCycleFailure( + Exception exception, + Exception? recoveryFailure, + string operation, + CancellationToken cancellationToken) + { + if (recoveryFailure is not null) + { + var combined = new AggregateException( + "The version-control operation and recovery both failed.", + exception, + recoveryFailure); + _logger.LogError( + combined, + "Failed to complete version-control operation {Operation}, and the original state could not be recovered.", + operation); + PublishNotification(() => + NotificationService.ShowError( + Strings.VersionControl_ErrorTitle, + string.Format( + Strings.VersionControl_RecoveryFailed, + GetErrorText(exception), + GetErrorText(recoveryFailure)))); + return false; + } + + if (exception is OperationCanceledException && cancellationToken.IsCancellationRequested) + { + cancellationToken.ThrowIfCancellationRequested(); + } + + _logger.LogError( + exception, + "Failed to complete version-control operation {Operation}.", + operation); + PublishNotification(() => + NotificationService.ShowError( + Strings.VersionControl_ErrorTitle, + GetErrorText(exception))); + return false; + } + + private void PublishNotification(Action notification) + { + lock (_stateGate) + { + if (_disposed && _lifecycleUsers == 0) + { + return; + } + + if (!_dispatcher.CheckAccess()) + { + _notificationUsers++; + _ = PublishNotificationAsync(notification); + return; + } + } + + TryPublishNotification(notification); + } + + private async Task PublishNotificationAsync(Action notification) + { + try + { + await _dispatcher.InvokeAsync(() => TryPublishNotification(notification)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to dispatch a version-control notification."); + } + finally + { + TaskCompletionSource? quiesced = null; + lock (_stateGate) + { + _notificationUsers--; + if (_notificationUsers == 0 && _disposed) + { + quiesced = _notificationsQuiesced; + } + } + + quiesced?.TrySetResult(); + } + } + + private void TryPublishNotification(Action notification) + { + if (_dispatcher.CheckAccess()) + { + try + { + notification(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to publish a version-control notification."); + } + } + else + { + throw new InvalidOperationException( + "Version-control notifications must be published on the captured dispatcher."); + } + } + + private void FinishInternalTransition() + { + if (_projectService.CurrentProject.Value is null) + { + ClearProjectState(); + } + } + + private async Task BeginLifecycleOperationAsync(CancellationToken cancellationToken) + { + while (true) + { + Task? configurationActivation; + lock (_stateGate) + { + ThrowIfLifecycleOperationUnavailableLocked(); + if (_configurationActivationActive) + { + configurationActivation = + (_configurationActivationQuiesced ??= CreateCompletionSource()).Task; + } + else + { + _lifecycleUsers++; + return; + } + } + + await configurationActivation.WaitAsync(cancellationToken).ConfigureAwait(false); + } + } + + private void ThrowIfLifecycleOperationUnavailable() + { + lock (_stateGate) + { + ThrowIfLifecycleOperationUnavailableLocked(); + } + } + + private void ThrowIfLifecycleOperationUnavailableLocked() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_operationCloseBarrierActive) + { + throw new InvalidOperationException( + "Lifecycle version-control operations cannot run while the project is closing."); + } + } + + private CancellationTokenSource CreateProjectServiceEpochCancellation( + CancellationToken cancellationToken) + { + lock (_stateGate) + { + ThrowIfLifecycleOperationUnavailableLocked(); + CancellationToken projectServiceEpoch = + (_projectServiceEpochCancellation + ?? throw new ObjectDisposedException(nameof(VersionControlCoordinator))) + .Token; + return CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + _lifetimeCancellation.Token, + projectServiceEpoch); + } + } + + private void AdvanceProjectServiceEpoch() + { + CancellationTokenSource? previous; + lock (_stateGate) + { + if (_disposed) + { + return; + } + + previous = _projectServiceEpochCancellation; + _projectServiceEpochCancellation = new CancellationTokenSource(); + } + + CancelProjectServiceEpoch(previous); + } + + private void CancelProjectServiceEpoch(CancellationTokenSource? cancellation) + { + if (cancellation is null) + { + return; + } + + try + { + cancellation.Cancel(); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "A project/service epoch cancellation callback failed."); + } + finally + { + cancellation.Dispose(); + } + } + + private async ValueTask BeginNonTransactionalOperationAsync( + CancellationToken cancellationToken) + { + while (true) + { + Task? configurationActivation; + CancellationToken operationEpochCancellation = default; + lock (_stateGate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_operationCloseBarrierActive) + { + throw new InvalidOperationException( + "Version-control operations cannot start while the project is closing."); + } + + if (_configurationActivationActive) + { + configurationActivation = + (_configurationActivationQuiesced ??= CreateCompletionSource()).Task; + } + else + { + configurationActivation = null; + operationEpochCancellation = (_operationEpochCancellation + ?? throw new ObjectDisposedException( + nameof(VersionControlCoordinator))) + .Token; + _operationUsers++; + } + } + + if (configurationActivation is not null) + { + await configurationActivation.WaitAsync(cancellationToken).ConfigureAwait(false); + continue; + } + + try + { + return new NonTransactionalOperationLease( + this, + CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + _lifetimeCancellation.Token, + operationEpochCancellation)); + } + catch + { + FinishNonTransactionalOperation(); + throw; + } + } + } + + private NonTransactionalOperationLease? TryBeginNonTransactionalOperation( + CancellationToken cancellationToken) + { + CancellationToken operationEpochCancellation; + lock (_stateGate) + { + if (_disposed || _operationCloseBarrierActive || _configurationActivationActive) + { + return null; + } + + operationEpochCancellation = (_operationEpochCancellation + ?? throw new ObjectDisposedException(nameof(VersionControlCoordinator))) + .Token; + _operationUsers++; + } + + try + { + return new NonTransactionalOperationLease( + this, + CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + _lifetimeCancellation.Token, + operationEpochCancellation)); + } + catch + { + FinishNonTransactionalOperation(); + throw; + } + } + + private void FinishNonTransactionalOperation() + { + TaskCompletionSource? quiesced = null; + bool clearProjectState = false; + lock (_stateGate) + { + _operationUsers--; + if (_operationUsers == 0) + { + quiesced = _operationsQuiesced; + _operationsQuiesced = null; + clearProjectState = _disposed + && _closeBarrierUsers == 0 + && _lifecycleUsers == 0; + } + } + + try + { + if (clearProjectState) + { + ClearProjectState(); + } + } + finally + { + quiesced?.TrySetResult(); + TryStartPendingConfigurationActivation(); + } + } + + private async Task + TryBeginNonTransactionalCloseBarrierAsync(CancellationToken cancellationToken) + { + lock (_stateGate) + { + if (_disposed) + { + return null; + } + + _closeBarrierUsers++; + } + + CancellationTokenSource? closeCancellation = null; + CancellationTokenSource? operationEpochCancellation = null; + bool gateEntered = false; + bool barrierEntered = false; + try + { + closeCancellation = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + _lifetimeCancellation.Token); + await _operationCloseGate.WaitAsync(closeCancellation.Token).ConfigureAwait(false); + gateEntered = true; + + Task operationsQuiesced; + bool disposed; + lock (_stateGate) + { + disposed = _disposed; + if (!disposed) + { + _operationCloseBarrierActive = true; + operationEpochCancellation = _operationEpochCancellation + ?? new CancellationTokenSource(); + _operationEpochCancellation = operationEpochCancellation; + operationsQuiesced = _operationUsers == 0 + ? Task.CompletedTask + : (_operationsQuiesced ??= CreateCompletionSource()).Task; + barrierEntered = true; + } + else + { + operationsQuiesced = Task.CompletedTask; + } + } + + if (disposed) + { + closeCancellation.Dispose(); + FinishNonTransactionalCloseBarrierWaiter(gateEntered); + return null; + } + + Exception? cancellationFailure = null; + try + { + operationEpochCancellation!.Cancel(); + } + catch (Exception ex) + { + cancellationFailure = ex; + } + + await operationsQuiesced.ConfigureAwait(false); + if (cancellationFailure is not null) + { + _logger.LogError( + cancellationFailure, + "An operation cancellation callback failed while closing the project."); + } + + closeCancellation.Token.ThrowIfCancellationRequested(); + return new NonTransactionalCloseBarrier( + this, + closeCancellation, + operationEpochCancellation!); + } + catch + { + closeCancellation?.Dispose(); + if (barrierEntered) + { + FinishNonTransactionalCloseBarrier( + operationEpochCancellation!); + TryStartPendingConfigurationActivation(); + } + else + { + FinishNonTransactionalCloseBarrierWaiter(gateEntered); + } + + throw; + } + } + + private void FinishNonTransactionalCloseBarrier( + CancellationTokenSource operationEpochCancellation) + { + TaskCompletionSource? quiesced = null; + bool clearProjectState = false; + lock (_stateGate) + { + if (ReferenceEquals(_operationEpochCancellation, operationEpochCancellation)) + { + _operationEpochCancellation = _disposed + ? null + : new CancellationTokenSource(); + } + + _operationCloseBarrierActive = false; + _closeBarrierUsers--; + if (_closeBarrierUsers == 0) + { + quiesced = _closeBarriersQuiesced; + _closeBarriersQuiesced = null; + clearProjectState = _disposed + && _lifecycleUsers == 0 + && _operationUsers == 0; + } + } + + try + { + operationEpochCancellation.Dispose(); + } + finally + { + _operationCloseGate.Release(); + try + { + if (clearProjectState) + { + ClearProjectState(); + } + } + finally + { + quiesced?.TrySetResult(); + } + } + } + + private Task CompleteNonTransactionalCloseBarrierAsync( + CancellationTokenSource operationEpochCancellation, + bool projectClosed) + { + if (projectClosed) + { + lock (_stateGate) + { + _pendingConfigurationActivation = null; + } + } + + FinishNonTransactionalCloseBarrier(operationEpochCancellation); + TryStartPendingConfigurationActivation(); + return Task.CompletedTask; + } + + private void FinishNonTransactionalCloseBarrierWaiter(bool gateEntered) + { + TaskCompletionSource? quiesced = null; + bool clearProjectState = false; + lock (_stateGate) + { + _closeBarrierUsers--; + if (_closeBarrierUsers == 0) + { + quiesced = _closeBarriersQuiesced; + _closeBarriersQuiesced = null; + clearProjectState = _disposed + && _lifecycleUsers == 0 + && _operationUsers == 0; + } + } + + if (gateEntered) + { + _operationCloseGate.Release(); + } + + try + { + if (clearProjectState) + { + ClearProjectState(); + } + } + finally + { + quiesced?.TrySetResult(); + TryStartPendingConfigurationActivation(); + } + } + + private void FinishLifecycleOperation(bool gateEntered) + { + if (gateEntered) + { + _lifecycleGate.Release(); + } + + TaskCompletionSource? quiesced = null; + bool clearProjectState = false; + lock (_stateGate) + { + _lifecycleUsers--; + if (_lifecycleUsers == 0 && _disposed) + { + clearProjectState = _closeBarrierUsers == 0 && _operationUsers == 0; + quiesced = _lifecycleQuiesced; + } + } + + try + { + if (clearProjectState) + { + ClearProjectState(); + } + } + finally + { + quiesced?.TrySetResult(); + TryStartPendingConfigurationActivation(); + } + } + + private static void EnsureAutomaticSnapshotWasNotSkipped(CommitResult result) + { + if (result is CommitResult.SkippedNoIdentity) + { + throw new GitIdentityRequiredException(); + } + } + + private static string GetShortSha(string sha) + { + return sha[..Math.Min(7, sha.Length)]; + } + + private static string GetErrorText(Exception exception) + { + return exception is GitOperationException { Stderr.Length: > 0 } gitException + ? gitException.Stderr + : exception.Message; + } + + private Task ShowRestoreConfirmationAsync( + CancellationToken cancellationToken) + { + return ShowConfirmationAsync( + Strings.VersionControl_Restore, + Strings.VersionControl_RestoreConfirmation, + cancellationToken); + } + + private Task ShowSwitchBranchConfirmationAsync( + string branchName, + CancellationToken cancellationToken) + { + return ShowConfirmationAsync( + Strings.VersionControl_SwitchBranch, + CreateSwitchBranchConfirmation( + branchName, + CurrentService?.Repository?.IsNestedInForeignRepo == true), + cancellationToken); + } + + // A branch switch is repository-wide by design, so a project sharing someone else's repository + // has to be told that the decision reaches past its own directory before it is taken. + internal static string CreateSwitchBranchConfirmation( + string branchName, + bool isNestedInForeignRepo) + { + string confirmation = string.Format( + CultureInfo.CurrentCulture, + Strings.VersionControl_SwitchBranchConfirmation, + branchName); + return isNestedInForeignRepo + ? $"{confirmation}\n\n{Strings.VersionControl_SwitchBranchEnclosingRepositoryNotice}" + : confirmation; + } + + private Task ShowPullConfirmationAsync( + CancellationToken cancellationToken) + { + return ShowConfirmationAsync( + Strings.VersionControl_Pull, + Strings.VersionControl_PullConfirmation, + cancellationToken); + } + + private Task ShowPendingPullRecoveryConfirmationAsync( + ProjectRecoveryInfo recovery, + CancellationToken cancellationToken) + { + return ShowConfirmationAsync( + Strings.VersionControl, + string.Format( + Strings.VersionControl_PendingPullRecoveryConfirmation, + recovery.ProjectFileName, + recovery.CreatedAt.ToLocalTime()), + cancellationToken); + } + + private Task ShowAdoptExistingRepositoryConfirmationAsync( + RepositoryInfo repository, + CancellationToken cancellationToken) + { + return ShowConfirmationAsync( + Strings.VersionControl, + $"{Strings.VersionControl_AdoptExistingRepository}\n\n{repository.RepoRoot}", + cancellationToken); + } + + private Task ShowEnclosingRepositoryConfirmationAsync( + RepositoryInfo repository, + CancellationToken cancellationToken) + { + return ShowConfirmationAsync( + Strings.VersionControl, + $"{Strings.VersionControl_EnclosingRepositoryFound}\n\n{repository.RepoRoot}", + cancellationToken); + } + + private Task ShowUntrackReservedPathsConfirmationAsync( + IReadOnlyList reservedPaths, + CancellationToken cancellationToken) + { + return ShowConfirmationAsync( + Strings.VersionControl, + $"{Strings.VersionControl_UntrackReservedPathsConfirmation}\n\n{string.Join('\n', reservedPaths)}", + cancellationToken); + } + + private Task ShowStaleLockConfirmationAsync( + RepositoryLockInfo lockInfo, + CancellationToken cancellationToken) + { + return ShowConfirmationAsync( + Strings.VersionControl, + $"{Strings.VersionControl_StaleLockConfirmation}\n\n{lockInfo.LockPath}", + cancellationToken); + } + + private async Task PrepareProjectOpeningAsync( + ProjectService.ProjectOpenAttempt attempt, + CancellationToken cancellationToken) + { + lock (_stateGate) + { + if (_pendingOpeningRepositoryDecision is { } pending + && !ReferenceEquals(pending.Attempt, attempt)) + { + _pendingOpeningRepositoryDecision = null; + } + } + + using NonTransactionalOperationLease? operation = + TryBeginNonTransactionalOperation(cancellationToken); + if (operation is null) + { + return new AbortProjectOpenPreparation(); + } + + OpeningRepositoryInspection? inspection = null; + try + { + inspection = await DiscoverPendingPullRecoveryForOpeningAsync( + attempt.ProjectFile, + operation.CancellationToken) + .ConfigureAwait(false); + if (inspection is null + || !inspection.Repository.IsNestedInForeignRepo + && inspection.Recovery is null) + { + return null; + } + + PendingPullRecoveryOpenSelection? selection = inspection.Recovery; + bool accepted = selection is null + || selection.AlreadyApplied + || await ConfirmPendingPullRecoveryAsync( + ToRecoveryInfo(selection.Recovery), + operation.CancellationToken); + return new VersionControlProjectOpenPreparation( + this, + attempt, + inspection with + { + Recovery = selection is null + ? null + : selection with { Accepted = accepted }, + }); + } + catch (OperationCanceledException) when (operation.CancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Failed to inspect pending pull recovery before opening {ProjectFile}.", + attempt.ProjectFile); + return new AbortProjectOpenPreparation(); + } + } + + private async Task InspectProjectOpeningAsync(string projectFile) + { + using NonTransactionalOperationLease? operation = + TryBeginNonTransactionalOperation(CancellationToken.None); + if (operation is null) + { + return; + } + + CancellationToken cancellationToken = operation.CancellationToken; + string? markerFile = await ProjectConflictMarkerScanner.FindFirstAsync( + projectFile, + cancellationToken); + if (markerFile is not null) + { + cancellationToken.ThrowIfCancellationRequested(); + await WarnConflictMarkersAsync(markerFile); + cancellationToken.ThrowIfCancellationRequested(); + } + } + + private async Task TryRecoverPendingPullBeforeOpeningAsync( + string projectFile, + CancellationToken cancellationToken, + string? requiredRecoveryId = null) + { + PendingPullRecoveryOpenSelection? selection = null; + try + { + OpeningRepositoryInspection? inspection = + await DiscoverPendingPullRecoveryForOpeningAsync( + projectFile, + cancellationToken, + requiredRecoveryId) + .ConfigureAwait(false); + selection = inspection?.Recovery; + if (selection is null) + { + return false; + } + + bool accepted = selection.AlreadyApplied + || await ConfirmPendingPullRecoveryAsync( + ToRecoveryInfo(selection.Recovery), + cancellationToken); + selection = selection with { Accepted = accepted }; + if (!accepted) + { + IsPendingRecoveryPathSafeForOpen(selection); + return false; + } + + ProjectOpenPreparationResult result = + await ApplyPendingPullRecoveryBeforeOpeningAsync( + selection, + cancellationToken) + .ConfigureAwait(false); + return result == ProjectOpenPreparationResult.Proceed; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Failed to inspect pending pull recovery before opening {ProjectFile}.", + projectFile); + if (selection is not null) + { + IsPendingRecoveryPathSafeForOpen(selection); + } + + return false; + } + } + + private async Task + DiscoverPendingPullRecoveryForOpeningAsync( + string projectFile, + CancellationToken cancellationToken, + string? requiredRecoveryId = null) + { + string canonicalProjectFile = GetOpeningRecoveryKey(projectFile); + PendingOpeningPullRecovery? cleanupCandidate; + lock (_stateGate) + { + _openingPullRecoveries.TryGetValue( + canonicalProjectFile, + out cleanupCandidate); + } + + IProjectVersionControlBackend? discoveryService = null; + IProjectVersionControlBackend? trackedService = null; + try + { + discoveryService = CreateTemporaryBackend(repository: null, projectFile); + GitAvailability availability = await discoveryService.GetAvailabilityAsync(cancellationToken) + .ConfigureAwait(false); + if (availability.State != GitAvailabilityState.Installed) + { + return null; + } + + string projectRoot = Path.GetDirectoryName(projectFile) + ?? throw new InvalidOperationException( + "The project file has no parent directory."); + RepositoryInfo? repository = await discoveryService.DiscoverRepositoryAsync( + projectRoot, + cancellationToken) + .ConfigureAwait(false); + if (repository is null) + { + return null; + } + + bool enclosingRepositoryAccepted = !repository.IsNestedInForeignRepo + || await ConfirmUseEnclosingRepositoryIfNeededAsync( + discoveryService, + repository, + cancellationToken); + if (!enclosingRepositoryAccepted) + { + return new OpeningRepositoryInspection( + repository, + projectFile, + EnclosingRepositoryAccepted: false, + Recovery: null); + } + + trackedService = CreateTemporaryBackend(repository, projectFile); + IReadOnlyList recoveries = + await trackedService.ExecuteExclusiveAsync( + transaction => transaction.GetPendingPullRecoveriesAsync(cancellationToken), + cancellationToken) + .ConfigureAwait(false); + PendingPullRecovery? recovery = recoveries + .Where(candidate => RecoveryProjectPathsEqual( + repository, + candidate.ProjectFile, + projectFile) + && (requiredRecoveryId is null + || string.Equals( + candidate.Id, + requiredRecoveryId, + StringComparison.Ordinal))) + .OrderBy(static candidate => candidate.CreatedAt) + .ThenBy(static candidate => candidate.Id, StringComparer.Ordinal) + .FirstOrDefault(); + if (recovery is null) + { + if (requiredRecoveryId is null && cleanupCandidate is not null) + { + lock (_stateGate) + { + if (_openingPullRecoveries.TryGetValue( + canonicalProjectFile, + out PendingOpeningPullRecovery? current) + && ReferenceEquals(current, cleanupCandidate)) + { + _openingPullRecoveries.Remove(canonicalProjectFile); + } + } + } + + return new OpeningRepositoryInspection( + repository, + projectFile, + EnclosingRepositoryAccepted: true, + Recovery: null); + } + + PendingOpeningPullRecovery? appliedMarker = null; + lock (_stateGate) + { + if (_openingPullRecoveries.TryGetValue( + canonicalProjectFile, + out PendingOpeningPullRecovery? liveMarker) + && liveMarker is not null + && RepositoriesEqual(liveMarker.Repository, repository) + && PendingPullRecoveriesMatch( + liveMarker.Recovery, + recovery, + repository)) + { + appliedMarker = liveMarker; + } + } + + return new OpeningRepositoryInspection( + repository, + projectFile, + EnclosingRepositoryAccepted: true, + Recovery: new PendingPullRecoveryOpenSelection( + repository, + recovery, + projectFile, + Accepted: false, + AppliedMarker: appliedMarker)); + } + finally + { + if (!ReferenceEquals(trackedService, discoveryService)) + { + DisposeService(trackedService); + } + + DisposeService(discoveryService); + } + } + + private async Task ApplyProjectOpeningPreparationAsync( + ProjectService.ProjectOpenAttempt attempt, + OpeningRepositoryInspection inspection, + ProjectTransitionContext transition, + CancellationToken cancellationToken) + { + try + { + if (transition.Purpose != ProjectTransitionPurpose.Normal + || !ReferenceEquals(transition.Owner, attempt) + || !PathsEqual(attempt.ProjectFile, inspection.ProjectFile)) + { + return ProjectOpenPreparationResult.Abort; + } + + if (inspection.Recovery is { } recovery) + { + ProjectOpenPreparationResult result = + await ApplyPendingPullRecoveryBeforeOpeningAsync( + recovery, + cancellationToken) + .ConfigureAwait(false); + if (result == ProjectOpenPreparationResult.Abort) + { + return result; + } + } + else + { + RepositoryInfo? current = await RevalidateOpeningRepositoryAsync( + inspection.ProjectFile, + cancellationToken) + .ConfigureAwait(false); + if (current is null || !RepositoriesEqual(current, inspection.Repository)) + { + return ProjectOpenPreparationResult.Proceed; + } + } + + if (!inspection.Repository.IsNestedInForeignRepo) + { + return ProjectOpenPreparationResult.Proceed; + } + + return TryRecordOpeningRepositoryDecision( + attempt, + transition, + inspection) + ? ProjectOpenPreparationResult.Proceed + : ProjectOpenPreparationResult.Abort; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return ProjectOpenPreparationResult.Abort; + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Failed to revalidate enclosing-repository consent for project open {ProjectFile}.", + inspection.ProjectFile); + return ProjectOpenPreparationResult.Abort; + } + } + + private async Task RevalidateOpeningRepositoryAsync( + string projectFile, + CancellationToken cancellationToken) + { + using NonTransactionalOperationLease? operation = + TryBeginNonTransactionalOperation(cancellationToken); + if (operation is null) + { + return null; + } + + IProjectVersionControlBackend? discoveryService = null; + try + { + discoveryService = CreateTemporaryBackend(repository: null, projectFile); + GitAvailability availability = await discoveryService.GetAvailabilityAsync( + operation.CancellationToken) + .ConfigureAwait(false); + if (availability.State != GitAvailabilityState.Installed) + { + return null; + } + + string projectRoot = Path.GetDirectoryName(projectFile) + ?? throw new InvalidOperationException( + "The project file has no parent directory."); + return await discoveryService.DiscoverRepositoryAsync( + projectRoot, + operation.CancellationToken) + .ConfigureAwait(false); + } + finally + { + DisposeService(discoveryService); + } + } + + private bool TryRecordOpeningRepositoryDecision( + ProjectService.ProjectOpenAttempt attempt, + ProjectTransitionContext transition, + OpeningRepositoryInspection inspection) + { + if (transition.Purpose != ProjectTransitionPurpose.Normal + || !ReferenceEquals(transition.Owner, attempt) + || !PathsEqual(attempt.ProjectFile, inspection.ProjectFile) + || !RepositoryPathComparer.AreEquivalent( + inspection.Repository.ProjectRoot, + Path.GetDirectoryName(inspection.ProjectFile) + ?? throw new InvalidOperationException( + "The project file has no parent directory."))) + { + return false; + } + + lock (_stateGate) + { + if (_disposed) + { + return false; + } + + _pendingOpeningRepositoryDecision = new PendingOpeningRepositoryDecision( + attempt, + attempt.Id, + transition.Id, + GetOpeningRecoveryKey(inspection.ProjectFile), + inspection.Repository, + inspection.EnclosingRepositoryAccepted); + return true; + } + } + + private async Task + ApplyPendingPullRecoveryBeforeOpeningAsync( + PendingPullRecoveryOpenSelection selection, + CancellationToken cancellationToken) + { + using NonTransactionalOperationLease? operation = + TryBeginNonTransactionalOperation(cancellationToken); + if (operation is null) + { + return ProjectOpenPreparationResult.Abort; + } + + using IDisposable? worktreeMutation = TryBeginWorktreeMutation(); + if (worktreeMutation is null) + { + return ProjectOpenPreparationResult.Abort; + } + + IProjectVersionControlBackend? discoveryService = null; + IProjectVersionControlBackend? trackedService = null; + try + { + CancellationToken operationCancellation = operation.CancellationToken; + string canonicalProjectFile = GetOpeningRecoveryKey(selection.ProjectFile); + discoveryService = CreateTemporaryBackend(repository: null, selection.ProjectFile); + GitAvailability availability = await discoveryService.GetAvailabilityAsync( + operationCancellation) + .ConfigureAwait(false); + if (availability.State != GitAvailabilityState.Installed) + { + return ProjectOpenPreparationResult.Abort; + } + + string projectRoot = Path.GetDirectoryName(selection.ProjectFile) + ?? throw new InvalidOperationException( + "The project file has no parent directory."); + RepositoryInfo? repository = await discoveryService.DiscoverRepositoryAsync( + projectRoot, + operationCancellation) + .ConfigureAwait(false); + if (repository is null || !RepositoriesEqual(repository, selection.Repository)) + { + return ProjectOpenPreparationResult.Abort; + } + + trackedService = CreateTemporaryBackend(repository, selection.ProjectFile); + PendingPullRecoveryOutcome? outcome = await trackedService.ExecuteExclusiveAsync( + async transaction => + { + PendingPullRecovery? current = + (await transaction.GetPendingPullRecoveriesAsync(operationCancellation)) + .SingleOrDefault(candidate => string.Equals( + candidate.Id, + selection.Recovery.Id, + StringComparison.Ordinal)); + if (current is null + || !PendingPullRecoveriesMatch( + selection.Recovery, + current, + repository) + || !RecoveryProjectPathsEqual( + repository, + current.ProjectFile, + selection.ProjectFile)) + { + throw new PendingPullRecoveryChangedException( + selection.Recovery.DescriptorRef); + } + + if (selection.AlreadyApplied) + { + bool markerMatches; + lock (_stateGate) + { + markerMatches = _openingPullRecoveries.TryGetValue( + canonicalProjectFile, + out PendingOpeningPullRecovery? liveMarker) + && liveMarker is not null + && ReferenceEquals( + liveMarker, + selection.AppliedMarker) + && liveMarker.Repository.Equals(repository) + && PendingPullRecoveriesMatch( + liveMarker.Recovery, + selection.Recovery, + repository); + } + + if (!markerMatches) + { + throw new PendingPullRecoveryChangedException( + selection.Recovery.DescriptorRef); + } + } + + if (!selection.Accepted || selection.AlreadyApplied) + { + return (PendingPullRecoveryOutcome?)null; + } + + return (PendingPullRecoveryOutcome?) + await transaction.RecoverPendingPullRecoveryAsync( + current, + CancellationToken.None); + }, + operationCancellation) + .ConfigureAwait(false); + if (!IsPendingRecoveryPathSafeForOpen(selection)) + { + return ProjectOpenPreparationResult.Abort; + } + + if (outcome is null) + { + return ProjectOpenPreparationResult.Proceed; + } + + lock (_stateGate) + { + _openingPullRecoveries[canonicalProjectFile] = + new PendingOpeningPullRecovery(repository, selection.Recovery); + } + + PublishRecoveryOutcomeNotification(selection.Recovery, outcome.Value); + return ProjectOpenPreparationResult.Proceed; + } + catch (OperationCanceledException) when (operation.CancellationToken.IsCancellationRequested) + { + return ProjectOpenPreparationResult.Abort; + } + catch (PendingPullRecoveryPreservedException ex) + { + PublishPreservedRecoveryBranchNotification(ex.RecoveryReference); + IsPendingRecoveryPathSafeForOpen(selection); + return ProjectOpenPreparationResult.Abort; + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Failed to validate or recover a pending pull before opening {ProjectFile}; its retained-reference state could not be verified.", + selection.ProjectFile); + IsPendingRecoveryPathSafeForOpen(selection); + return ProjectOpenPreparationResult.Abort; + } + finally + { + if (!ReferenceEquals(trackedService, discoveryService)) + { + DisposeService(trackedService); + } + + DisposeService(discoveryService); + } + } + + private bool IsPendingRecoveryPathSafeForOpen( + PendingPullRecoveryOpenSelection selection) + { + try + { + EnsurePendingRecoveryPathIsSafeForOpen( + selection.Repository, + selection.Recovery, + selection.ProjectFile); + return true; + } + catch (Exception ex) + { + _logger.LogError( + ex, + "The recovered project path {ProjectFile} is not safe to open.", + selection.ProjectFile); + return false; + } + } + + private async Task CompleteOpeningPullRecoveryAfterPublishedAsync(Project project) + { + string projectFile = GetProjectFile(project); + string canonicalProjectFile = GetOpeningRecoveryKey(projectFile); + PendingOpeningPullRecovery? prepared; + lock (_stateGate) + { + _openingPullRecoveries.TryGetValue(canonicalProjectFile, out prepared); + } + + if (prepared is null) + { + return; + } + + IProjectVersionControlBackend? service = null; + try + { + service = CreateTemporaryBackend(prepared.Repository, projectFile); + await service.ExecuteExclusiveAsync( + async transaction => + { + PendingPullRecovery? current = + (await transaction.GetPendingPullRecoveriesAsync( + CancellationToken.None)) + .SingleOrDefault(candidate => string.Equals( + candidate.Id, + prepared.Recovery.Id, + StringComparison.Ordinal)); + if (current is null + || !PendingPullRecoveriesMatch( + prepared.Recovery, + current, + prepared.Repository) + || !RecoveryProjectPathsEqual( + prepared.Repository, + current.ProjectFile, + projectFile)) + { + throw new PendingPullRecoveryChangedException( + prepared.Recovery.DescriptorRef); + } + + await transaction.CompletePendingPullRecoveryAsync( + current, + CancellationToken.None); + return true; + }, + CancellationToken.None) + .ConfigureAwait(false); + lock (_stateGate) + { + if (_openingPullRecoveries.TryGetValue( + canonicalProjectFile, + out PendingOpeningPullRecovery? current) + && ReferenceEquals(current, prepared)) + { + _openingPullRecoveries.Remove(canonicalProjectFile); + } + } + + CompletePendingPullRecoveryPublication(prepared.Recovery.Id); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "The project opened after pending pull recovery, but its descriptor could not be completed."); + } + finally + { + DisposeService(service); + } + } + + private IProjectVersionControlBackend CreateTemporaryBackend( + RepositoryInfo? repository, + string projectFile) + { + return _serviceFactory?.Invoke(repository) + ?? new GitCliVersionControlService( + _installationLocator, + repository, + () => _projectService.CurrentProject.Value is not { } project + || !PathsEqual(GetProjectFile(project), projectFile), + PresentPolicyNoticeAsync, + projectFile); + } + + private async Task ShowConflictMarkerWarningAsync(string markerFile) + { + await _dispatcher.InvokeAsync( + () => NotificationService.ShowWarning( + Strings.VersionControl_ConflictMarkerWarningTitle, + string.Format( + Strings.VersionControl_ConflictMarkerWarning, + markerFile))); + } + + private async Task ShowConfirmationAsync( + string title, + string message, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + VersionControlPickerFlyout? flyout = null; + Task? confirmation = null; + await _dispatcher.InvokeAsync(() => + { + if (GetFlyoutAnchor() is not { } anchor) + { + return; + } + + flyout = new VersionControlPickerFlyout(); + confirmation = flyout.ShowConfirmationAsync(anchor, title, message); + }); + + if (flyout is null || confirmation is null) + { + return false; + } + + using CancellationTokenRegistration registration = cancellationToken.Register( + () => _dispatcher.Post(flyout.Hide)); + return await confirmation.WaitAsync(cancellationToken); + } + + private static Control? GetFlyoutAnchor() + { + if (Application.Current?.ApplicationLifetime + is not IClassicDesktopStyleApplicationLifetime + { + MainWindow: { } mainWindow, + }) + { + return null; + } + + Control? focused = mainWindow.FocusManager?.GetFocusedElement() as Control; + return focused?.IsAttachedToVisualTree() == true + ? focused + : mainWindow; + } + + private async Task ShowPolicyNoticeAsync( + VersionControlPolicyNotice notice, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + string message = notice switch + { + VersionControlPolicyNotice.LfsRemoteQuota + => Strings.VersionControl_LfsQuotaNotice, + VersionControlPolicyNotice.LargeMediaWithoutLfs largeMedia + => string.Format( + Strings.VersionControl_LargeMediaWarningFormat, + largeMedia.Path), + VersionControlPolicyNotice.MissingIdentity + => Strings.VersionControl_MissingIdentityNotice, + _ => throw new ArgumentOutOfRangeException(nameof(notice)), + }; + + await _dispatcher.InvokeAsync(() => + NotificationService.ShowWarning(Strings.VersionControl, message)); + } + + private async Task CommitSnapshotAsync( + bool enabled, + string message, + SnapshotKind kind, + IProjectFileWriteLease? completedWrite, + CancellationToken cancellationToken) + { + if (!enabled) + { + return; + } + + IProjectVersionControlBackend? service = GetOperationReadyBackend(); + if (service?.Repository is null) + { + DeferSnapshotUntilActivated(); + return; + } + + using IDisposable? snapshotMutation = TryBeginWorktreeMutation(completedWrite); + if (snapshotMutation is null) + { + _logger.LogInformation( + "Skipped the {SnapshotKind} project snapshot because the workspace is reserved.", + kind); + return; + } + + try + { + await service.CommitAllAsync(message, kind, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create the {SnapshotKind} project snapshot.", kind); + } + } + + // Repository discovery runs asynchronously after the project opens, so an explicit save can + // land before the tracked backend becomes visible. The save is the only trigger for its + // snapshot, so remember it and replay it once activation publishes the backend. + private void DeferSnapshotUntilActivated() + { + lock (_stateGate) + { + if (!_disposed + && _activation is { ProjectRoot: var activationRoot } + && _state.ProjectRoot is { } currentRoot + && string.Equals(activationRoot, currentRoot, PathComparison)) + { + _deferredSaveSnapshotRoot = currentRoot; + } + } + } + + private void StartDeferredSaveSnapshot() + { + string deferredRoot; + lock (_stateGate) + { + string? pendingRoot = _deferredSaveSnapshotRoot; + _deferredSaveSnapshotRoot = null; + if (_disposed + || pendingRoot is null + || _state.ProjectRoot is not { } currentRoot + || !string.Equals(pendingRoot, currentRoot, PathComparison)) + { + return; + } + + deferredRoot = currentRoot; + } + + // Taken synchronously so the replay is registered as an in-flight operation before the + // fire-and-forget task starts, which is what keeps disposal from racing past it. + NonTransactionalOperationLease? operation = + TryBeginNonTransactionalOperation(_lifetimeCancellation.Token); + if (operation is null) + { + return; + } + + _ = RunDeferredSaveSnapshotAsync(operation, deferredRoot); + } + + private async Task RunDeferredSaveSnapshotAsync( + NonTransactionalOperationLease operation, + string projectRoot) + { + using (operation) + { + try + { + lock (_stateGate) + { + if (_disposed + || _state.ProjectRoot is not { } currentRoot + || !string.Equals(currentRoot, projectRoot, PathComparison)) + { + return; + } + } + + await CommitSnapshotAsync( + _config.AutoCommitOnSave, + SaveSnapshotMessage, + SnapshotKind.Save, + completedWrite: null, + operation.CancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Failed to record the save snapshot deferred while version control was activating."); + } + } + } + + private PendingOpeningRepositoryDecision? TryTakeOpeningRepositoryDecision(Project project) + { + ProjectTransitionContext? transition = _projectService.CurrentTransition; + string projectFile = GetProjectFile(project); + string projectRoot = GetProjectRoot(project); + lock (_stateGate) + { + if (_pendingOpeningRepositoryDecision is not { } pending) + { + return null; + } + + bool matches = false; + try + { + matches = transition is + { + Purpose: ProjectTransitionPurpose.Normal, + Owner: ProjectService.ProjectOpenAttempt attempt, + } + && ReferenceEquals(pending.Attempt, attempt) + && pending.AttemptId == attempt.Id + && pending.TransitionId == transition.Id + && PathsEqual(pending.ProjectFile, projectFile) + && RepositoryPathComparer.AreEquivalent( + pending.Repository.ProjectRoot, + projectRoot); + } + catch (Exception ex) + when (ex is IOException + or UnauthorizedAccessException + or NotSupportedException + or ArgumentException) + { + _logger.LogWarning( + ex, + "Could not match enclosing-repository consent to the opening project {ProjectFile}.", + projectFile); + } + + _pendingOpeningRepositoryDecision = null; + return matches ? pending : null; + } + } + + internal void OnProjectChanged(Project? project) + { + bool internalTransition = IsInternalVersionControlTransition(); + PendingOpeningRepositoryDecision? openingRepositoryDecision = + project is null || internalTransition + ? null + : TryTakeOpeningRepositoryDecision(project); + CancellationTokenSource? configurationActivationCancellation; + long activationRevision; + lock (_stateGate) + { + _lastProjectNotification = project; + _hasProjectNotification = true; + _pendingConfigurationActivation = null; + if (!internalTransition) + { + _repositoryHygieneConfigurationDirty = false; + } + + configurationActivationCancellation = _configurationActivationCancellation; + if (!TryBeginActivationSetupLocked( + internalTransition, + out activationRevision)) + { + return; + } + } + + AdvanceProjectServiceEpoch(); + CancelConfigurationActivation(configurationActivationCancellation); + if (!internalTransition) + { + CancelPendingPullRecoveryOffer(); + } + StartProjectActivation( + project, + internalTransition, + activationRevision, + openingRepositoryDecision); + } + + private void ObserveCurrentProjectSnapshot() + { + bool internalTransition = IsInternalVersionControlTransition(); + CancellationTokenSource? configurationActivationCancellation; + Project? project; + long activationRevision; + lock (_stateGate) + { + project = _projectService.CurrentProject.Value; + if (_hasProjectNotification + && ReferenceEquals(_lastProjectNotification, project)) + { + return; + } + + _pendingConfigurationActivation = null; + if (!internalTransition) + { + _repositoryHygieneConfigurationDirty = false; + } + + configurationActivationCancellation = _configurationActivationCancellation; + if (!TryBeginActivationSetupLocked( + internalTransition, + out activationRevision)) + { + return; + } + } + + CancelConfigurationActivation(configurationActivationCancellation); + if (!internalTransition) + { + CancelPendingPullRecoveryOffer(); + } + StartProjectActivation( + project, + internalTransition, + activationRevision, + openingRepositoryDecision: null); + } + + private void StartProjectActivation( + Project? project, + bool internalTransition, + long activationRevision, + PendingOpeningRepositoryDecision? openingRepositoryDecision) + { + _ = StartProjectActivationAfterOpeningRecoveryAsync( + project, + internalTransition, + activationRevision, + openingRepositoryDecision); + } + + private async Task StartProjectActivationAfterOpeningRecoveryAsync( + Project? project, + bool internalTransition, + long activationRevision, + PendingOpeningRepositoryDecision? openingRepositoryDecision) + { + try + { + if (!ReferenceEquals(_projectService.CurrentProject.Value, project)) + { + return; + } + + if (project is not null) + { + await CompleteOpeningPullRecoveryAfterPublishedAsync(project).ConfigureAwait(false); + if (!ReferenceEquals(_projectService.CurrentProject.Value, project)) + { + return; + } + } + + await OnProjectChangedAsync( + project, + internalTransition, + activationRevision, + openingRepositoryDecision, + CancellationToken.None) + .ConfigureAwait(false); + } + finally + { + FinishActivationSetup(); + } + } + + private async Task StartProjectActivationAsync( + Project? project, + bool internalTransition, + CancellationToken cancellationToken) + { + if (!TryBeginActivationSetup(internalTransition, out long activationRevision)) + { + return null; + } + + try + { + return await OnProjectChangedAsync( + project, + internalTransition, + activationRevision, + openingRepositoryDecision: null, + cancellationToken: cancellationToken) + .ConfigureAwait(false); + } + finally + { + FinishActivationSetup(); + } + } + + private async Task OnProjectChangedAsync( + Project? project, + bool internalTransition, + long activationRevision, + PendingOpeningRepositoryDecision? openingRepositoryDecision, + CancellationToken cancellationToken) + { + try + { + if (internalTransition && !TryPromoteActivationRevision(activationRevision)) + { + return null; + } + + if (internalTransition) + { + if (project is null) + { + SetVisibleService(null); + return null; + } + + string preservedRoot = GetProjectRoot(project); + IProjectVersionControlBackend? preservedService = GetOwnedBackend(); + if (preservedService?.Repository is { } preservedRepository + && RepositoryPathComparer.AreEquivalent( + preservedRepository.ProjectRoot, + preservedRoot)) + { + SetVisibleService(preservedService); + QueueRepositoryHygieneConfigurationIfDirty(project); + return null; + } + } + + if (project is null) + { + ClearProjectState(activationRevision); + return null; + } + + string projectRoot = GetProjectRoot(project); + string projectFile = GetProjectFile(project); + IProjectVersionControlBackend service = _serviceFactory?.Invoke(null) + ?? new GitCliVersionControlService( + _installationLocator, + repository: null, + () => _projectService.CurrentProject.Value is null, + PresentPolicyNoticeAsync, + projectFile); + var activation = new ActivationContext( + activationRevision, + projectRoot, + projectFile, + service, + openingRepositoryDecision, + cancellationToken); + if (BeginActivation(activation, out bool cleanupRejectedService)) + { + _ = ActivateRepositoryAsync(activation); + return activation; + } + + await CompleteRejectedActivationAsync(activation, cleanupRejectedService) + .ConfigureAwait(false); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to activate version control for the open project."); + ClearProjectState(activationRevision); + return null; + } + } + + private bool TryBeginActivationSetup( + bool internalTransition, + out long activationRevision) + { + lock (_stateGate) + { + return TryBeginActivationSetupLocked( + internalTransition, + out activationRevision); + } + } + + private bool TryBeginActivationSetupLocked( + bool internalTransition, + out long activationRevision) + { + if (_disposed) + { + activationRevision = 0; + return false; + } + + _activationSetupUsers++; + activationRevision = ++_nextActivationRevision; + if (!internalTransition) + { + _latestActivationRevision = activationRevision; + } + + return true; + } + + private bool TryPromoteActivationRevision(long activationRevision) + { + lock (_stateGate) + { + if (_disposed || activationRevision < _latestActivationRevision) + { + return false; + } + + _latestActivationRevision = activationRevision; + return true; + } + } + + private void FinishActivationSetup() + { + TaskCompletionSource? quiesced = null; + lock (_stateGate) + { + _activationSetupUsers--; + if (_activationSetupUsers == 0 && _disposed) + { + quiesced = _activationSetupsQuiesced; + } + } + + quiesced?.TrySetResult(); + TryStartPendingConfigurationActivation(); + } + + private async Task CompleteRejectedActivationAsync( + ActivationContext activation, + bool cleanupService) + { + try + { + CancelActivation(activation); + activation.Complete(); + await activation.CancellationQuiesced.ConfigureAwait(false); + if (cleanupService) + { + await RetireDiscardedServiceAsync( + activation, + activation.Service, + cleanupAlreadyClaimed: true) + .ConfigureAwait(false); + } + } + finally + { + activation.Finish(); + } + } + + private async Task ActivateRepositoryAsync(ActivationContext activation) + { + IProjectVersionControlBackend? candidateService = null; + IProjectVersionControlBackend? pendingCleanup = null; + IProjectVersionControlBackend? pendingRecoveryOfferService = null; + try + { + await activation.PredecessorsCompleted.ConfigureAwait(false); + activation.CancellationToken.ThrowIfCancellationRequested(); + if (!TryPublishActivationServiceIfCurrent(activation)) + { + return; + } + + GitAvailability availability = await activation.Service.GetAvailabilityAsync( + activation.CancellationToken); + if (availability.State != GitAvailabilityState.Installed) + { + return; + } + + RepositoryInfo? repository = await activation.Service.DiscoverRepositoryAsync( + activation.ProjectRoot, + activation.CancellationToken); + if (repository is null) + { + return; + } + + if (repository.IsNestedInForeignRepo) + { + PendingOpeningRepositoryDecision? openingDecision = + activation.OpeningRepositoryDecision; + bool matchesOpeningDecision = openingDecision is not null + && RepositoriesEqual( + openingDecision.Repository, + repository) + && RepositoryPathComparer.AreEquivalent( + repository.ProjectRoot, + activation.ProjectRoot); + if (matchesOpeningDecision) + { + if (!openingDecision!.Accepted) + { + return; + } + } + else if (!await ConfirmUseEnclosingRepositoryIfNeededAsync( + activation.Service, + repository, + activation.CancellationToken)) + { + return; + } + } + else if (!await ConfirmAdoptRepositoryIfNeededAsync( + activation.Service, + repository, + activation.CancellationToken)) + { + return; + } + + if (!IsCurrentActivation(activation)) + { + return; + } + + IProjectVersionControlBackend trackedService = _serviceFactory?.Invoke(repository) + ?? new GitCliVersionControlService( + _installationLocator, + repository, + () => _projectService.CurrentProject.Value is null, + PresentPolicyNoticeAsync, + activation.ProjectFile); + candidateService = trackedService; + if (!TryRegisterCandidateService(activation, trackedService)) + { + pendingCleanup = trackedService; + return; + } + + await activation.PredecessorsCompleted.ConfigureAwait(false); + activation.CancellationToken.ThrowIfCancellationRequested(); + + try + { + await trackedService.EnsureRepositoryHygieneAsync( + activation.CancellationToken); + } + catch + { + if (activation.OwnsService(trackedService)) + { + ClearProjectState(activation.Revision); + } + else + { + pendingCleanup = trackedService; + } + + throw; + } + + bool activationCompleted = CompleteActivation(activation, trackedService); + if (!activationCompleted && !activation.OwnsService(trackedService)) + { + pendingCleanup = trackedService; + } + + if (activationCompleted) + { + pendingRecoveryOfferService = trackedService; + } + } + catch (OperationCanceledException) when (activation.CancellationToken.IsCancellationRequested) + { + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to discover version control for the open project."); + } + finally + { + try + { + activation.Complete(); + await activation.CancellationQuiesced.ConfigureAwait(false); + await activation.PredecessorsCompleted.ConfigureAwait(false); + if (pendingCleanup is not null) + { + await RetireDiscardedServiceAsync(activation, pendingCleanup) + .ConfigureAwait(false); + } + else if (candidateService is not null) + { + if (activation.OwnsService(candidateService)) + { + UnregisterCandidateService(activation, candidateService); + } + else + { + await RetireDiscardedServiceAsync(activation, candidateService) + .ConfigureAwait(false); + } + } + + bool stillOwned; + lock (_stateGate) + { + if (ReferenceEquals(_activation, activation)) + { + _activation = null; + } + + stillOwned = ReferenceEquals(_state.OwnedService, activation.Service); + } + + if (!stillOwned) + { + await RetireDiscardedServiceAsync(activation, activation.Service) + .ConfigureAwait(false); + } + } + finally + { + activation.Finish(); + StartDeferredSaveSnapshot(); + if (pendingRecoveryOfferService is not null) + { + StartPendingPullRecoveryOffer(pendingRecoveryOfferService); + } + + TryStartPendingConfigurationActivation(); + } + } + } + + // Version tracking stays opt-in per project, so merely opening a project whose directory the + // user has already made a repository must not start writing hygiene files and commits. Only a + // repository that already records an earlier opt-in resumes tracking without asking. + // Adopting a foreign work tree is the user's call, but only the first time: a repository that + // already records an opt-in resumes tracking without asking, exactly like a non-nested one. + // Asking on every open would turn a dismissed prompt into a session with no snapshots at all. + private async Task ConfirmUseEnclosingRepositoryIfNeededAsync( + IProjectVersionControlBackend service, + RepositoryInfo repository, + CancellationToken cancellationToken) + { + return await service.HasVersionTrackingOptInAsync(repository, cancellationToken) + || await ConfirmUseEnclosingRepositoryAsync(repository, cancellationToken); + } + + private async Task ConfirmAdoptRepositoryIfNeededAsync( + IProjectVersionControlBackend service, + RepositoryInfo repository, + CancellationToken cancellationToken) + { + return await service.HasVersionTrackingOptInAsync(repository, cancellationToken) + || await ConfirmAdoptExistingRepositoryAsync(repository, cancellationToken); + } + + private async Task SelectRepositoryForInitializationAsync( + IProjectVersionControlBackend service, + string projectRoot, + CancellationToken cancellationToken) + { + RepositoryInfo? discovered = await service.DiscoverRepositoryAsync( + projectRoot, + cancellationToken); + if (discovered is not { IsNestedInForeignRepo: true }) + { + return discovered ?? new RepositoryInfo(projectRoot, projectRoot); + } + + return await ConfirmUseEnclosingRepositoryAsync(discovered, cancellationToken) + ? discovered + : null; + } + + private void StartPendingPullRecoveryOffer(IProjectVersionControlBackend service) + { + var offer = new PendingRecoveryOfferContext( + service, + CancellationTokenSource.CreateLinkedTokenSource(_lifetimeCancellation.Token)); + PendingRecoveryOfferContext? previousOffer; + lock (_stateGate) + { + if (_disposed + || !ReferenceEquals(_state.OwnedService, service) + || !ReferenceEquals(_state.VisibleService, service)) + { + offer.Cancellation.Dispose(); + return; + } + + previousOffer = _pendingRecoveryOffer; + _pendingRecoveryOffer = offer; + _pendingRecoveryOfferUsers++; + } + + CancelPendingPullRecoveryOffer(previousOffer); + _ = RunPendingPullRecoveryOfferAsync(offer); + } + + private async Task RunPendingPullRecoveryOfferAsync( + PendingRecoveryOfferContext offer) + { + IProjectVersionControlBackend service = offer.Service; + CancellationToken cancellationToken = offer.Cancellation.Token; + try + { + IReadOnlyList recoveries; + using (NonTransactionalOperationLease operation = + await BeginNonTransactionalOperationAsync(cancellationToken) + .ConfigureAwait(false)) + { + if (!ReferenceEquals(GetOperationReadyBackend(), service)) + { + return; + } + + recoveries = await service.ExecuteExclusiveAsync( + transaction => transaction.GetPendingPullRecoveriesAsync( + operation.CancellationToken), + operation.CancellationToken) + .ConfigureAwait(false); + } + + var currentIds = recoveries + .Select(static recovery => recovery.Id) + .ToHashSet(StringComparer.Ordinal); + PendingPullRecovery[] orderedRecoveries = recoveries + .OrderBy(static candidate => candidate.CreatedAt) + .ThenBy(static candidate => candidate.Id, StringComparer.Ordinal) + .ToArray(); + PendingPullRecovery? recovery = null; + bool offerRecovery = false; + lock (_stateGate) + { + _offeredPendingRecoveryIds.RemoveWhere(id => !currentIds.Contains(id)); + if (!_disposed + && ReferenceEquals(_pendingRecoveryOffer, offer) + && ReferenceEquals(_state.OwnedService, service) + && ReferenceEquals(_state.VisibleService, service)) + { + recovery = orderedRecoveries.FirstOrDefault(candidate => + !_offeredPendingRecoveryIds.Contains(candidate.Id)); + if (recovery is not null) + { + offerRecovery = _offeredPendingRecoveryIds.Add(recovery.Id); + } + } + } + + if (!offerRecovery + || recovery is null + || !await ConfirmPendingPullRecoveryAsync( + ToRecoveryInfo(recovery), + cancellationToken)) + { + return; + } + + await RunPendingPullRecoveryCycleAsync( + recovery.Id, + requireConfirmation: false, + cancellationToken, + confirmedRecovery: recovery) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (InvalidOperationException ex) + { + _logger.LogInformation( + ex, + "Skipped a pending pull recovery offer because the project lifecycle changed."); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to offer a pending pull recovery."); + } + finally + { + TaskCompletionSource? quiesced = null; + lock (_stateGate) + { + if (ReferenceEquals(_pendingRecoveryOffer, offer)) + { + _pendingRecoveryOffer = null; + } + + _pendingRecoveryOfferUsers--; + if (_pendingRecoveryOfferUsers == 0 && _disposed) + { + quiesced = _pendingRecoveryOffersQuiesced; + } + } + + offer.Cancellation.Dispose(); + quiesced?.TrySetResult(); + } + } + + private void CancelPendingPullRecoveryOffer() + { + PendingRecoveryOfferContext? offer; + lock (_stateGate) + { + offer = _pendingRecoveryOffer; + _pendingRecoveryOffer = null; + } + + CancelPendingPullRecoveryOffer(offer); + } + + private void CancelPendingPullRecoveryOffer(PendingRecoveryOfferContext? offer) + { + try + { + offer?.Cancellation.Cancel(); + } + catch (ObjectDisposedException) + { + } + catch (Exception ex) + { + _logger.LogError( + ex, + "A pending pull recovery offer cancellation callback failed."); + } + } + + private void ReconcileOfferedPendingRecoveryIds( + IReadOnlyList recoveries) + { + var currentIds = recoveries + .Select(static recovery => recovery.Id) + .ToHashSet(StringComparer.Ordinal); + lock (_stateGate) + { + _offeredPendingRecoveryIds.RemoveWhere(id => !currentIds.Contains(id)); + } + } + + private void CompletePendingPullRecoveryPublication(string recoveryId) + { + lock (_stateGate) + { + _offeredPendingRecoveryIds.Remove(recoveryId); + } + + PublishPendingPullRecoveriesChanged(); + } + + private void PublishPendingPullRecoveriesChanged() + { + EventHandler? handlers = PendingPullRecoveriesChanged; + if (handlers is null) + { + return; + } + + foreach (EventHandler handler in handlers.GetInvocationList()) + { + try + { + handler(this, EventArgs.Empty); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "A pending pull recovery subscriber failed."); + } + } + } + + private void OnVersionControlConfigChanged(object? sender, EventArgs e) + { + bool executablePathChanged = + TryCaptureGitExecutablePathChange(out string? executablePath); + bool useLfsWhenAvailableChanged = TryCaptureUseLfsWhenAvailableChange( + out bool useLfsWhenAvailable); + if (executablePathChanged) + { + AdvanceProjectServiceEpoch(); + } + + if ((executablePathChanged || useLfsWhenAvailableChanged) + && _projectService.CurrentProject.Value is { } project) + { + QueueConfigurationActivation( + project, + executablePath, + useLfsWhenAvailable, + rediscoverUnassociatedBackend: executablePathChanged, + reapplyTrackedRepositoryHygiene: useLfsWhenAvailableChanged); + } + + StartAvailabilityRefresh(); + } + + private bool TryCaptureGitExecutablePathChange(out string? executablePath) + { + executablePath = NormalizeGitExecutablePath(_config.GitExecutablePath); + lock (_stateGate) + { + if (_disposed + || string.Equals( + executablePath, + _observedGitExecutablePath, + PathComparison)) + { + return false; + } + + _observedGitExecutablePath = executablePath; + return true; + } + } + + private bool TryCaptureUseLfsWhenAvailableChange(out bool useLfsWhenAvailable) + { + useLfsWhenAvailable = _config.UseLfsWhenAvailable; + lock (_stateGate) + { + if (_disposed || useLfsWhenAvailable == _observedUseLfsWhenAvailable) + { + return false; + } + + _observedUseLfsWhenAvailable = useLfsWhenAvailable; + if (_state.OwnedService?.Repository is not null) + { + _repositoryHygieneConfigurationDirty = true; + } + + return true; + } + } + + private void QueueRepositoryHygieneConfigurationIfDirty(Project project) + { + string? executablePath; + bool useLfsWhenAvailable; + lock (_stateGate) + { + if (_disposed + || !_repositoryHygieneConfigurationDirty + || !ReferenceEquals(_projectService.CurrentProject.Value, project)) + { + return; + } + + executablePath = _observedGitExecutablePath; + useLfsWhenAvailable = _observedUseLfsWhenAvailable; + } + + QueueConfigurationActivation( + project, + executablePath, + useLfsWhenAvailable, + rediscoverUnassociatedBackend: false, + reapplyTrackedRepositoryHygiene: true); + } + + private void QueueConfigurationActivation( + Project project, + string? executablePath, + bool useLfsWhenAvailable, + bool rediscoverUnassociatedBackend, + bool reapplyTrackedRepositoryHygiene) + { + string projectRoot = GetProjectRoot(project); + CancellationTokenSource? activeCancellation = null; + lock (_stateGate) + { + if (_disposed || !ReferenceEquals(_projectService.CurrentProject.Value, project)) + { + return; + } + + if (_state.ProjectRoot is { } stateRoot + && string.Equals(stateRoot, projectRoot, PathComparison) + && _state.OwnedService?.Repository is not null + && !reapplyTrackedRepositoryHygiene) + { + return; + } + + ConfigurationActivationRequest? pending = _pendingConfigurationActivation; + _pendingConfigurationActivation = new ConfigurationActivationRequest( + ++_nextConfigurationActivationRevision, + project, + projectRoot, + executablePath, + useLfsWhenAvailable, + rediscoverUnassociatedBackend + || pending?.RediscoverUnassociatedBackend == true, + reapplyTrackedRepositoryHygiene + || pending?.ReapplyTrackedRepositoryHygiene == true); + if (rediscoverUnassociatedBackend) + { + activeCancellation = _configurationActivationCancellation; + } + } + + CancelConfigurationActivation(activeCancellation); + TryStartPendingConfigurationActivation(); + } + + private void TryStartPendingConfigurationActivation() + { + ConfigurationActivationStart? activationStart; + lock (_stateGate) + { + activationStart = TryPreparePendingConfigurationActivationLocked(); + } + + StartConfigurationActivation(activationStart); + } + + private ConfigurationActivationStart? TryPreparePendingConfigurationActivationLocked() + { + ConfigurationActivationRequest? request = _pendingConfigurationActivation; + if (_disposed) + { + _pendingConfigurationActivation = null; + return null; + } + + if (request is null || _configurationActivationActive) + { + return null; + } + + Project? currentProject = _projectService.CurrentProject.Value; + if (!ReferenceEquals(currentProject, request.Project)) + { + _pendingConfigurationActivation = null; + return null; + } + + if (_state.ProjectRoot is { } stateRoot + && !string.Equals(stateRoot, request.ProjectRoot, PathComparison)) + { + _pendingConfigurationActivation = null; + return null; + } + + if (_operationCloseBarrierActive + || _closeBarrierUsers != 0 + || _operationUsers != 0 + || _lifecycleUsers != 0 + || _activationSetupUsers != 0 + || _activation is not null) + { + return null; + } + + IProjectVersionControlBackend? trackedService = null; + if (_state.ProjectRoot is { } trackedRoot + && string.Equals(trackedRoot, request.ProjectRoot, PathComparison) + && _state.OwnedService?.Repository is not null) + { + if (!request.ReapplyTrackedRepositoryHygiene) + { + _pendingConfigurationActivation = null; + return null; + } + + trackedService = _state.OwnedService; + } + else if (!request.RediscoverUnassociatedBackend) + { + _pendingConfigurationActivation = null; + return null; + } + + CancellationToken operationEpochCancellation = (_operationEpochCancellation + ?? throw new ObjectDisposedException( + nameof(VersionControlCoordinator))) + .Token; + var cancellation = CancellationTokenSource.CreateLinkedTokenSource( + _lifetimeCancellation.Token, + operationEpochCancellation); + _pendingConfigurationActivation = null; + _configurationActivationActive = true; + _configurationActivationCancellation = cancellation; + _operationUsers++; + return new ConfigurationActivationStart(request, cancellation, trackedService); + } + + private void StartConfigurationActivation(ConfigurationActivationStart? activationStart) + { + if (activationStart is not null) + { + _ = RunConfigurationActivationAsync(activationStart); + } + } + + private async Task RunConfigurationActivationAsync(ConfigurationActivationStart activationStart) + { + ConfigurationActivationRequest request = activationStart.Request; + CancellationTokenSource cancellation = activationStart.Cancellation; + bool retry = false; + try + { + cancellation.Token.ThrowIfCancellationRequested(); + if (activationStart.TrackedService is { } trackedService) + { + await trackedService.EnsureRepositoryHygieneAsync(cancellation.Token) + .ConfigureAwait(false); + cancellation.Token.ThrowIfCancellationRequested(); + lock (_stateGate) + { + if (ReferenceEquals(_state.OwnedService, trackedService) + && trackedService.Repository is not null + && request.UseLfsWhenAvailable == _observedUseLfsWhenAvailable) + { + _repositoryHygieneConfigurationDirty = false; + } + } + } + else + { + ActivationContext? activation = await StartProjectActivationAsync( + request.Project, + internalTransition: false, + cancellation.Token) + .ConfigureAwait(false); + if (activation is not null) + { + await activation.Completion.ConfigureAwait(false); + } + } + + cancellation.Token.ThrowIfCancellationRequested(); + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + retry = true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to apply a version-control configuration change."); + } + finally + { + FinishConfigurationActivation(activationStart, retry); + } + } + + private void FinishConfigurationActivation( + ConfigurationActivationStart activationStart, + bool retry) + { + ConfigurationActivationRequest request = activationStart.Request; + CancellationTokenSource cancellation = activationStart.Cancellation; + TaskCompletionSource? configurationActivationQuiesced = null; + TaskCompletionSource? operationsQuiesced = null; + ConfigurationActivationStart? nextActivation = null; + lock (_stateGate) + { + if (ReferenceEquals(_configurationActivationCancellation, cancellation)) + { + _configurationActivationCancellation = null; + } + + _configurationActivationActive = false; + configurationActivationQuiesced = _configurationActivationQuiesced; + _configurationActivationQuiesced = null; + _operationUsers--; + + bool retryTargetStillCurrent = activationStart.TrackedService is { } trackedService + ? request.ReapplyTrackedRepositoryHygiene + && request.UseLfsWhenAvailable == _observedUseLfsWhenAvailable + && ReferenceEquals(_state.OwnedService, trackedService) + && trackedService.Repository is not null + : request.RediscoverUnassociatedBackend + && string.Equals( + _observedGitExecutablePath, + request.ExecutablePath, + PathComparison) + && _state.OwnedService?.Repository is null; + if (retry + && !_disposed + && request.Revision == _nextConfigurationActivationRevision + && _pendingConfigurationActivation is null + && ReferenceEquals(_projectService.CurrentProject.Value, request.Project) + && (_state.ProjectRoot is null + || string.Equals( + _state.ProjectRoot, + request.ProjectRoot, + PathComparison)) + && retryTargetStillCurrent) + { + _pendingConfigurationActivation = request; + } + + nextActivation = TryPreparePendingConfigurationActivationLocked(); + if (_operationUsers == 0) + { + operationsQuiesced = _operationsQuiesced; + _operationsQuiesced = null; + } + } + + try + { + cancellation.Dispose(); + } + finally + { + try + { + StartConfigurationActivation(nextActivation); + } + finally + { + configurationActivationQuiesced?.TrySetResult(); + operationsQuiesced?.TrySetResult(); + } + } + } + + private void CancelConfigurationActivation(CancellationTokenSource? cancellation) + { + try + { + cancellation?.Cancel(); + } + catch (ObjectDisposedException) + { + } + catch (Exception ex) + { + _logger.LogError( + ex, + "A Git configuration activation cancellation callback failed."); + } + } + + private void StartAvailabilityRefresh() + { + lock (_stateGate) + { + if (_disposed) + { + return; + } + + _availabilityUsers++; + } + + _ = RefreshAvailabilityAsync(); + } + + private async Task RefreshAvailabilityAsync() + { + try + { + await GetAvailabilityAsync(_lifetimeCancellation.Token); + } + catch (OperationCanceledException) when (_lifetimeCancellation.IsCancellationRequested) + { + return; + } + catch (ObjectDisposedException) when (_disposed) + { + return; + } + catch (Exception ex) + { + bool schedulePublication = false; + lock (_stateGate) + { + if (!_disposed) + { + schedulePublication = TransitionStateLocked( + _state with { IsGitAvailable = false }); + } + } + + SchedulePublicationDrain(schedulePublication); + _logger.LogWarning(ex, "Failed to refresh Git availability."); + } + finally + { + FinishAvailabilityOperation(); + } + } + + private void FinishAvailabilityOperation() + { + TaskCompletionSource? quiesced = null; + lock (_stateGate) + { + _availabilityUsers--; + if (_availabilityUsers == 0 && _disposed) + { + quiesced = _availabilityQuiesced; + } + } + + quiesced?.TrySetResult(); + } + + private bool BeginActivation( + ActivationContext activation, + out bool cleanupRejectedService) + { + ActivationContext? previousActivation; + bool schedulePublication = false; + bool waitsForPredecessors = false; + bool rejected; + lock (_stateGate) + { + rejected = _disposed + || activation.Revision != Volatile.Read(ref _latestActivationRevision) + || activation.CancellationToken.IsCancellationRequested + || !CanAdoptServiceLocked(activation.Service); + if (rejected) + { + previousActivation = null; + cleanupRejectedService = TryClaimRejectedServiceCleanupLocked( + activation.Service); + } + else + { + previousActivation = _activation; + LinkServiceUsersLocked(activation, activation.Service); + _activation = activation; + cleanupRejectedService = false; + waitsForPredecessors = + !activation.PredecessorsCompleted.IsCompletedSuccessfully; + schedulePublication = TransitionOwnedServiceLocked( + activation.Service, + !waitsForPredecessors + ? activation.Service + : null, + activation.ProjectRoot, + previousActivation, + out _); + } + } + + if (rejected) + { + return false; + } + + CancelPendingPullRecoveryOffer(); + SchedulePublicationDrain(schedulePublication); + CancelActivation(previousActivation); + + return true; + } + + private bool TryPublishActivationServiceIfCurrent(ActivationContext activation) + { + bool schedulePublication = false; + bool accepted; + lock (_stateGate) + { + accepted = IsCurrentActivationLocked(activation); + if (accepted + && (!activation.HasPredecessors + || activation.Service.Repository is null)) + { + schedulePublication = TransitionStateLocked( + _state with + { + VisibleService = activation.Service, + IsTracked = activation.Service.Repository is not null, + }); + } + } + + SchedulePublicationDrain(schedulePublication); + return accepted; + } + + private bool TryRegisterCandidateService( + ActivationContext activation, + IProjectVersionControlBackend service) + { + lock (_stateGate) + { + if (!IsCurrentActivationLocked(activation) || !CanAdoptServiceLocked(service)) + { + if (IsServiceOwnedOrClaimedLocked(service)) + { + activation.MarkServiceCleanupDelegated(service); + } + + return false; + } + + LinkServiceUsersLocked(activation, service); + if (!_candidateServiceUsers.TryGetValue(service, out HashSet? users)) + { + users = []; + _candidateServiceUsers.Add(service, users); + } + + users.Add(activation); + return true; + } + } + + private void LinkServiceUsersLocked( + ActivationContext activation, + IProjectVersionControlBackend service) + { + var predecessors = new HashSet(); + if (_activation is { } current + && !ReferenceEquals(current, activation) + && current.Revision < activation.Revision + && current.OwnsService(service)) + { + predecessors.Add(current); + } + + if (_candidateServiceUsers.TryGetValue(service, out HashSet? users)) + { + foreach (ActivationContext user in users) + { + if (!ReferenceEquals(user, activation) + && user.Revision < activation.Revision) + { + predecessors.Add(user); + } + } + } + + foreach (ActivationContext predecessor in predecessors) + { + activation.AddCompletionDependency(predecessor.Completion); + predecessor.MarkServiceCleanupDelegated(service); + } + } + + private bool CanAdoptServiceLocked(IProjectVersionControlBackend service) + { + return !_managedServices.Contains(service) + || ReferenceEquals(_state.OwnedService, service); + } + + private bool IsServiceOwnedOrClaimedLocked(IProjectVersionControlBackend service) + { + return ReferenceEquals(_state.OwnedService, service) + || _managedServices.Contains(service) + || _candidateServiceUsers.TryGetValue(service, out HashSet? users) + && users.Count > 0; + } + + private bool TryClaimRejectedServiceCleanupLocked( + IProjectVersionControlBackend service) + { + return !IsServiceOwnedOrClaimedLocked(service) && _managedServices.Add(service); + } + + private bool CompleteActivation( + ActivationContext activation, + IProjectVersionControlBackend trackedService) + { + bool accepted; + bool schedulePublication = false; + lock (_stateGate) + { + accepted = !_disposed + && ReferenceEquals(_activation, activation) + && activation.Revision == Volatile.Read(ref _latestActivationRevision) + && ReferenceEquals(_state.OwnedService, activation.Service) + && _state.ProjectRoot is { } projectRoot + && string.Equals(projectRoot, activation.ProjectRoot, PathComparison) + && !activation.CancellationToken.IsCancellationRequested + && CanAdoptServiceLocked(trackedService); + if (accepted) + { + schedulePublication = TransitionOwnedServiceLocked( + trackedService, + trackedService, + activation.ProjectRoot, + activation, + out _); + activation.TransferOwnership(trackedService); + } + } + + if (!accepted) + { + return false; + } + + SchedulePublicationDrain(schedulePublication); + return true; + } + + private bool IsCurrentActivation(ActivationContext activation) + { + lock (_stateGate) + { + return IsCurrentActivationLocked(activation); + } + } + + private bool IsCurrentActivationLocked(ActivationContext activation) + { + return !_disposed + && ReferenceEquals(_activation, activation) + && activation.Revision == Volatile.Read(ref _latestActivationRevision) + && ReferenceEquals(_state.OwnedService, activation.Service) + && _state.ProjectRoot is { } projectRoot + && string.Equals(projectRoot, activation.ProjectRoot, PathComparison) + && !activation.CancellationToken.IsCancellationRequested; + } + + private void ClearProjectState(long? expectedActivationRevision = null) + { + ActivationContext? activation; + bool schedulePublication; + lock (_stateGate) + { + if (expectedActivationRevision is { } expected + && expected != Volatile.Read(ref _latestActivationRevision)) + { + return; + } + + activation = _activation; + _activation = null; + _repositoryHygieneConfigurationDirty = false; + schedulePublication = TransitionOwnedServiceLocked( + ownedService: null, + visibleService: null, + projectRoot: null, + activation, + out _); + } + + CancelPendingPullRecoveryOffer(); + SchedulePublicationDrain(schedulePublication); + CancelActivation(activation); + } + + private void CancelActivation(ActivationContext? activation) + { + if (activation is null) + { + return; + } + + try + { + activation.Cancel(); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "An activation cancellation callback failed while version control state was transitioning."); + } + } + + private void SetVisibleService(IProjectVersionControlService? service) + { + bool schedulePublication; + lock (_stateGate) + { + if (service is not null && !ReferenceEquals(service, _state.OwnedService)) + { + return; + } + + schedulePublication = TransitionStateLocked( + _state with + { + VisibleService = service, + IsTracked = service?.Repository is not null, + }); + } + + SchedulePublicationDrain(schedulePublication); + } + + private bool TransitionOwnedServiceLocked( + IProjectVersionControlBackend? ownedService, + IProjectVersionControlService? visibleService, + string? projectRoot, + ActivationContext? retiringActivation, + out bool retirementQueued) + { + IProjectVersionControlBackend? previous = _state.OwnedService; + if (ownedService is not null) + { + _managedServices.Add(ownedService); + } + + if (!ReferenceEquals(previous, ownedService)) + { + if (previous is IRepositoryLockRecoveryService previousRecovery) + { + previousRecovery.RecoverableLockAvailable -= OnRecoverableLockAvailable; + } + + if (ownedService is IRepositoryLockRecoveryService recovery) + { + recovery.RecoverableLockAvailable += OnRecoverableLockAvailable; + } + } + + ServiceRetirement? retirement = null; + bool retirementWaitsForActivation = false; + if (previous is not null && !ReferenceEquals(previous, ownedService)) + { + retirementWaitsForActivation = retiringActivation?.OwnsService(previous) == true; + Task activationReady = retirementWaitsForActivation + ? retiringActivation!.Completion + : Task.CompletedTask; + retirement = new ServiceRetirement(previous, activationReady); + } + if (retirement is not null && retirementWaitsForActivation) + { + retiringActivation!.MarkServiceCleanupDelegated(previous!); + } + + retirementQueued = retirement is not null; + return TransitionStateLocked( + _state with + { + ProjectRoot = projectRoot, + OwnedService = ownedService, + VisibleService = visibleService, + IsTracked = visibleService?.Repository is not null, + }, + retirement); + } + + private bool TransitionStateLocked( + CoordinatorState next, + ServiceRetirement? retirement = null) + { + if (retirement is null + && ReferenceEquals(_state.OwnedService, next.OwnedService) + && ReferenceEquals(_state.VisibleService, next.VisibleService) + && string.Equals(_state.ProjectRoot, next.ProjectRoot, PathComparison) + && _state.IsGitAvailable == next.IsGitAvailable + && _state.IsTracked == next.IsTracked) + { + return false; + } + + next = next with { Revision = ++_nextStateRevision }; + _state = next; + _publicationQueue.Enqueue(new StatePublication(next, retirement)); + if (_publicationDrainScheduled) + { + return false; + } + + _publicationDrainScheduled = true; + return true; + } + + private void SchedulePublicationDrain(bool schedulePublication) + { + if (!schedulePublication) + { + return; + } + + if (_dispatcher.CheckAccess()) + { + DrainStatePublications(); + } + else + { + _dispatcher.Post(DrainStatePublications); + } + } + + private void DrainStatePublications() + { + lock (_stateGate) + { + if (_publicationDrainRunning) + { + return; + } + + _publicationDrainRunning = true; + } + + bool disposeProperties = false; + bool reschedule = false; + TaskCompletionSource? drainQuiesced = null; + try + { + while (true) + { + StatePublication publication; + lock (_stateGate) + { + if (_publicationQueue.Count == 0) + { + break; + } + + publication = _publicationQueue.Dequeue(); + } + + if (publication.State.Revision > _lastPublishedRevision) + { + _lastPublishedRevision = publication.State.Revision; + if (!_propertiesDisposed) + { + PublishStateValue( + () => _isGitAvailable.Value = publication.State.IsGitAvailable, + publication.State.Revision, + nameof(IsGitAvailable)); + PublishStateValue( + () => _isTracked.Value = publication.State.IsTracked, + publication.State.Revision, + nameof(IsTracked)); + PublishStateValue( + () => _editorService.PublishProjectVersionControlService( + publication.State.VisibleService), + publication.State.Revision, + nameof(EditorService.ProjectVersionControlService)); + } + } + + if (publication.Retirement is { } retirement) + { + RetireService(retirement); + } + } + } + finally + { + lock (_stateGate) + { + _publicationDrainRunning = false; + if (_publicationQueue.Count == 0) + { + _publicationDrainScheduled = false; + drainQuiesced = _publicationDrainQuiesced; + _publicationDrainQuiesced = null; + if (_disposePropertiesRequested && !_propertiesDisposed) + { + _propertiesDisposed = true; + disposeProperties = true; + } + } + else + { + _publicationDrainScheduled = true; + reschedule = true; + } + } + + try + { + if (disposeProperties) + { + try + { + _isGitAvailable.Dispose(); + _isTracked.Dispose(); + } + finally + { + _propertiesDisposedCompletion.TrySetResult(); + } + } + + if (reschedule) + { + _dispatcher.Post(DrainStatePublications); + } + } + finally + { + drainQuiesced?.TrySetResult(); + } + } + } + + private void PublishStateValue(Action publish, long revision, string member) + { + try + { + publish(); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "A version-control state subscriber failed while publishing {Member} at revision {Revision}.", + member, + revision); + } + } + + private void DisposePublishedProperties() + { + void DisposeOnUiThread() + { + bool drain; + lock (_stateGate) + { + if (_propertiesDisposed) + { + return; + } + + _disposePropertiesRequested = true; + drain = !_publicationDrainRunning; + if (drain) + { + _publicationDrainScheduled = true; + } + } + + if (drain) + { + DrainStatePublications(); + } + } + + if (_dispatcher.CheckAccess()) + { + DisposeOnUiThread(); + } + else + { + _dispatcher.Post(DisposeOnUiThread); + } + } + + private void RetireService(ServiceRetirement retirement) + { + lock (_stateGate) + { + _retirementUsers++; + } + + _ = RetireServiceAsync(retirement); + } + + private async Task RetireServiceAsync(ServiceRetirement retirement) + { + try + { + await retirement.ActivationReady.ConfigureAwait(false); + await retirement.Service.RetireAsync(finalSnapshot: null).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to retire a project version-control service."); + } + finally + { + DisposeService(retirement.Service); + TaskCompletionSource? quiesced = null; + lock (_stateGate) + { + _retirementUsers--; + if (_retirementUsers == 0 && _disposed) + { + quiesced = _retirementsQuiesced; + } + } + + quiesced?.TrySetResult(); + } + } + + private void UnregisterCandidateService( + ActivationContext activation, + IProjectVersionControlBackend service) + { + lock (_stateGate) + { + UnregisterCandidateServiceLocked(activation, service); + } + } + + private void UnregisterCandidateServiceLocked( + ActivationContext activation, + IProjectVersionControlBackend service) + { + if (_candidateServiceUsers.TryGetValue(service, out HashSet? users)) + { + users.Remove(activation); + if (users.Count == 0) + { + _candidateServiceUsers.Remove(service); + } + } + } + + private async Task RetireDiscardedServiceAsync( + ActivationContext activation, + IProjectVersionControlBackend service, + bool cleanupAlreadyClaimed = false) + { + bool cleanupService; + lock (_stateGate) + { + UnregisterCandidateServiceLocked(activation, service); + cleanupService = cleanupAlreadyClaimed + || !activation.IsServiceCleanupDelegated(service) + && !IsServiceOwnedOrClaimedLocked(service) + && _managedServices.Add(service); + activation.MarkServiceCleanupDelegated(service); + } + + if (!cleanupService) + { + return; + } + + try + { + await service.RetireAsync(finalSnapshot: null).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to retire a discarded project version-control service."); + } + finally + { + DisposeService(service); + } + } + + private void DisposeService(IProjectVersionControlBackend? service) + { + try + { + service?.Dispose(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to dispose a project version control service."); + } + } + + private void DetachRetiredService(IProjectVersionControlBackend service) + { + bool detached = false; + bool schedulePublication = false; + lock (_stateGate) + { + if (ReferenceEquals(_state.OwnedService, service)) + { + if (service is IRepositoryLockRecoveryService recovery) + { + recovery.RecoverableLockAvailable -= OnRecoverableLockAvailable; + } + + detached = true; + schedulePublication = TransitionStateLocked( + _state with + { + OwnedService = null, + VisibleService = null, + IsTracked = false, + }); + } + } + + SchedulePublicationDrain(schedulePublication); + if (detached) + { + CancelPendingPullRecoveryOffer(); + DisposeService(service); + } + } + + private void OnRecoverableLockAvailable(object? sender, RepositoryLockInfo lockInfo) + { + lock (_stateGate) + { + if (_disposed) + { + return; + } + + _lockRecoveryUsers++; + } + + _ = RunLockRecoveryAsync(sender, lockInfo); + } + + private async Task RunLockRecoveryAsync(object? sender, RepositoryLockInfo lockInfo) + { + try + { + if (_dispatcher.CheckAccess()) + { + await OfferLockRecoveryAsync(sender, lockInfo); + } + else + { + await _dispatcher.InvokeAsync( + () => OfferLockRecoveryAsync(sender, lockInfo)); + } + } + finally + { + TaskCompletionSource? quiesced = null; + lock (_stateGate) + { + _lockRecoveryUsers--; + if (_lockRecoveryUsers == 0 && _disposed) + { + quiesced = _lockRecoveryQuiesced; + } + } + + quiesced?.TrySetResult(); + } + } + + private async Task OfferLockRecoveryAsync(object? sender, RepositoryLockInfo lockInfo) + { + bool gateEntered = false; + try + { + await _lockRecoveryGate.WaitAsync(_lifetimeCancellation.Token); + gateEntered = true; + if (_disposed + || sender is not IRepositoryLockRecoveryService recovery + || !ReferenceEquals(CurrentService, sender) + || !Equals(recovery.RecoverableLock, lockInfo)) + { + return; + } + + if (!await ConfirmRemoveStaleLockAsync(lockInfo, _lifetimeCancellation.Token) + || _disposed + || !ReferenceEquals(CurrentService, sender) + || !Equals(recovery.RecoverableLock, lockInfo)) + { + return; + } + + if (await recovery.RemoveRecoverableLockAsync(_lifetimeCancellation.Token)) + { + _logger.LogWarning( + "Removed stale Git repository lock with user consent. Lock: {LockPath}, LastWriteTimeUtc: {LastWriteTimeUtc}", + lockInfo.LockPath, + lockInfo.LastWriteTimeUtc); + await _dispatcher.InvokeAsync(() => + NotificationService.ShowInformation( + Strings.VersionControl, + Strings.VersionControl_StaleLockRemoved)); + } + } + catch (OperationCanceledException) when (_lifetimeCancellation.IsCancellationRequested) + { + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to recover a stale Git repository lock."); + } + finally + { + if (gateEntered) + { + _lockRecoveryGate.Release(); + } + } + } + + private enum RecoveryKind + { + Branch, + Restore, + } + + private sealed record CoordinatorState( + long Revision, + string? ProjectRoot, + IProjectVersionControlBackend? OwnedService, + IProjectVersionControlService? VisibleService, + bool IsGitAvailable, + bool IsTracked) + { + public static CoordinatorState Empty { get; } = new( + Revision: 0, + ProjectRoot: null, + OwnedService: null, + VisibleService: null, + IsGitAvailable: false, + IsTracked: false); + } + + private sealed record StatePublication( + CoordinatorState State, + ServiceRetirement? Retirement); + + private sealed record ServiceRetirement( + IProjectVersionControlBackend Service, + Task ActivationReady); + + private sealed record PendingRecoveryOfferContext( + IProjectVersionControlBackend Service, + CancellationTokenSource Cancellation); + + private sealed record PendingOpeningPullRecovery( + RepositoryInfo Repository, + PendingPullRecovery Recovery); + + private sealed record PendingOpeningRepositoryDecision( + ProjectService.ProjectOpenAttempt Attempt, + long AttemptId, + long TransitionId, + string ProjectFile, + RepositoryInfo Repository, + bool Accepted); + + private sealed record OpeningRepositoryInspection( + RepositoryInfo Repository, + string ProjectFile, + bool EnclosingRepositoryAccepted, + PendingPullRecoveryOpenSelection? Recovery); + + private sealed record PendingPullRecoveryOpenSelection( + RepositoryInfo Repository, + PendingPullRecovery Recovery, + string ProjectFile, + bool Accepted, + PendingOpeningPullRecovery? AppliedMarker) + { + public bool AlreadyApplied => AppliedMarker is not null; + } + + private sealed class VersionControlProjectOpenPreparation( + VersionControlCoordinator owner, + ProjectService.ProjectOpenAttempt attempt, + OpeningRepositoryInspection inspection) + : ProjectService.ProjectOpenPreparation + { + internal override Task ApplyAsync( + ProjectTransitionContext transition, + CancellationToken cancellationToken) + { + return owner.ApplyProjectOpeningPreparationAsync( + attempt, + inspection, + transition, + cancellationToken); + } + } + + private sealed class AbortProjectOpenPreparation : ProjectService.ProjectOpenPreparation + { + internal override Task ApplyAsync( + ProjectTransitionContext transition, + CancellationToken cancellationToken) + { + return Task.FromResult(ProjectOpenPreparationResult.Abort); + } + } + + private sealed record PullMutationOutcome( + RemoteOpResult Result, + PendingPullRecovery? Recovery, + string ProjectFile); + + private sealed record ConfigurationActivationRequest( + long Revision, + Project Project, + string ProjectRoot, + string? ExecutablePath, + bool UseLfsWhenAvailable, + bool RediscoverUnassociatedBackend, + bool ReapplyTrackedRepositoryHygiene); + + private sealed record ConfigurationActivationStart( + ConfigurationActivationRequest Request, + CancellationTokenSource Cancellation, + IProjectVersionControlBackend? TrackedService); + + private sealed class NonTransactionalCloseBarrier + { + private VersionControlCoordinator? _owner; + private readonly CancellationTokenSource _cancellation; + private readonly CancellationTokenSource _operationEpochCancellation; + + public NonTransactionalCloseBarrier( + VersionControlCoordinator owner, + CancellationTokenSource cancellation, + CancellationTokenSource operationEpochCancellation) + { + _owner = owner; + _cancellation = cancellation; + _operationEpochCancellation = operationEpochCancellation; + } + + public async Task CompleteAsync(bool projectClosed) + { + VersionControlCoordinator? owner = Interlocked.Exchange(ref _owner, null); + if (owner is null) + { + return; + } + + try + { + _cancellation.Dispose(); + } + finally + { + await owner.CompleteNonTransactionalCloseBarrierAsync( + _operationEpochCancellation, + projectClosed) + .ConfigureAwait(false); + } + } + } + + private sealed class NonTransactionalOperationLease : IDisposable + { + private VersionControlCoordinator? _owner; + private readonly CancellationTokenSource _cancellation; + + public NonTransactionalOperationLease( + VersionControlCoordinator owner, + CancellationTokenSource cancellation) + { + _owner = owner; + _cancellation = cancellation; + } + + public CancellationToken CancellationToken => _cancellation.Token; + + public void Dispose() + { + VersionControlCoordinator? owner = Interlocked.Exchange(ref _owner, null); + if (owner is null) + { + return; + } + + _cancellation.Dispose(); + owner.FinishNonTransactionalOperation(); + } + } + + private sealed class ActivationContext + { + private readonly object _gate = new(); + private readonly CancellationTokenSource _cancellation; + private readonly TaskCompletionSource _cancellationQuiesced = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _completion = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private readonly HashSet _cleanupDelegatedServices = new( + ReferenceEqualityComparer.Instance); + private Task _completionDependency = Task.CompletedTask; + private IProjectVersionControlBackend _ownedService; + private int _activeCancellations; + private bool _cleanupStarted; + private bool _completionRequested; + private bool _hasPredecessors; + + public ActivationContext( + long revision, + string projectRoot, + string projectFile, + IProjectVersionControlBackend service, + PendingOpeningRepositoryDecision? openingRepositoryDecision = null, + CancellationToken cancellationToken = default) + { + Revision = revision; + ProjectRoot = projectRoot; + ProjectFile = projectFile; + Service = service; + OpeningRepositoryDecision = openingRepositoryDecision; + _ownedService = service; + _cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + } + + public long Revision { get; } + + public string ProjectRoot { get; } + + public string ProjectFile { get; } + + public IProjectVersionControlBackend Service { get; } + + public PendingOpeningRepositoryDecision? OpeningRepositoryDecision { get; } + + public CancellationToken CancellationToken => _cancellation.Token; + + public Task CancellationQuiesced => _cancellationQuiesced.Task; + + public Task Completion => _completion.Task; + + public bool HasPredecessors + { + get + { + lock (_gate) + { + return _hasPredecessors; + } + } + } + + public Task PredecessorsCompleted + { + get + { + lock (_gate) + { + return _completionDependency; + } + } + } + + public bool OwnsService(IProjectVersionControlBackend service) + { + lock (_gate) + { + return ReferenceEquals(_ownedService, service); + } + } + + public void TransferOwnership(IProjectVersionControlBackend service) + { + lock (_gate) + { + _ownedService = service; + } + } + + public void AddCompletionDependency(Task completion) + { + lock (_gate) + { + _hasPredecessors = true; + _completionDependency = Task.WhenAll(_completionDependency, completion); + } + } + + public void Cancel() + { + lock (_gate) + { + if (_cleanupStarted) + { + return; + } + + _activeCancellations++; + } + + try + { + _cancellation.Cancel(); + } + catch (ObjectDisposedException) + { + } + finally + { + bool cleanup; + lock (_gate) + { + _activeCancellations--; + cleanup = TryBeginCleanupLocked(); + } + + if (cleanup) + { + FinishCleanup(); + } + } + } + + public void Complete() + { + bool cleanup; + lock (_gate) + { + _completionRequested = true; + cleanup = TryBeginCleanupLocked(); + } + + if (cleanup) + { + FinishCleanup(); + } + } + + public bool IsServiceCleanupDelegated(IProjectVersionControlBackend service) + { + lock (_gate) + { + return _cleanupDelegatedServices.Contains(service); + } + } + + public void MarkServiceCleanupDelegated(IProjectVersionControlBackend service) + { + lock (_gate) + { + _cleanupDelegatedServices.Add(service); + } + } + + public void Finish() + { + _completion.TrySetResult(); + } + + private bool TryBeginCleanupLocked() + { + if (_cleanupStarted || !_completionRequested || _activeCancellations != 0) + { + return false; + } + + _cleanupStarted = true; + return true; + } + + private void FinishCleanup() + { + try + { + _cancellation.Dispose(); + } + finally + { + _cancellationQuiesced.TrySetResult(); + } + } + } + + private static string GetProjectRoot(Project project) + { + string projectPath = project.Uri?.LocalPath + ?? throw new InvalidOperationException("The project has no file path."); + return Path.GetDirectoryName(projectPath) + ?? throw new InvalidOperationException("The project file has no parent directory."); + } + + private static string? NormalizeGitExecutablePath(string? path) + => string.IsNullOrWhiteSpace(path) ? null : path; + + private static StringComparison PathComparison + => FileSystemPathComparison.ForCurrentPlatform; + + private static StringComparer PathComparer + => FileSystemPathComparison.ComparerForCurrentPlatform; +} diff --git a/src/Beutl/ViewModels/Dialogs/CreateNewProjectViewModel.cs b/src/Beutl/ViewModels/Dialogs/CreateNewProjectViewModel.cs index 6a62ff9c22..969dc94849 100644 --- a/src/Beutl/ViewModels/Dialogs/CreateNewProjectViewModel.cs +++ b/src/Beutl/ViewModels/Dialogs/CreateNewProjectViewModel.cs @@ -1,21 +1,35 @@ using Avalonia; using Beutl.Configuration; +using Beutl.Editor.VersionControl; +using Beutl.Logging; using Beutl.Services; +using Microsoft.Extensions.Logging; using Reactive.Bindings; namespace Beutl.ViewModels.Dialogs; public sealed class CreateNewProjectViewModel { + private readonly ILogger _logger = Log.CreateLogger(); private readonly ProjectService _projectService; + private readonly IProjectVersionControlInitializer _versionControlInitializer; + private readonly Func> _requestIdentityAsync; - public CreateNewProjectViewModel(ProjectService projectService) + public CreateNewProjectViewModel( + ProjectService projectService, + IProjectVersionControlInitializer versionControlInitializer, + Func> requestIdentityAsync) { - _projectService = projectService; + _projectService = projectService ?? throw new ArgumentNullException(nameof(projectService)); + _versionControlInitializer = versionControlInitializer + ?? throw new ArgumentNullException(nameof(versionControlInitializer)); + _requestIdentityAsync = requestIdentityAsync + ?? throw new ArgumentNullException(nameof(requestIdentityAsync)); Location.Value = GetDefaultLocation(); Name.Value = GenProjectName(Location.Value); + _ = DetectGitAsync(); Name.SetValidateNotifyError(n => { @@ -86,12 +100,31 @@ public CreateNewProjectViewModel(ProjectService projectService) Create = new AsyncReactiveCommand(CanCreate); Create.Subscribe(async () => { + // Capture only an option that was visible before creation started. Git detection can + // finish while the project is being written, but that must not silently opt the user in. + bool initializeVersionControl = IsGitAvailable.Value && TrackHistory.Value; + // CreateProject surfaces failures to the user itself, so no fallback notification here. - await _projectService.CreateProject( + Project? project = await _projectService.CreateProject( Size.Value.Width, Size.Value.Height, FrameRate.Value, SampleRate.Value, Name.Value, Location.Value); + if (project is not null + && initializeVersionControl) + { + try + { + await _versionControlInitializer.InitializeCurrentProjectAsync( + project, + _requestIdentityAsync, + CancellationToken.None); + } + catch (Exception ex) + { + await ex.Handle(); + } + } }); } @@ -109,6 +142,33 @@ await _projectService.CreateProject( public AsyncReactiveCommand Create { get; } + public ReactivePropertySlim TrackHistory { get; } = new(); + + public ReactivePropertySlim IsGitAvailable { get; } = new(); + + private async Task DetectGitAsync() + { + try + { + GitAvailability availability = await _versionControlInitializer.GetAvailabilityAsync( + CancellationToken.None); + bool isAvailable = availability.State == GitAvailabilityState.Installed; + TrackHistory.Value = isAvailable + && GlobalConfiguration.Instance.VersionControlConfig.EnableForNewProjects; + IsGitAvailable.Value = isAvailable; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + TrackHistory.Value = false; + IsGitAvailable.Value = false; + _logger.LogWarning(ex, "Failed to detect Git while creating a project."); + } + } + private static string GetDefaultLocation() { ViewConfig config = GlobalConfiguration.Instance.ViewConfig; diff --git a/src/Beutl/ViewModels/Dialogs/GitIdentityDialogViewModel.cs b/src/Beutl/ViewModels/Dialogs/GitIdentityDialogViewModel.cs new file mode 100644 index 0000000000..d242739b8a --- /dev/null +++ b/src/Beutl/ViewModels/Dialogs/GitIdentityDialogViewModel.cs @@ -0,0 +1,33 @@ +using Beutl.Editor.VersionControl; +using Reactive.Bindings; + +namespace Beutl.ViewModels.Dialogs; + +public sealed class GitIdentityDialogViewModel +{ + public GitIdentityDialogViewModel() + { + Name.Value = Environment.UserName; + CanSave = Name.CombineLatest( + Email, + static (name, email) => + !string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(email)) + .ToReadOnlyReactivePropertySlim(); + } + + public ReactivePropertySlim Name { get; } = new(); + + public ReactivePropertySlim Email { get; } = new(); + + public ReadOnlyReactivePropertySlim CanSave { get; } + + public GitIdentity CreateIdentity() + { + if (!CanSave.Value) + { + throw new InvalidOperationException("A Git user name and email address are required."); + } + + return new GitIdentity(Name.Value.Trim(), Email.Value.Trim()); + } +} diff --git a/src/Beutl/ViewModels/EditContext/ElementAdderImpl.cs b/src/Beutl/ViewModels/EditContext/ElementAdderImpl.cs index 9da8fa9c99..b810d5aa98 100644 --- a/src/Beutl/ViewModels/EditContext/ElementAdderImpl.cs +++ b/src/Beutl/ViewModels/EditContext/ElementAdderImpl.cs @@ -32,6 +32,11 @@ public void AddElement(ElementDescription desc) _logger.LogInformation("Adding new element with description: {Description}", desc); Scene scene = _context.Scene; + if (!EnsureSceneIsSaved(scene)) + { + return; + } + if (scene.IsLayerLocked(desc.Layer)) { NotificationService.ShowWarning(Strings.Lock, Strings.LayerIsLocked); @@ -42,13 +47,14 @@ Element CreateElement() { _logger.LogDebug("Creating new element with start: {Start}, length: {Length}, layer: {Layer}", desc.Start, desc.Length, desc.Layer); - return new Element() + var element = new Element { Start = desc.Start, Length = desc.Length, ZIndex = desc.Layer, - Uri = RandomFileNameGenerator.GenerateUri(scene.Uri!, EditorConstants.ElementFileExtension) }; + element.Uri = ElementFileNaming.GetUri(scene.Uri!, element.Id); + return element; } void SetAccentColor(Element element, string str) @@ -256,6 +262,11 @@ public void AddElementFromTemplate(ObjectTemplateItem template, TimeSpan start, _logger.LogInformation("Adding element from template: {TemplateName}", template.Name.Value); Scene scene = _context.Scene; + if (!EnsureSceneIsSaved(scene)) + { + return; + } + if (scene.IsLayerLocked(layer)) { NotificationService.ShowWarning(Strings.Lock, Strings.LayerIsLocked); @@ -295,7 +306,7 @@ public void AddElementFromTemplate(ObjectTemplateItem template, TimeSpan start, return; } - newElement.Uri = RandomFileNameGenerator.GenerateUri(scene.Uri!, EditorConstants.ElementFileExtension); + newElement.Uri = ElementFileNaming.GetUri(scene.Uri!, newElement.Id); CoreSerializer.StoreToUri(newElement, newElement.Uri!); scene.AddChild(newElement); @@ -307,6 +318,20 @@ public void AddElementFromTemplate(ObjectTemplateItem template, TimeSpan start, _logger.LogInformation("Element from template added successfully."); } + private bool EnsureSceneIsSaved(Scene scene) + { + if (scene.Uri is not null) + { + return true; + } + + _logger.LogWarning("Cannot add an element before the scene is saved."); + NotificationService.ShowWarning( + Strings.File, + Strings.ElementAdder_ProjectNotSaved); + return false; + } + private static bool MatchFileExtensions(string filePath, IEnumerable extensions) { string ext = Path.GetExtension(filePath); diff --git a/src/Beutl/ViewModels/EditViewModel.cs b/src/Beutl/ViewModels/EditViewModel.cs index f88be11ce6..8207159638 100644 --- a/src/Beutl/ViewModels/EditViewModel.cs +++ b/src/Beutl/ViewModels/EditViewModel.cs @@ -9,6 +9,7 @@ using Beutl.Editor; using Beutl.Editor.Observers; using Beutl.Editor.Operations; +using Beutl.Editor.VersionControl; using Beutl.Graphics.Rendering.Cache; using Beutl.Helpers; using Beutl.Logging; @@ -31,6 +32,7 @@ public sealed partial class EditViewModel : IEditorContext, ISupportAutoSaveEdit { private readonly ILogger _logger = Log.CreateLogger(); private readonly AutoSaveService _autoSaveService = new(); + private readonly CancellationTokenSource _autoSaveCancellation = new(); private readonly HistoryMutationPlaybackGuard _historyMutationPlaybackGuard = new(); private readonly CompositeDisposable _disposables = []; @@ -393,18 +395,28 @@ private void OnChangeOperations(IList list) private void AutoSave(IList list) { - Dispatcher.UIThread.InvokeAsync(() => + Dispatcher.UIThread.InvokeAsync(async () => { - _autoSaveService.AutoSave(list); - - // ビューステートを保存 try { + using IDisposable fileWrite = + await EditorService.BeginProjectFileWriteAsync( + _autoSaveCancellation.Token); + if (_disposed) + { + return; + } + + _autoSaveService.AutoSave(list); SaveState(); } + catch (OperationCanceledException) + when (_autoSaveCancellation.IsCancellationRequested) + { + } catch (Exception ex) { - _logger.LogError(ex, "An exception occurred while saving the view state."); + _logger.LogError(ex, "An exception occurred while auto-saving the editor state."); } }); } @@ -594,8 +606,20 @@ public async ValueTask DisposeAsync() // Block any proxy-invalidation flush already posted to the UI thread from running after this // nulls Scene / disposes FrameCacheManager below. _disposed = true; + _autoSaveCancellation.Cancel(); GlobalConfiguration.Instance.EditorConfig.PropertyChanged -= OnEditorConfigPropertyChanged; - SaveState(); + if (!EditorService.IsWorktreeMutationActive) + { + using IDisposable fileWrite = await EditorService.BeginProjectFileWriteAsync( + CancellationToken.None); + SaveState(); + } + else + { + _logger.LogDebug( + "Skipping the final view-state save during a worktree mutation ({SceneId}).", + SceneId); + } _editorSelection.SelectedObject.Value = null; // Player を破棄する前にイベント購読を外し、Subject 破棄後の OnNext を抑止する。 DisposeCommandStateNotifier(); @@ -910,6 +934,15 @@ private void QuarantineCorruptViewState(string viewStateFile) if (serviceType == typeof(HistoryManager)) return HistoryManager; + if (serviceType == typeof(IProjectVersionControlService)) + return EditorService.ProjectVersionControlService.Value; + + if (serviceType == typeof(IReadOnlyReactiveProperty)) + return EditorService.ProjectVersionControlService; + + if (serviceType == typeof(IProjectVersionControlCoordinator)) + return EditorService.ProjectVersionControlCoordinator; + if (serviceType.IsAssignableTo(typeof(ITimelineOptionsProvider))) return _timelineOptionsProvider; @@ -1113,7 +1146,6 @@ public ValueTask OnSave() { viewModel._logger.LogInformation("Saving scene ({SceneId}).", scene.Id); CoreSerializer.StoreToUri(scene, scene.Uri!); - Parallel.ForEach(scene.Children, item => CoreSerializer.StoreToUri(item, item.Uri!)); viewModel.SaveState(isExplicitUserSave: true); viewModel._logger.LogInformation("Scene ({SceneId}) saved successfully.", scene.Id); diff --git a/src/Beutl/ViewModels/EditorHostViewModel.cs b/src/Beutl/ViewModels/EditorHostViewModel.cs index 14e52f83b3..ab07ef0e87 100644 --- a/src/Beutl/ViewModels/EditorHostViewModel.cs +++ b/src/Beutl/ViewModels/EditorHostViewModel.cs @@ -7,31 +7,99 @@ namespace Beutl.ViewModels; -public class EditorHostViewModel +public class EditorHostViewModel : IDisposable, IAsyncDisposable { private readonly ILogger _logger = Log.CreateLogger(); private readonly ProjectService _projectService; private readonly EditorService _editorService; + private readonly object _lifetimeGate = new(); + private readonly TaskCompletionSource _asyncDisposalCompletion = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private Project? _subscribedProject; + private TaskCompletionSource? _operationsDrained; + private int _ownedOperations; + private int _asyncDisposalStarted; + private bool _disposeRequested; public EditorHostViewModel(ProjectService projectService, EditorService editorService) { _projectService = projectService; _editorService = editorService; - _projectService.ProjectObservable.Subscribe(item => DispatchProjectChange(item.New, item.Old)); + _projectService.Closing += OnProjectClosingAsync; + _projectService.Opened += OnProjectOpenedAsync; } - private void DispatchProjectChange(Project? @new, Project? old) + private Task OnProjectClosingAsync( + ProjectService.ProjectCloseContext closeContext, + CancellationToken _) + { + return RunOwnedOperationAsync(async () => + { + Project? project = _projectService.CurrentProject.Value; + CoreObject? selectedObject = _editorService.SelectedTabItem.Value?.Context.Value?.Object; + if (project is not null) + { + closeContext.RegisterCompletion(projectClosed => + RestoreAfterAbortedCloseAsync(project, selectedObject, projectClosed)); + } + + await DispatchProjectChangeAsync(null, project); + }); + } + + private Task OnProjectOpenedAsync(Project project) + { + return RunOwnedOperationAsync(() => DispatchProjectChangeAsync(project, null)); + } + + private async Task DispatchProjectChangeAsync(Project? @new, Project? old) { - // Run inline when already on the UI thread so callers awaiting OpenProject - // (e.g. RestoreLastProjectTask) still see tabs populated on return. Post only - // when the notification comes from a background thread. if (Dispatcher.UIThread.CheckAccess()) { - _ = OnProjectChangedAsync(@new, old); + await OnProjectChangedAsync(@new, old); } else { - Dispatcher.UIThread.Post(() => _ = OnProjectChangedAsync(@new, old)); + await Dispatcher.UIThread.InvokeAsync(() => OnProjectChangedAsync(@new, old)); + } + } + + private Task RestoreAfterAbortedCloseAsync( + Project project, + CoreObject? selectedObject, + bool projectClosed) + { + if (projectClosed || !ReferenceEquals(_projectService.CurrentProject.Value, project)) + { + return Task.CompletedTask; + } + + return RunOwnedOperationAsync(() => DispatchProjectRestoreAsync(project, selectedObject)); + } + + private async Task DispatchProjectRestoreAsync(Project project, CoreObject? selectedObject) + { + if (Dispatcher.UIThread.CheckAccess()) + { + await RestoreProjectAsync(project, selectedObject); + } + else + { + await Dispatcher.UIThread.InvokeAsync(() => RestoreProjectAsync(project, selectedObject)); + } + } + + private async Task RestoreProjectAsync(Project project, CoreObject? selectedObject) + { + if (!ReferenceEquals(_projectService.CurrentProject.Value, project)) + { + return; + } + + await OnProjectChangedAsync(project, null); + if (selectedObject is ProjectItem selectedItem && project.Items.Contains(selectedItem)) + { + _editorService.ActivateTabItem(selectedItem); } } @@ -44,16 +112,17 @@ private async Task OnProjectChangedAsync(Project? @new, Project? old) { try { + _editorService.SelectedTabItem.Value = null; _editorService.TabItems.Clear(); if (old != null) { - old.Items.CollectionChanged -= Project_Items_CollectionChanged; + UnsubscribeFromProject(old); } if (@new != null) { - @new.Items.CollectionChanged += Project_Items_CollectionChanged; + SubscribeToProject(@new); foreach (ProjectItem item in @new.Items) { _editorService.ActivateTabItem(item); @@ -82,6 +151,7 @@ private async Task OnProjectChangedAsync(Project? @new, Project? old) } catch (OperationCanceledException) { + throw; } catch (Exception ex) { @@ -92,12 +162,155 @@ private async Task OnProjectChangedAsync(Project? @new, Project? old) SafeLocalPath(old?.Uri), SafeLocalPath(@new?.Uri)); NotificationService.ShowError(Strings.Project, MessageStrings.OperationFailed); + throw; } } private void Project_Items_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { - _ = HandleProjectItemsChangedAsync(e); + _ = RunOwnedOperationAsync(() => HandleProjectItemsChangedAsync(e)); + } + + public void Dispose() + { + _ = ObserveDisposalAsync(StartDisposal()); + } + + public ValueTask DisposeAsync() + { + return new ValueTask(StartDisposal()); + } + + private Task StartDisposal() + { + Task operationsDrained = BeginDispose(); + if (Interlocked.CompareExchange(ref _asyncDisposalStarted, 1, 0) == 0) + { + _ = CompleteDisposalAsync(operationsDrained); + } + + return _asyncDisposalCompletion.Task; + } + + private static async Task ObserveDisposalAsync(Task disposal) + { + try + { + await disposal; + } + catch + { + // CompleteDisposalAsync already logged the failure. This observes it for sync Dispose. + } + } + + private async Task CompleteDisposalAsync(Task operationsDrained) + { + try + { + await operationsDrained; + await DispatchProjectChangeAsync(null, _projectService.CurrentProject.Value); + _asyncDisposalCompletion.TrySetResult(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to dispose the editor host."); + _asyncDisposalCompletion.TrySetException(ex); + } + } + + private Task RunOwnedOperationAsync(Func operation) + { + lock (_lifetimeGate) + { + if (_disposeRequested) + { + return Task.CompletedTask; + } + + _ownedOperations++; + } + + return CompleteOwnedOperationAsync(operation); + } + + private async Task CompleteOwnedOperationAsync(Func operation) + { + try + { + await operation(); + } + finally + { + TaskCompletionSource? operationsDrained = null; + lock (_lifetimeGate) + { + _ownedOperations--; + if (_disposeRequested && _ownedOperations == 0) + { + operationsDrained = _operationsDrained; + } + } + + operationsDrained?.TrySetResult(); + } + } + + private Task BeginDispose() + { + lock (_lifetimeGate) + { + if (!_disposeRequested) + { + _disposeRequested = true; + _projectService.Closing -= OnProjectClosingAsync; + _projectService.Opened -= OnProjectOpenedAsync; + if (_subscribedProject is { } project) + { + project.Items.CollectionChanged -= Project_Items_CollectionChanged; + _subscribedProject = null; + } + } + + if (_ownedOperations == 0) + { + return Task.CompletedTask; + } + + return (_operationsDrained ??= new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously)).Task; + } + } + + private void SubscribeToProject(Project project) + { + lock (_lifetimeGate) + { + if (_disposeRequested || ReferenceEquals(_subscribedProject, project)) + { + return; + } + + if (_subscribedProject is { } previous) + { + previous.Items.CollectionChanged -= Project_Items_CollectionChanged; + } + + project.Items.CollectionChanged += Project_Items_CollectionChanged; + _subscribedProject = project; + } + } + + private void UnsubscribeFromProject(Project project) + { + lock (_lifetimeGate) + { + if (ReferenceEquals(_subscribedProject, project)) + { + project.Items.CollectionChanged -= Project_Items_CollectionChanged; + _subscribedProject = null; + } + } } private async Task HandleProjectItemsChangedAsync(NotifyCollectionChangedEventArgs e) diff --git a/src/Beutl/ViewModels/ExtensionsPages/PackageOperationHandler.cs b/src/Beutl/ViewModels/ExtensionsPages/PackageOperationHandler.cs index aa1e229bbe..06963fc300 100644 --- a/src/Beutl/ViewModels/ExtensionsPages/PackageOperationHandler.cs +++ b/src/Beutl/ViewModels/ExtensionsPages/PackageOperationHandler.cs @@ -1,6 +1,7 @@ using Beutl.Api; using Beutl.Api.Objects; using Beutl.Api.Services; +using Beutl.Editor.VersionControl; using Beutl.Logging; using Beutl.Serialization; using Beutl.Services; @@ -197,35 +198,69 @@ public async Task EnsureProjectClosed() if (result == ContentDialogResult.Secondary) { - await SaveAll(); - _projectService.CloseProject(); + // "Save and close" must not close on a failed save: closing would discard exactly the + // edits that could not be written. + if (!await SaveAll()) + { + return false; + } + + await _projectService.CloseProject(); return true; } if (result == ContentDialogResult.Primary) { - _projectService.CloseProject(); + await _projectService.CloseProject(); return true; } return false; } - private async Task SaveAll() + private async Task SaveAll() { - Project? project = _projectService.CurrentProject.Value; - if (project != null) + using IProjectFileWriteLease fileWrite = await _editorService.BeginProjectFileWriteAsync( + CancellationToken.None); + if (_projectService.CurrentProject.Value is { } project) { CoreSerializer.StoreToUri(project, project.Uri!); } - foreach (EditorTabItem item in _editorService.TabItems) + // Every editor is saved independently rather than through SaveProjectFilesAsync: the caller + // closes the project right after this, so one editor that cannot save must not discard the + // edits of the ones that follow it. + bool allSaved = true; + foreach (EditorTabItem item in _editorService.TabItems.ToArray()) { - if (item.Commands.Value != null) + try { - await item.Commands.Value.OnSave(); + if (item.Commands.Value is { } commands && !await commands.OnSave()) + { + allSaved = false; + s_logger.LogWarning( + "{FileName} could not be saved before the package operation closed the project.", + item.FileName.Value); + } } + catch (Exception ex) + { + allSaved = false; + s_logger.LogError( + ex, + "{FileName} threw while being saved before the package operation closed the project.", + item.FileName.Value); + } + } + + if (!allSaved) + { + NotificationService.ShowError( + ExtensionsStrings.PackageInstaller, + MessageStrings.UnableToSaveFile); } + + return allSaved; } public void Cancel(string packageName) diff --git a/src/Beutl/ViewModels/MainViewModel.cs b/src/Beutl/ViewModels/MainViewModel.cs index 9dd1ef217c..54b671ff76 100644 --- a/src/Beutl/ViewModels/MainViewModel.cs +++ b/src/Beutl/ViewModels/MainViewModel.cs @@ -4,6 +4,7 @@ using Beutl.AgentHost; using Beutl.Api; using Beutl.Api.Services; +using Beutl.Editor.Components.VersionControl.ViewModels; using Beutl.Helpers; using Beutl.Logging; using Beutl.Services; @@ -17,29 +18,46 @@ namespace Beutl.ViewModels; -public sealed class MainViewModel : BasePageViewModel, IContextCommandHandler +public sealed class MainViewModel : BasePageViewModel, IContextCommandHandler, IAsyncDisposable { internal readonly BeutlApiApplication _beutlClients; private readonly HttpClient _authHttpClient; private readonly ProjectService _projectService; private readonly EditorService _editorService; + private readonly VersionControlCoordinator _versionControlCoordinator; private readonly ExtensionProvider _extensionProvider; private readonly AgentHostEndpoint _agentHostEndpoint; private readonly ILogger _logger = Log.CreateLogger(); + private readonly object _shutdownGate = new(); + private readonly TaskCompletionSource _disposalCompletion = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private Task? _shutdownTask; + private int _disposed; public MainViewModel() + : this(static (projectService, editorService) => + new AgentHostEndpoint(projectService, editorService)) { + } + + internal MainViewModel( + Func agentHostFactory) + { + ArgumentNullException.ThrowIfNull(agentHostFactory); + _authHttpClient = new HttpClient(); // Composition root: own the editor-session services here and thread the instances // down to child view models and services. _extensionProvider = new ExtensionProvider(); _projectService = new ProjectService(); _editorService = new EditorService(_extensionProvider); - _agentHostEndpoint = new AgentHostEndpoint(_projectService, _editorService); + ProxyMediaServices.Current?.BindWorkspaceOperations(_editorService); + _versionControlCoordinator = new VersionControlCoordinator(_projectService, _editorService); + _agentHostEndpoint = agentHostFactory(_projectService, _editorService); _beutlClients = new BeutlApiApplication(_authHttpClient, _extensionProvider); ContextCommandManager = _beutlClients.GetResource(); - MenuBar = new MenuBarViewModel(_projectService, _editorService); + MenuBar = new MenuBarViewModel(_projectService, _editorService, _versionControlCoordinator); IsProjectOpened = _projectService.IsOpened; NameOfOpenProject = _projectService.CurrentProject.Select(v => @@ -48,6 +66,10 @@ public MainViewModel() WindowTitle = NameOfOpenProject.Select(v => string.IsNullOrWhiteSpace(v) ? "Beutl" : $"Beutl - {v}") .ToReadOnlyReactivePropertySlim("Beutl"); TitleBreadcrumbBar = new TitleBreadcrumbBarViewModel(this, _editorService); + TitleBarBranch = new TitleBarBranchViewModel( + _editorService.ProjectVersionControlService, + _versionControlCoordinator.IsGitAvailable, + _versionControlCoordinator); EditorHost = new EditorHostViewModel(_projectService, _editorService); @@ -98,6 +120,8 @@ public MainViewModel() public TitleBreadcrumbBarViewModel TitleBreadcrumbBar { get; } + internal TitleBarBranchViewModel TitleBarBranch { get; } + public EditorHostViewModel EditorHost { get; } // Exposed so views bound to this composition root (MainView, MacWindow) can read the @@ -106,6 +130,8 @@ public MainViewModel() internal EditorService EditorService => _editorService; + internal VersionControlCoordinator VersionControlCoordinator => _versionControlCoordinator; + internal ExtensionProvider ExtensionProvider => _extensionProvider; internal AgentHostEndpoint AgentHostEndpoint => _agentHostEndpoint; @@ -146,24 +172,230 @@ public void RegisterServices() _agentHostEndpoint.StartInBackground(); } + internal bool IsDisposed => Volatile.Read(ref _disposed) != 0; + public override void Dispose() { - CommandPalette.Dispose(); - _agentHostEndpoint.RequestStop(); - _projectService.CloseProject(); - BeutlApplication.Current.Items.Clear(); + _ = ShutdownAsync(); + _ = ObserveShutdownCompletionAsync(); } - private void OnExit(object? sender, ControlledApplicationLifetimeExitEventArgs e) + public ValueTask DisposeAsync() { + return new ValueTask(ShutdownAsync()); + } + + private void BeginCompositionDisposal() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + _agentHostEndpoint.RequestStop(); + _ = CompleteDisposalAsync(); + } + + private async Task CompleteDisposalAsync() + { + var failures = new List(); try { - ProxyMediaServices.Current?.DisposeAsync().AsTask().GetAwaiter().GetResult(); + CommandPalette.Dispose(); } catch (Exception ex) { - _logger.LogWarning(ex, "Proxy media services failed to dispose during shutdown."); + failures.Add(ex); + } + + try + { + TitleBarBranch.Dispose(); + } + catch (Exception ex) + { + failures.Add(ex); + } + + try + { + await _agentHostEndpoint.DisposeAsync(); + } + catch (Exception ex) + { + failures.Add(ex); + } + + try + { + await Task.WhenAll( + EditorHost.DisposeAsync().AsTask(), + _versionControlCoordinator.DisposeAsync().AsTask()); + } + catch (Exception ex) + { + failures.Add(ex); + } + + try + { + BeutlApplication.Current.Items.Clear(); + } + catch (Exception ex) + { + failures.Add(ex); + } + + if (failures.Count == 0) + { + _disposalCompletion.TrySetResult(); + } + else + { + _disposalCompletion.TrySetException( + failures.Count == 1 ? failures[0] : new AggregateException(failures)); + } + } + + private async Task ObserveShutdownCompletionAsync() + { + try + { + await ShutdownAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to shut down the main view-model composition root."); + } + } + + internal Task ShutdownAsync(CancellationToken cancellationToken = default) + { + lock (_shutdownGate) + { + return _shutdownTask ??= ShutdownCoreAsync(cancellationToken); + } + } + + private async Task ShutdownCoreAsync(CancellationToken cancellationToken) + { + // Publish the single-flight task before a synchronous Closing handler can re-enter shutdown. + await Task.Yield(); + AgentHostEndpoint.AgentHostShutdownScope hostScope; + try + { + hostScope = await _agentHostEndpoint.BeginShutdownDrainAsync(cancellationToken); + } + catch + { + _projectService.ClearShutdownRequest(); + lock (_shutdownGate) + { + _shutdownTask = null; + } + + throw; + } + + ProjectCloseAbortedException? closeAborted = null; + OperationCanceledException? shutdownCanceled = null; + await using (hostScope) + { + _projectService.RequestShutdown(); + + try + { + try + { + await using ProjectService.ProjectTransitionScope transition = + await _projectService.BeginShutdownTransitionAsync(this, cancellationToken); + using IDisposable editorSuspension = _editorService.SuspendEditors(); + await transition.CloseProjectAsync(cancellationToken); + } + catch (ProjectCloseAbortedException ex) + { + // The close abandoned itself so the unsaved edits stay in their editors. Disposing + // the composition anyway would discard exactly what it protected, so the shutdown + // stops here and the application keeps running. + closeAborted = ex; + _logger.LogWarning( + ex, + "Application shutdown was abandoned because the open project could not be closed."); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // CloseProjectAsync awaits completion callbacks while unwinding, so this is safe + // to retry without committing the host shutdown scope. + shutdownCanceled = new OperationCanceledException(cancellationToken); + _logger.LogInformation("Application shutdown was canceled before the project closed."); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to close the project during application shutdown."); + } + + if (closeAborted is null + && shutdownCanceled is null + && ProxyMediaServices.Current is { } proxyMediaServices) + { + Task disposalTask = proxyMediaServices.DisposeAsync().AsTask(); + try + { + await disposalTask; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Proxy media services failed to dispose during shutdown."); + } + } + } + finally + { + if (closeAborted is null && shutdownCanceled is null) + { + hostScope.Commit(); + BeginCompositionDisposal(); + await _disposalCompletion.Task; + } + } + + if (closeAborted is not null || shutdownCanceled is not null) + { + // Shutdown is otherwise terminal, so the request has to be cleared for the user to save + // and close again; the shutdown task is dropped for the same reason. + _projectService.ClearShutdownRequest(); + } + } + + if (closeAborted is not null) + { + lock (_shutdownGate) + { + _shutdownTask = null; + } + + throw closeAborted; + } + + if (shutdownCanceled is not null) + { + lock (_shutdownGate) + { + _shutdownTask = null; + } + + throw shutdownCanceled; + } + } + + private void OnExit(object? sender, ControlledApplicationLifetimeExitEventArgs e) + { + _agentHostEndpoint.RequestStop(); + if (ProxyMediaServices.Current is { } proxyMediaServices) + { + // The window pipeline normally drains this before closing. If the deadline expired (or + // another lifetime initiated exit), make a best-effort start without blocking the UI thread. + _ = DisposeProxyMediaServicesAfterExitAsync(proxyMediaServices); } PackageChangesQueue queue = _beutlClients.GetResource(); @@ -202,6 +434,18 @@ private void OnExit(object? sender, ControlledApplicationLifetimeExitEventArgs e } } + private async Task DisposeProxyMediaServicesAfterExitAsync(ProxyMediaServices proxyMediaServices) + { + try + { + await proxyMediaServices.DisposeAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Proxy media services failed to dispose during shutdown."); + } + } + public void Execute(ContextCommandExecution execution) { if (execution.KeyEventArgs != null) @@ -216,7 +460,16 @@ public void Execute(ContextCommandExecution execution) if (MenuBar.FindContextCommand(execution.CommandName) is { } command) { if (command.CanExecute(null)) - command.Execute(null); + { + // MenuBar resolves to ReactiveCommandSlim or AsyncReactiveCommand only. The async + // one returns from Execute as soon as its handler yields, so the awaitable overload + // is what lets a caller keep a progress indicator up for the whole operation. + if (command is AsyncReactiveCommand asyncCommand) + execution.Completion = asyncCommand.ExecuteAsync(null!); + else + command.Execute(null); + } + return; } diff --git a/src/Beutl/ViewModels/MenuBarViewModel.Files.cs b/src/Beutl/ViewModels/MenuBarViewModel.Files.cs index 6f321623c5..dbdda2e5e9 100644 --- a/src/Beutl/ViewModels/MenuBarViewModel.Files.cs +++ b/src/Beutl/ViewModels/MenuBarViewModel.Files.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using Beutl.Configuration; +using Beutl.Editor.VersionControl; using Beutl.Serialization; using Beutl.Services; using Microsoft.Extensions.Logging; @@ -9,7 +10,17 @@ namespace Beutl.ViewModels; public partial class MenuBarViewModel { - [MemberNotNull(nameof(CloseFile), nameof(CloseProject), nameof(Save), nameof(SaveAll), nameof(ExportProject))] + private TaskCompletionSource _closeProjectCompletion = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + [MemberNotNull( + nameof(CloseFile), + nameof(CloseProject), + nameof(Save), + nameof(SaveAll), + nameof(EnableVersionControl), + nameof(CommitVersion), + nameof(ExportProject))] private void InitializeFilesCommands() { CloseFile = new ReactiveCommandSlim(_editorService.SelectedTabItem.Select(i => i != null)) @@ -18,8 +29,8 @@ private void InitializeFilesCommands() CloseFileCore = new ReactiveCommandSlim() .WithSubscribe(OnCloseFileCore); - CloseProject = new ReactiveCommandSlim(IsProjectOpened) - .WithSubscribe(_projectService.CloseProject); + CloseProject = new AsyncReactiveCommand(IsProjectOpened) + .WithSubscribe(CloseProjectAsync); Save = new AsyncReactiveCommand(IsProjectOpened) .WithSubscribe(OnSave); @@ -27,6 +38,19 @@ private void InitializeFilesCommands() SaveAll = new AsyncReactiveCommand(IsProjectOpened) .WithSubscribe(OnSaveAll); + IObservable canEnableVersionControl = IsProjectOpened.CombineLatest( + _versionControlSession.IsGitAvailable, + _versionControlSession.IsTracked, + static (isOpened, isGitAvailable, isTracked) => + isOpened && isGitAvailable && !isTracked); + EnableVersionControl = new AsyncReactiveCommand(canEnableVersionControl); + IObservable canCommitVersion = IsProjectOpened.CombineLatest( + _versionControlSession.IsGitAvailable, + _versionControlSession.IsTracked, + static (isOpened, isGitAvailable, isTracked) => + isOpened && isGitAvailable && isTracked); + CommitVersion = new AsyncReactiveCommand(canCommitVersion); + ExportProject = new AsyncReactiveCommand(IsProjectOpened); ViewConfig viewConfig = GlobalConfiguration.Instance.ViewConfig; @@ -44,14 +68,7 @@ private void InitializeFilesCommands() OpenRecentProject.Subscribe(async file => { - if (!File.Exists(file)) - { - NotificationService.ShowInformation(Strings.File, MessageStrings.FileDoesNotExist); - } - else - { - await _projectService.OpenProject(file); - } + await _projectService.OpenProject(file); }); } @@ -81,12 +98,18 @@ private void InitializeFilesCommands() public ReactiveCommandSlim CloseFile { get; private set; } - public ReactiveCommandSlim CloseProject { get; private set; } + public AsyncReactiveCommand CloseProject { get; private set; } + + internal Task CloseProjectCompletion => _closeProjectCompletion.Task; public AsyncReactiveCommand Save { get; private set; } public AsyncReactiveCommand SaveAll { get; private set; } + public AsyncReactiveCommand EnableVersionControl { get; private set; } + + public AsyncReactiveCommand CommitVersion { get; private set; } + public ReactiveCommandSlim OpenRecentFile { get; } = new(); public AsyncReactiveCommand OpenRecentProject { get; } = new(); @@ -101,14 +124,48 @@ private void InitializeFilesCommands() public AsyncReactiveCommand ImportProject { get; } = new(); + private async Task CloseProjectAsync() + { + TaskCompletionSource completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + _closeProjectCompletion = completion; + bool handled = false; + try + { + await _projectService.CloseProject(); + handled = true; + } + catch (ProjectCloseAbortedException) + { + handled = true; + } + catch (Exception ex) + { + handled = true; + _logger.LogError(ex, "Failed to close the project."); + NotificationService.ShowError(string.Empty, MessageStrings.OperationFailed); + } + finally + { + if (handled) + { + completion.TrySetResult(); + } + } + } + private async Task OnSaveAll() { using Activity? activity = Telemetry.StartActivity("SaveAll"); - Project? project = _projectService.CurrentProject.Value; int itemsCount = 0; + bool allRequestedSavesSucceeded = true; try { + using IProjectFileWriteLease fileWrite = await _editorService.BeginProjectFileWriteAsync( + CancellationToken.None); + // Waiting for the lease can span a whole version-control transition, which closes the + // project and reopens a new instance, so nothing may be captured before the wait. + Project? project = _projectService.CurrentProject.Value; if (project != null) { CoreSerializer.StoreToUri(project, project.Uri!); @@ -116,19 +173,20 @@ private async Task OnSaveAll() itemsCount++; - foreach (EditorTabItem? item in _editorService.TabItems) + // Each OnSave yields to the dispatcher, which can close a tab, so the live list is + // snapshotted rather than enumerated across the awaits. + foreach (EditorTabItem item in _editorService.TabItems.ToArray()) { - if (item.Commands.Value != null) + if (item.Commands.Value is { } commands) { - if (await item.Commands.Value.OnSave()) + if (await commands.OnSave()) { itemsCount++; } else { - Type type = item.Extension.Value.GetType(); - _logger.LogError("{Extension} failed to save file: {FileName}", type.FullName ?? type.Name, - item.FileName.Value); + allRequestedSavesSucceeded = false; + LogFailedSave(item); NotificationService.ShowError(MessageStrings.UnableToSaveFile, item.FileName.Value); } } @@ -141,6 +199,11 @@ private async Task OnSaveAll() { NotificationService.ShowInformation(string.Empty, MessageStrings.FilesAutoSaved); } + + if (allRequestedSavesSucceeded) + { + await _versionControlSession.NotifySavedAsync(fileWrite); + } } catch (Exception ex) { @@ -157,40 +220,59 @@ private async Task OnSaveAll() private async Task OnSave() { using Activity? activity = Telemetry.StartActivity("Save"); - EditorTabItem? item = _editorService.SelectedTabItem.Value; - if (item != null) + if (_editorService.SelectedTabItem.Value == null) { - try + return; + } + + EditorTabItem? item = null; + try + { + using IProjectFileWriteLease fileWrite = await _editorService.BeginProjectFileWriteAsync( + CancellationToken.None); + // The tab captured before the wait may have been disposed by a version-control + // transition, so the save targets whichever tab is selected once the lease is held. + item = _editorService.SelectedTabItem.Value; + if (item == null) { - bool result = await (item.Commands.Value == null - ? ValueTask.FromResult(false) - : item.Commands.Value.OnSave()); + return; + } - if (result) - { - NotificationService.ShowSuccess(string.Empty, string.Format(MessageStrings.ItemSaved, item.FileName)); + bool result = item.Commands.Value is { } commands && await commands.OnSave(); + if (result) + { + NotificationService.ShowSuccess(string.Empty, string.Format(MessageStrings.ItemSaved, item.FileName.Value)); - if (GlobalConfiguration.Instance.EditorConfig.IsAutoSaveEnabled - && item.Context.Value is ISupportAutoSaveEditorContext) - { - NotificationService.ShowInformation(string.Empty, MessageStrings.FilesAutoSaved); - } - } - else + if (GlobalConfiguration.Instance.EditorConfig.IsAutoSaveEnabled + && item.Context.Value is ISupportAutoSaveEditorContext) { - Type type = item.Extension.Value.GetType(); - _logger.LogError("{Extension} failed to save file: {FileName}", type.FullName ?? type.Name, - item.FileName.Value); - NotificationService.ShowInformation(string.Empty, MessageStrings.OperationFailed); + NotificationService.ShowInformation(string.Empty, MessageStrings.FilesAutoSaved); } + + await _versionControlSession.NotifySavedAsync(fileWrite); } - catch (Exception ex) + else { - activity?.SetStatus(ActivityStatusCode.Error); - _logger.LogError(ex, "Failed to save file: {FileName}", item.FileName.Value); - NotificationService.ShowError(string.Empty, MessageStrings.OperationFailed); + LogFailedSave(item); + NotificationService.ShowInformation(string.Empty, MessageStrings.OperationFailed); } } + catch (Exception ex) + { + activity?.SetStatus(ActivityStatusCode.Error); + _logger.LogError(ex, "Failed to save file: {FileName}", item?.FileName.Value); + NotificationService.ShowError(string.Empty, MessageStrings.OperationFailed); + } + } + + private void LogFailedSave(EditorTabItem item) + { + // Extension is typed non-nullable but is a projection of Context, which tab teardown nulls. + Type? type = item.Extension.Value?.GetType(); + _logger.LogError( + "{Extension} failed to save file: {FileName}", + type?.FullName ?? type?.Name ?? "(unknown)", + item.FileName.Value); } internal void OpenFileCore(string file) diff --git a/src/Beutl/ViewModels/MenuBarViewModel.Palette.cs b/src/Beutl/ViewModels/MenuBarViewModel.Palette.cs index 7d77c41e2d..a8078419f9 100644 --- a/src/Beutl/ViewModels/MenuBarViewModel.Palette.cs +++ b/src/Beutl/ViewModels/MenuBarViewModel.Palette.cs @@ -30,6 +30,8 @@ public IEnumerable EnumeratePaletteCommands() "OpenFile" => OpenFile, "Save" => Save, "SaveAll" => SaveAll, + "EnableVersionControl" => EnableVersionControl, + "CommitVersion" => CommitVersion, "CloseProject" => CloseProject, "Undo" => Undo, "Redo" => Redo, diff --git a/src/Beutl/ViewModels/MenuBarViewModel.cs b/src/Beutl/ViewModels/MenuBarViewModel.cs index 14da1da197..be2ab05657 100644 --- a/src/Beutl/ViewModels/MenuBarViewModel.cs +++ b/src/Beutl/ViewModels/MenuBarViewModel.cs @@ -1,4 +1,5 @@ -using Beutl.Logging; +using Beutl.Editor.VersionControl; +using Beutl.Logging; using Beutl.Services; using Microsoft.Extensions.Logging; @@ -12,25 +13,27 @@ public sealed partial class MenuBarViewModel private readonly ILogger _logger = Log.CreateLogger(); private readonly ProjectService _projectService; private readonly EditorService _editorService; + private readonly IProjectVersionControlSession _versionControlSession; #pragma warning disable CS8618 - public MenuBarViewModel(ProjectService projectService, EditorService editorService) + public MenuBarViewModel( + ProjectService projectService, + EditorService editorService, + IProjectVersionControlSession versionControlSession) { - _projectService = projectService; - _editorService = editorService; + _projectService = projectService ?? throw new ArgumentNullException(nameof(projectService)); + _editorService = editorService ?? throw new ArgumentNullException(nameof(editorService)); + _versionControlSession = versionControlSession + ?? throw new ArgumentNullException(nameof(versionControlSession)); IsProjectOpened = _projectService.IsOpened; IObservable isSceneOpened = _editorService.SelectedTabItem .SelectMany(i => i?.Context ?? Observable.Empty()) .Select(v => v is EditViewModel); - Parallel.Invoke( - () => InitializeFilesCommands(), - () => InitializeSceneCommands(isSceneOpened), - () => InitializeViewCommands(isSceneOpened)); - - //InitializeFilesCommands(); - //InitializeSceneCommands(isSceneOpened); + InitializeFilesCommands(); + InitializeSceneCommands(isSceneOpened); + InitializeViewCommands(isSceneOpened); Undo = new AsyncReactiveCommand(IsProjectOpened) .WithSubscribe(OnUndo); diff --git a/src/Beutl/ViewModels/SettingsPages/EditorSettingsPageViewModel.cs b/src/Beutl/ViewModels/SettingsPages/EditorSettingsPageViewModel.cs index 0cd53858a4..142548df2d 100644 --- a/src/Beutl/ViewModels/SettingsPages/EditorSettingsPageViewModel.cs +++ b/src/Beutl/ViewModels/SettingsPages/EditorSettingsPageViewModel.cs @@ -11,6 +11,7 @@ public sealed class EditorSettingsPageViewModel : IDisposable private readonly EditorConfig _editorConfig; private readonly GraphicsConfig _graphicsConfig; private readonly ProxyStoreConfig _proxyStoreConfig; + private readonly VersionControlConfig _versionControlConfig; private readonly CompositeDisposable _disposables = []; public EditorSettingsPageViewModel() @@ -19,6 +20,7 @@ public EditorSettingsPageViewModel() _editorConfig = GlobalConfiguration.Instance.EditorConfig; _graphicsConfig = GlobalConfiguration.Instance.GraphicsConfig; _proxyStoreConfig = GlobalConfiguration.Instance.ProxyStoreConfig; + _versionControlConfig = GlobalConfiguration.Instance.VersionControlConfig; AutoAdjustSceneDuration = _editorConfig.GetObservable(EditorConfig.AutoAdjustSceneDurationProperty) .ToReactiveProperty() @@ -186,6 +188,69 @@ public EditorSettingsPageViewModel() ProxyDefaultPreset.Subscribe(preset => _proxyStoreConfig.DefaultPreset = (int)preset) .DisposeWith(_disposables); + EnableVersionControlForNewProjects = _versionControlConfig + .GetObservable(VersionControlConfig.EnableForNewProjectsProperty) + .ToReactiveProperty() + .DisposeWith(_disposables); + EnableVersionControlForNewProjects.Subscribe( + value => _versionControlConfig.EnableForNewProjects = value) + .DisposeWith(_disposables); + + AutoCommitOnSave = _versionControlConfig + .GetObservable(VersionControlConfig.AutoCommitOnSaveProperty) + .ToReactiveProperty() + .DisposeWith(_disposables); + AutoCommitOnSave.Subscribe(value => _versionControlConfig.AutoCommitOnSave = value) + .DisposeWith(_disposables); + + AutoCommitOnClose = _versionControlConfig + .GetObservable(VersionControlConfig.AutoCommitOnCloseProperty) + .ToReactiveProperty() + .DisposeWith(_disposables); + AutoCommitOnClose.Subscribe(value => _versionControlConfig.AutoCommitOnClose = value) + .DisposeWith(_disposables); + + GitExecutablePath = _versionControlConfig + .GetObservable(VersionControlConfig.GitExecutablePathProperty) + .Select(static value => value ?? string.Empty) + .ToReactiveProperty() + .DisposeWith(_disposables); + GitExecutablePath.Subscribe(value => + { + string? normalized = string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + if (_versionControlConfig.GitExecutablePath != normalized) + { + _versionControlConfig.GitExecutablePath = normalized; + } + }) + .DisposeWith(_disposables); + + UseLfsWhenAvailable = _versionControlConfig + .GetObservable(VersionControlConfig.UseLfsWhenAvailableProperty) + .ToReactiveProperty() + .DisposeWith(_disposables); + UseLfsWhenAvailable.Subscribe(value => _versionControlConfig.UseLfsWhenAvailable = value) + .DisposeWith(_disposables); + + LargeMediaWarningThresholdMb = _versionControlConfig + .GetObservable(VersionControlConfig.LargeMediaWarningThresholdMbProperty) + .ToReactiveProperty() + .DisposeWith(_disposables); + LargeMediaWarningThresholdMb.Subscribe(value => + { + int normalized = Math.Max(1, value); + if (_versionControlConfig.LargeMediaWarningThresholdMb != normalized) + { + _versionControlConfig.LargeMediaWarningThresholdMb = normalized; + } + + if (LargeMediaWarningThresholdMb.Value != normalized) + { + LargeMediaWarningThresholdMb.Value = normalized; + } + }) + .DisposeWith(_disposables); + // GPU selection InitializeGpuSelection(); } @@ -263,6 +328,18 @@ private void InitializeGpuSelection() public ReactiveProperty ProxyDefaultPreset { get; } + public ReactiveProperty EnableVersionControlForNewProjects { get; } + + public ReactiveProperty AutoCommitOnSave { get; } + + public ReactiveProperty AutoCommitOnClose { get; } + + public ReactiveProperty GitExecutablePath { get; } + + public ReactiveProperty UseLfsWhenAvailable { get; } + + public ReactiveProperty LargeMediaWarningThresholdMb { get; } + public IReadOnlyList ProxyPresetOptions { get; } = Enum.GetValues(); public IReadOnlyList AvailableGpus { get; private set; } = []; diff --git a/src/Beutl/ViewModels/Tools/OutputTabViewModel.cs b/src/Beutl/ViewModels/Tools/OutputTabViewModel.cs index b6dabf85eb..b913901a12 100644 --- a/src/Beutl/ViewModels/Tools/OutputTabViewModel.cs +++ b/src/Beutl/ViewModels/Tools/OutputTabViewModel.cs @@ -61,8 +61,20 @@ public void RemoveItem(OutputProfileItem item) { _logger.LogInformation("Removing item: {ItemName}", item.Context.Name.Value); int index = Items.IndexOf(item); - Items.Remove(item); - item.Dispose(); + if (index < 0) + { + _logger.LogWarning("The output profile is not part of this tab."); + return; + } + + if (!item.TryDisposeIfIdle()) + { + _logger.LogWarning("Cannot remove an output profile while it is encoding: {ItemName}", + item.Context.Name.Value); + return; + } + + Items.RemoveAt(index); if (Items.Count > 0) { if (index < Items.Count) diff --git a/src/Beutl/ViewModels/Tools/OutputViewModel.cs b/src/Beutl/ViewModels/Tools/OutputViewModel.cs index 315210a4eb..fb7f87185c 100644 --- a/src/Beutl/ViewModels/Tools/OutputViewModel.cs +++ b/src/Beutl/ViewModels/Tools/OutputViewModel.cs @@ -35,9 +35,16 @@ public sealed class OutputViewModel : IOutputContext, ISupportOutputPreset private CancellationTokenSource? _lastCts; private string? _activeDestination; - public OutputViewModel(EditViewModel editViewModel) + private readonly IOutputOperationLeaseProvider _outputOperations; + + public OutputViewModel( + EditViewModel editViewModel, + IOutputOperationLeaseProvider outputOperations) { + ArgumentNullException.ThrowIfNull(outputOperations); + _editViewModel = editViewModel; + _outputOperations = outputOperations; Model = editViewModel.Scene; Controller = SelectedEncoder .CombineLatest(DestinationFile) @@ -224,6 +231,16 @@ static string[] ToPatterns(ControllableEncodingExtension encoder) public async Task StartEncode() { + using IDisposable? outputOperation = _outputOperations.TryBeginOutputOperation(); + if (outputOperation is null) + { + ProgressText.Value = Strings.VersionControl_WorktreeOperationInProgress; + NotificationService.ShowInformation( + Strings.VersionControl, + Strings.VersionControl_WorktreeOperationInProgress); + return; + } + // Defensive re-check: reject if supersampled surface cannot be allocated. if (SupersampleWarning.Value is { } supersampleWarning) { @@ -280,7 +297,6 @@ public async Task StartEncode() FrameProgressText.Value = "0 / 0"; _activeDestination = DestinationFile.Value; Started?.Invoke(this, EventArgs.Empty); - stopwatch.Start(); await Task.Run(async () => diff --git a/src/Beutl/Views/Dialogs/CreateNewProject.axaml b/src/Beutl/Views/Dialogs/CreateNewProject.axaml index b4bc704a20..fadb5ba692 100644 --- a/src/Beutl/Views/Dialogs/CreateNewProject.axaml +++ b/src/Beutl/Views/Dialogs/CreateNewProject.axaml @@ -65,6 +65,11 @@ Text="Hz" /> + + diff --git a/src/Beutl/Views/MacWindow.axaml b/src/Beutl/Views/MacWindow.axaml index 5f7c340694..816811475b 100644 --- a/src/Beutl/Views/MacWindow.axaml +++ b/src/Beutl/Views/MacWindow.axaml @@ -12,6 +12,7 @@ Title="{Binding WindowTitle.Value}" d:DesignHeight="720" d:DesignWidth="1280" + x:CompileBindings="True" x:DataType="vm:MainViewModel" Background="{DynamicResource MainWindowBackground}" Icon="avares://Beutl.Controls/Assets/logo.png" diff --git a/src/Beutl/Views/MacWindow.axaml.cs b/src/Beutl/Views/MacWindow.axaml.cs index a357af00f5..0baf680210 100644 --- a/src/Beutl/Views/MacWindow.axaml.cs +++ b/src/Beutl/Views/MacWindow.axaml.cs @@ -1,20 +1,26 @@ using System.Collections.ObjectModel; +using System.Runtime.ExceptionServices; using System.Windows.Input; using Avalonia; using Avalonia.Controls; using Avalonia.Platform; using Beutl.Configuration; +using Beutl.Language; +using Beutl.Logging; using Beutl.Services; using Beutl.ViewModels; using DynamicData; using DynamicData.Binding; +using Microsoft.Extensions.Logging; using Reactive.Bindings.Extensions; namespace Beutl.Views; public sealed partial class MacWindow : Window { + private static readonly ILogger s_logger = Log.CreateLogger(); private readonly Dictionary> _openToolWindows = new(); + private readonly WindowShutdownCoordinator _shutdown; public MacWindow() { @@ -27,6 +33,7 @@ public MacWindow() } InitializeComponent(); + _shutdown = new WindowShutdownCoordinator(ShutdownAsync, Close); ViewConfig viewConfig = GlobalConfiguration.Instance.ViewConfig; (int X, int Y)? pos = viewConfig.WindowPosition; (int Width, int Height)? size = viewConfig.WindowSize; @@ -128,7 +135,16 @@ void RemoveItem(NativeMenu list, string item) } } - private void InitExtMenuItems(MainViewModel viewModel) + // Resolved by header rather than position: a menu edit used to shift these silently, and the + // catch below then dropped every extension entry instead of surfacing anything. + internal static NativeMenuItem? FindMenuItem(NativeMenu? menu, string header) + { + return menu?.Items + .OfType() + .FirstOrDefault(item => string.Equals(item.Header, header, StringComparison.Ordinal)); + } + + internal void InitExtMenuItems(MainViewModel viewModel) { NativeMenuItem? viewMenuItem = null; NativeMenu? editorTabMenu = null; @@ -137,13 +153,12 @@ private void InitExtMenuItems(MainViewModel viewModel) NativeMenu? dockLayoutPresetMenu = null; try { - var rootMenu = NativeMenu.GetMenu(this)!; - viewMenuItem = (NativeMenuItem)rootMenu.Items[2]; - editorTabMenu = ((NativeMenuItem)viewMenuItem.Menu!.Items[0]).Menu; - toolTabMenu = ((NativeMenuItem)viewMenuItem.Menu!.Items[1]).Menu; - toolWindowMenu = ((NativeMenuItem)rootMenu.Items[3]).Menu; - // View > ... > "Apply dock layout" (see MacWindow.axaml). - dockLayoutPresetMenu = ((NativeMenuItem)viewMenuItem.Menu!.Items[^2]).Menu; + NativeMenu rootMenu = NativeMenu.GetMenu(this)!; + viewMenuItem = FindMenuItem(rootMenu, Strings.View); + editorTabMenu = FindMenuItem(viewMenuItem?.Menu, Strings.Editors)?.Menu; + toolTabMenu = FindMenuItem(viewMenuItem?.Menu, Strings.Tools)?.Menu; + toolWindowMenu = FindMenuItem(rootMenu, Strings.Tools)?.Menu; + dockLayoutPresetMenu = FindMenuItem(viewMenuItem?.Menu, Strings.ApplyDockLayout)?.Menu; } catch { @@ -204,33 +219,23 @@ NativeMenuItem CreateEditorMenuItem(EditorExtension item) menuItem.Click += async (s, e) => { - EditorTabItem? selectedTab = viewModel.EditorService.SelectedTabItem.Value; - if (s is NativeMenuItem { CommandParameter: EditorExtension editorExtension } menuItem - && selectedTab != null) + if (s is not NativeMenuItem { CommandParameter: EditorExtension editorExtension }) { - IKnownEditorCommands? commands = selectedTab.Commands.Value; - if (commands != null) - { - await commands.OnSave(); - } + return; + } - if (editorExtension.TryCreateContext( - selectedTab.Context.Value.Object, - new EditorContextServices(viewModel.EditorService, viewModel.ExtensionProvider), - out IEditorContext? context)) - { - selectedTab.Context.Value.Dispose(); - selectedTab.Context.Value = context; - } - else - { - NotificationService.ShowInformation( - title: MessageStrings.ContextNotCreated, - message: string.Format( - format: MessageStrings.FailedToOpenFileWithExtension, - arg0: editorExtension.DisplayName, - arg1: selectedTab.FileName.Value)); - } + // An unhandled exception in an async void handler terminates the process. + try + { + await viewModel.EditorService.SwitchEditorExtensionAsync(editorExtension); + } + catch (Exception ex) + { + s_logger.LogError( + ex, + "Failed to switch to the {Extension} editor.", + editorExtension.DisplayName); + NotificationService.ShowError(string.Empty, MessageStrings.OperationFailed); } }; @@ -411,27 +416,13 @@ private async Task OpenToolWindowAsync(ToolWindowExtension extension) } } - private bool _captureStopped; - private Task? _captureStopTask; - protected override void OnClosing(WindowClosingEventArgs e) { - if (!_captureStopped) + if (!_shutdown.CanClose) { - if (_captureStopTask is not null) - { - // A shutdown task is already draining ffmpeg from a prior close - // attempt; keep cancelling until that task finalizes and calls Close(). - e.Cancel = true; - return; - } - - if (mainView is { HasActiveCapture: true } mv) - { - e.Cancel = true; - _captureStopTask = StopCaptureAndCloseAsync(mv); - return; - } + e.Cancel = true; + _ = _shutdown.BeginShutdownAsync(); + return; } base.OnClosing(e); @@ -439,18 +430,28 @@ protected override void OnClosing(WindowClosingEventArgs e) viewConfig.WindowSize = ((int)ClientSize.Width, (int)ClientSize.Height); viewConfig.WindowPosition = (Position.X, Position.Y); viewConfig.IsWindowMaximized = WindowState == WindowState.Maximized; + } + + private async Task ShutdownAsync(CancellationToken cancellationToken) + { + // See MainWindow.ShutdownAsync: a capture that refuses to stop must not skip the agent-host + // drain, the project close and its version-control snapshot, or the composition-root dispose. + ExceptionDispatchInfo? captureFailure = null; + try + { + await mainView.EnsureCaptureStoppedAsync(); + } + catch (Exception ex) + { + captureFailure = ExceptionDispatchInfo.Capture(ex); + } if (DataContext is MainViewModel viewModel) { - viewModel.Dispose(); + await viewModel.ShutdownAsync(cancellationToken); } - } - private async Task StopCaptureAndCloseAsync(MainView mv) - { - await mv.EnsureCaptureStoppedAsync(); - _captureStopped = true; - Close(); + captureFailure?.Throw(); } private async void OpenTutorialsDialog(object? sender, EventArgs e) => await mainView.ShowTutorialsDialogAsync(); diff --git a/src/Beutl/Views/MainView.axaml b/src/Beutl/Views/MainView.axaml index a26c7476a4..b479e099f0 100644 --- a/src/Beutl/Views/MainView.axaml +++ b/src/Beutl/Views/MainView.axaml @@ -15,6 +15,7 @@ Padding="0" d:DesignHeight="450" d:DesignWidth="800" + x:CompileBindings="True" x:DataType="vm:MainViewModel" Focusable="True" mc:Ignorable="d"> @@ -219,17 +220,24 @@ - + + + + - { var dialog = new CreateNewProject(); - dialog.DataContext = new CreateNewProjectViewModel(viewModel.ProjectService); + dialog.DataContext = new CreateNewProjectViewModel( + viewModel.ProjectService, + viewModel.VersionControlCoordinator, + RequestGitIdentityAsync); await dialog.ShowAsync(); }).AddTo(_disposables); viewModel.MenuBar.OpenProject.Subscribe(OnOpenProject).AddTo(_disposables); viewModel.MenuBar.OpenFile.Subscribe(OnOpenFile).AddTo(_disposables); + viewModel.MenuBar.EnableVersionControl.Subscribe( + () => EnableVersionControlAsync(viewModel)).AddTo(_disposables); + viewModel.MenuBar.CommitVersion.Subscribe( + () => CommitVersionAsync(viewModel)).AddTo(_disposables); viewModel.MenuBar.RemoveFromProject.Subscribe(OnRemoveFromProject).AddTo(_disposables); @@ -124,6 +135,93 @@ void DisposeMenuItem(MenuItem menuItem) .DisposeWith(_disposables); } + private async Task EnableVersionControlAsync(MainViewModel viewModel) + { + try + { + GitAvailability availability = await viewModel.VersionControlCoordinator.GetAvailabilityAsync(); + if (availability.State != GitAvailabilityState.Installed) + { + return; + } + + Project project = viewModel.ProjectService.CurrentProject.Value + ?? throw new InvalidOperationException("No project is open."); + await viewModel.VersionControlCoordinator.InitializeCurrentProjectAsync( + project, + RequestGitIdentityAsync); + } + catch (Exception ex) + { + await ex.Handle(); + } + } + + private async Task RequestGitIdentityAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var viewModel = new GitIdentityDialogViewModel(); + var flyout = new VersionControlPickerFlyout(); + VersionControlIdentityInput? input = await flyout.ShowIdentityAsync( + GetVersionControlFlyoutAnchor(), + Strings.VersionControl_IdentityTitle, + Strings.VersionControl_IdentityName, + Strings.VersionControl_IdentityEmail, + viewModel.Name.Value, + viewModel.Email.Value, + cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + if (input is not { } identity) + { + return null; + } + + viewModel.Name.Value = identity.Name; + viewModel.Email.Value = identity.Email; + return viewModel.CreateIdentity(); + } + + private async Task CommitVersionAsync(MainViewModel viewModel) + { + var flyout = new VersionControlPickerFlyout(); + string? message = await flyout.ShowTextInputAsync( + GetVersionControlFlyoutAnchor(), + Strings.VersionControl_Commit, + Strings.VersionControl_CommitMessage, + initialText: null); + if (string.IsNullOrWhiteSpace(message)) + { + return; + } + + try + { + CommitResult result = await viewModel.VersionControlCoordinator.CommitManualAsync( + message.Trim()); + NotificationService.ShowInformation( + Strings.VersionControl, + result is CommitResult.NoChanges + ? Strings.VersionControl_NothingToCommit + : Strings.VersionControl_CommitCreated); + } + catch (GitIdentityRequiredException) + { + } + catch (Exception ex) + { + await ex.Handle(); + } + } + + private Control GetVersionControlFlyoutAnchor() + { + Control? focused = + TopLevel.GetTopLevel(this)?.FocusManager?.GetFocusedElement() as Control; + return focused is not MenuItem && focused?.IsAttachedToVisualTree() == true + ? focused + : this; + } + private void InitializeRecentItems(MainViewModel viewModel) { void AddItem(AvaloniaList list, string item, ICommand command) @@ -319,6 +417,30 @@ private async Task OnExportProject() IStorageFile? file = await window.StorageProvider.SaveFilePickerAsync(options); if (file?.TryGetLocalPath() is string outputPath) { + // The reservation is taken only once a destination is chosen: holding it across the + // picker makes a project close skip its Git snapshot for as long as the dialog is open. + // It covers project-file writes too, so the copy never reads a half-written save. + using IDisposable? outputOperation = + await exportVm.EditorService.TryBeginProjectDirectoryReadAsync( + CancellationToken.None); + if (outputOperation is null) + { + NotificationService.ShowWarning( + Strings.ExportProject, + Strings.VersionControl_WorktreeOperationInProgress); + return; + } + + // The picker can span a project close and reopen, so the export targets whatever is + // open now rather than the instance captured before the dialog. + if (!ReferenceEquals(exportVm.ProjectService.CurrentProject.Value, project)) + { + NotificationService.ShowWarning( + Strings.ExportProject, + MessageStrings.OperationFailed); + return; + } + try { ExportResult result = await ProjectPackageService.Current.ExportAsync( diff --git a/src/Beutl/Views/MainView.axaml.cs b/src/Beutl/Views/MainView.axaml.cs index 28e185a722..84f638fe65 100644 --- a/src/Beutl/Views/MainView.axaml.cs +++ b/src/Beutl/Views/MainView.axaml.cs @@ -2,6 +2,7 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Interactivity; +using Avalonia.VisualTree; using Beutl.AgentToolkit.Installation; using Beutl.Configuration; using Beutl.Language; @@ -56,6 +57,13 @@ public MainView() Titlebar.PointerPressed += (s, e) => { + if (e.Source is Visual source + && source.FindAncestorOfType( + includeSelf: true) is not null) + { + return; + } + if (TopLevel.GetTopLevel(this) is Window window && window.WindowState != WindowState.FullScreen) { if (e.ClickCount == 2) @@ -110,6 +118,7 @@ private async void OnParentWindowOpened(object? sender, EventArgs e) Titlebar.Margin = new Thickness(0, 0, titleBar.LeftInset, 0); AppWindow.SetAllowInteractionInTitleBar(MenuBar, true); + AppWindow.SetAllowInteractionInTitleBar(TitleBarBranchWidget, true); AppWindow.SetAllowInteractionInTitleBar(OpenNotificationsButton, true); NotificationPanel.Margin = new(0, titleBar.Height + 8, 8, 0); } @@ -245,33 +254,23 @@ MenuItem CreateEditorMenuItem(EditorExtension item) menuItem.Click += async (s, e) => { - EditorTabItem? selectedTab = viewModel.EditorService.SelectedTabItem.Value; - if (s is MenuItem { DataContext: EditorExtension editorExtension } menuItem - && selectedTab != null) + if (s is not MenuItem { DataContext: EditorExtension editorExtension }) { - IKnownEditorCommands? commands = selectedTab.Commands.Value; - if (commands != null) - { - await commands.OnSave(); - } + return; + } - if (editorExtension.TryCreateContext( - selectedTab.Context.Value.Object, - new EditorContextServices(viewModel.EditorService, viewModel.ExtensionProvider), - out IEditorContext? context)) - { - selectedTab.Context.Value.Dispose(); - selectedTab.Context.Value = context; - } - else - { - NotificationService.ShowInformation( - title: MessageStrings.ContextNotCreated, - message: string.Format( - format: MessageStrings.FailedToOpenFileWithExtension, - arg0: editorExtension.DisplayName, - arg1: selectedTab.FileName.Value)); - } + // An unhandled exception in an async void handler terminates the process. + try + { + await viewModel.EditorService.SwitchEditorExtensionAsync(editorExtension); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Failed to switch to the {Extension} editor.", + editorExtension.DisplayName); + NotificationService.ShowError(string.Empty, MessageStrings.OperationFailed); } }; diff --git a/src/Beutl/Views/MainWindow.axaml.cs b/src/Beutl/Views/MainWindow.axaml.cs index 289c57e2db..786364378b 100644 --- a/src/Beutl/Views/MainWindow.axaml.cs +++ b/src/Beutl/Views/MainWindow.axaml.cs @@ -1,4 +1,5 @@ -using Avalonia; +using System.Runtime.ExceptionServices; +using Avalonia; using Avalonia.Controls; using Avalonia.Platform; @@ -11,9 +12,12 @@ namespace Beutl.Views; public sealed partial class MainWindow : AppWindow { + private readonly WindowShutdownCoordinator _shutdown; + public MainWindow() { InitializeComponent(); + _shutdown = new WindowShutdownCoordinator(ShutdownAsync, Close); ViewConfig viewConfig = GlobalConfiguration.Instance.ViewConfig; (int X, int Y)? pos = viewConfig.WindowPosition; (int Width, int Height)? size = viewConfig.WindowSize; @@ -62,27 +66,13 @@ protected override void OnOpened(EventArgs e) mainView.Focus(); } - private bool _captureStopped; - private Task? _captureStopTask; - protected override void OnClosing(WindowClosingEventArgs e) { - if (!_captureStopped) + if (!_shutdown.CanClose) { - if (_captureStopTask is not null) - { - // A shutdown task is already draining ffmpeg from a prior close - // attempt; keep cancelling until that task finalizes and calls Close(). - e.Cancel = true; - return; - } - - if (mainView is { HasActiveCapture: true } mv) - { - e.Cancel = true; - _captureStopTask = StopCaptureAndCloseAsync(mv); - return; - } + e.Cancel = true; + _ = _shutdown.BeginShutdownAsync(); + return; } base.OnClosing(e); @@ -90,17 +80,28 @@ protected override void OnClosing(WindowClosingEventArgs e) viewConfig.WindowSize = ((int)ClientSize.Width, (int)ClientSize.Height); viewConfig.WindowPosition = (Position.X, Position.Y); viewConfig.IsWindowMaximized = WindowState == WindowState.Maximized; + } + + private async Task ShutdownAsync(CancellationToken cancellationToken) + { + // The rest of shutdown drains the agent host, closes the project (which takes its + // version-control snapshot) and disposes the composition root. A capture that refuses to + // stop must not cost all of that, so its failure is carried past the remaining cleanup. + ExceptionDispatchInfo? captureFailure = null; + try + { + await mainView.EnsureCaptureStoppedAsync(); + } + catch (Exception ex) + { + captureFailure = ExceptionDispatchInfo.Capture(ex); + } if (DataContext is MainViewModel viewModel) { - viewModel.Dispose(); + await viewModel.ShutdownAsync(cancellationToken); } - } - private async Task StopCaptureAndCloseAsync(MainView mv) - { - await mv.EnsureCaptureStoppedAsync(); - _captureStopped = true; - Close(); + captureFailure?.Throw(); } } diff --git a/src/Beutl/Views/TitleBarBranchView.axaml b/src/Beutl/Views/TitleBarBranchView.axaml new file mode 100644 index 0000000000..7a63f3dfba --- /dev/null +++ b/src/Beutl/Views/TitleBarBranchView.axaml @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Beutl/Views/TitleBarBranchView.axaml.cs b/src/Beutl/Views/TitleBarBranchView.axaml.cs new file mode 100644 index 0000000000..e78ed7c268 --- /dev/null +++ b/src/Beutl/Views/TitleBarBranchView.axaml.cs @@ -0,0 +1,84 @@ +using Avalonia.Controls; +using Avalonia.Interactivity; +using Beutl.Editor.Components.VersionControl.ViewModels; +using Beutl.Editor.Components.VersionControl.Views; +using Beutl.Language; +using Beutl.Services; + +namespace Beutl.Views; + +public sealed partial class TitleBarBranchView : UserControl +{ + internal VersionControlPickerFlyout PromptFlyout { get; } = new(); + + public TitleBarBranchView() + { + InitializeComponent(); + } + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + if (DataContext is TitleBarBranchViewModel viewModel) + { + viewModel.RequestNewBranchNameAsync = ShowNewBranchFlyoutAsync; + } + } + + private async void OnBranchFlyoutOpening( + object? sender, + EventArgs e) + { + await HandleBranchFlyoutOpeningAsync(); + } + + internal async Task HandleBranchFlyoutOpeningAsync() + { + try + { + if (DataContext is TitleBarBranchViewModel viewModel) + { + await viewModel.PrepareFlyoutAsync(); + } + } + catch (Exception ex) + { + await ex.Handle(); + } + } + + private async void OnBranchClick(object? sender, RoutedEventArgs e) + { + await HandleBranchClickAsync(sender); + } + + internal async Task HandleBranchClickAsync(object? sender) + { + try + { + if (DataContext is TitleBarBranchViewModel viewModel + && sender is Button + { + DataContext: TitleBarBranchItemViewModel branch, + }) + { + TitleBarBranchButton.Flyout?.Hide(); + await viewModel.SwitchBranchAsync(branch.Name); + } + } + catch (Exception ex) + { + await ex.Handle(); + } + } + + private Task ShowNewBranchFlyoutAsync() + { + TitleBarBranchButton.Flyout?.Hide(); + return PromptFlyout.ShowTextInputAsync( + TitleBarBranchButton, + Strings.VersionControl_NewBranch, + Strings.VersionControl_BranchName, + initialText: null); + } +} diff --git a/src/Beutl/Views/TitleBreadcrumbBar.axaml b/src/Beutl/Views/TitleBreadcrumbBar.axaml index 5d9a0c5be7..d92c7100b6 100644 --- a/src/Beutl/Views/TitleBreadcrumbBar.axaml +++ b/src/Beutl/Views/TitleBreadcrumbBar.axaml @@ -15,25 +15,6 @@ x:DataType="viewModels:TitleBreadcrumbBarViewModel" mc:Ignorable="d"> - - - - - - - - - - - - - - - @@ -50,16 +31,16 @@ Content="{Binding FileName.Value}" Theme="{StaticResource LiteButtonStyle}"> - +