From 0586243ae64a907e7ea3435a2017b14503dca39e Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Mon, 7 Sep 2026 22:33:32 +0900 Subject: [PATCH 01/10] feat(version-control)!: add Git project history Add opt-in Git-backed snapshots, history, diffs, restore, branches, and fast-forward remote synchronization to editing projects. Keep repository mutation scoped to the project and isolate review-derived helpers inside the version-control implementation. BREAKING-CHANGE: Beutl.Core now preserves loaded project appVersion and minAppVersion until explicit migration, and its shared JsonHelper and CoreSerializer output uses LF line endings for projects, configuration, editor state, and every other consumer. Windows consumers may observe one-time newline diffs. --- .specify/feature.json | 2 +- CLAUDE.md | 1 + Directory.Packages.props | 1 + README.md | 3 + .../checklists/requirements.md | 35 + .../contracts/coordinator-lifecycle.md | 79 + .../contracts/git-cli-invocation.md | 63 + .../contracts/version-control-service.md | 63 + .../005-project-git-versioning/data-model.md | 142 + docs/specs/005-project-git-versioning/plan.md | 146 + .../005-project-git-versioning/quickstart.md | 50 + .../005-project-git-versioning/research.md | 141 + docs/specs/005-project-git-versioning/spec.md | 285 + .../specs/005-project-git-versioning/tasks.md | 162 + .../GlobalConfiguration.cs | 9 + .../VersionControlConfig.cs | 86 + src/Beutl.Controls/Styles.axaml | 16 + src/Beutl.Core/JsonHelper.cs | 2 + src/Beutl.Core/Project.cs | 30 +- .../Properties/AssemblyInfo.cs | 1 + .../ViewModels/TitleBarBranchViewModel.cs | 636 + .../Views/VersionControlPickerFlyout.cs | 330 + .../ViewModels/VersionControlPrimaryAction.cs | 18 + .../ViewModels/VersionControlTabViewModel.cs | 2365 +++ .../Views/VersionControlChangesView.axaml | 98 + .../Views/VersionControlChangesView.axaml.cs | 79 + .../VersionControlDetailHeaderView.axaml | 33 + .../VersionControlDetailHeaderView.axaml.cs | 11 + .../Views/VersionControlHistoryView.axaml | 84 + .../Views/VersionControlHistoryView.axaml.cs | 64 + .../Views/VersionControlTabLayout.cs | 11 + .../Views/VersionControlTabView.axaml | 267 + .../Views/VersionControlTabView.axaml.cs | 145 + src/Beutl.Editor/AutoSaveService.cs | 1 - src/Beutl.Editor/Beutl.Editor.csproj | 2 + src/Beutl.Editor/Services/DuplicateHelper.cs | 2 +- .../Services/ElementClipboardService.cs | 4 +- .../Services/ElementFileNaming.cs | 25 + .../Services/ElementStructureService.cs | 2 +- .../VersionControl/AtomicFileExchange.cs | 122 + .../VersionControl/GitCliRunner.cs | 1421 ++ .../GitCliVersionControlService.cs | 12114 ++++++++++++++++ .../VersionControl/GitInstallationLocator.cs | 533 + .../VersionControl/IProjectFileWriteLease.cs | 7 + .../IProjectVersionControlCoordinator.cs | 48 + .../IProjectVersionControlInitializer.cs | 11 + .../IProjectVersionControlService.cs | 221 + .../IProjectVersionControlSession.cs | 25 + .../ProjectConflictMarkerScanner.cs | 565 + .../VersionControl/RepositoryWatcher.cs | 725 + .../VersionControl/SerializedProjectGraph.cs | 56 + .../VersionControl/VersionControlModels.cs | 534 + .../VersionControlPathComparison.cs | 345 + .../VersionControlSerializationGraph.cs | 2081 +++ src/Beutl.Language/SettingsStrings.ja.resx | 39 + src/Beutl.Language/SettingsStrings.resx | 39 + src/Beutl.Language/Strings.ja.resx | 337 + src/Beutl.Language/Strings.resx | 337 + .../ProjectSystem/Scene.cs | 21 +- .../SettingsPages/EditorSettingsPage.axaml | 61 + src/Beutl/Services/EditorService.cs | 507 + src/Beutl/Services/OutputService.cs | 16 + .../PrimitiveImpls/MainViewExtension.cs | 2 + .../VersionControlTabExtension.cs | 63 + .../Services/ProjectCloseAbortedException.cs | 8 + src/Beutl/Services/ProjectService.cs | 843 +- .../LoadPrimitiveExtensionTask.cs | 1 + .../Services/VersionControlCoordinator.cs | 6908 +++++++++ .../Dialogs/CreateNewProjectViewModel.cs | 76 +- .../Dialogs/GitIdentityDialogViewModel.cs | 33 + .../EditContext/ElementAdderImpl.cs | 31 +- src/Beutl/ViewModels/EditViewModel.cs | 46 +- src/Beutl/ViewModels/MainViewModel.cs | 15 +- .../ViewModels/MenuBarViewModel.Files.cs | 168 +- .../ViewModels/MenuBarViewModel.Palette.cs | 2 + src/Beutl/ViewModels/MenuBarViewModel.cs | 25 +- .../EditorSettingsPageViewModel.cs | 77 + .../Views/Dialogs/CreateNewProject.axaml | 5 + src/Beutl/Views/MacWindow.axaml | 1 + src/Beutl/Views/MacWindow.axaml.cs | 4 +- src/Beutl/Views/MainView.axaml | 18 +- .../Views/MainView.axaml.InitializeMenuBar.cs | 127 +- src/Beutl/Views/MainView.axaml.cs | 9 + src/Beutl/Views/TitleBarBranchView.axaml | 132 + src/Beutl/Views/TitleBarBranchView.axaml.cs | 84 + src/Beutl/Views/TitleBreadcrumbBar.axaml | 25 +- .../CreateNewProjectDialogTests.cs | 487 +- .../EditorServiceTests.cs | 406 + .../EditorSettingsPageViewModelTests.cs | 57 + .../EditorWorkflowTests.cs | 29 + .../Beutl.HeadlessUITests/OpenProjectTests.cs | 162 +- .../Beutl.HeadlessUITests/ShellSmokeTests.cs | 2 +- tests/Beutl.HeadlessUITests/TestReset.cs | 2 +- tests/Beutl.HeadlessUITests/TestShell.cs | 2 + .../VersionControlConflictTests.cs | 65 + .../VersionControlRestoreTests.cs | 11425 +++++++++++++++ .../VersionControlSaveTests.cs | 1151 ++ .../VersionControlTabViewTests.cs | 1262 ++ .../VersionControlConfigTests.cs | 66 + .../Editor/AutoSaveServiceTests.cs | 31 +- .../Editor/ElementFileNamingTests.cs | 55 + .../VersionControl/GitCliRunnerTests.cs | 1702 +++ .../GitCliVersionControlServiceTests.cs | 8352 +++++++++++ .../GitInstallationLocatorTests.cs | 711 + .../VersionControl/NestedRepositoryTests.cs | 2037 +++ .../ProjectConflictMarkerScannerTests.cs | 636 + .../VersionControl/RealGitTestRepository.cs | 163 + .../VersionControl/RemoteOperationsTests.cs | 3726 +++++ .../RepositoryWatcherStressTests.cs | 278 + .../VersionControl/RepositoryWatcherTests.cs | 663 + .../TitleBarBranchViewModelTests.cs | 671 + .../VersionControlCommandSurfaceTests.cs | 96 + .../VersionControlModelsTests.cs | 270 + .../VersionControlPerformanceTests.cs | 100 + .../VersionControlPolicyTests.cs | 1075 ++ .../VersionControlSnapshotScopeTests.cs | 975 ++ .../VersionControlTabLayoutTests.cs | 24 + .../VersionControlTabViewModelTests.cs | 2935 ++++ .../VersionControlTabViewTests.cs | 111 + .../Language/VersionControlStringsTests.cs | 100 + .../NoMigrationRegressionTests.cs | 89 +- .../ProjectSystem/SceneTests.cs | 79 + 122 files changed, 74199 insertions(+), 158 deletions(-) create mode 100644 docs/specs/005-project-git-versioning/checklists/requirements.md create mode 100644 docs/specs/005-project-git-versioning/contracts/coordinator-lifecycle.md create mode 100644 docs/specs/005-project-git-versioning/contracts/git-cli-invocation.md create mode 100644 docs/specs/005-project-git-versioning/contracts/version-control-service.md create mode 100644 docs/specs/005-project-git-versioning/data-model.md create mode 100644 docs/specs/005-project-git-versioning/plan.md create mode 100644 docs/specs/005-project-git-versioning/quickstart.md create mode 100644 docs/specs/005-project-git-versioning/research.md create mode 100644 docs/specs/005-project-git-versioning/spec.md create mode 100644 docs/specs/005-project-git-versioning/tasks.md create mode 100644 src/Beutl.Configuration/VersionControlConfig.cs create mode 100644 src/Beutl.Editor.Components/VersionControl/ViewModels/TitleBarBranchViewModel.cs create mode 100644 src/Beutl.Editor.Components/VersionControl/Views/VersionControlPickerFlyout.cs create mode 100644 src/Beutl.Editor.Components/VersionControlTab/ViewModels/VersionControlPrimaryAction.cs create mode 100644 src/Beutl.Editor.Components/VersionControlTab/ViewModels/VersionControlTabViewModel.cs create mode 100644 src/Beutl.Editor.Components/VersionControlTab/Views/VersionControlChangesView.axaml create mode 100644 src/Beutl.Editor.Components/VersionControlTab/Views/VersionControlChangesView.axaml.cs create mode 100644 src/Beutl.Editor.Components/VersionControlTab/Views/VersionControlDetailHeaderView.axaml create mode 100644 src/Beutl.Editor.Components/VersionControlTab/Views/VersionControlDetailHeaderView.axaml.cs create mode 100644 src/Beutl.Editor.Components/VersionControlTab/Views/VersionControlHistoryView.axaml create mode 100644 src/Beutl.Editor.Components/VersionControlTab/Views/VersionControlHistoryView.axaml.cs create mode 100644 src/Beutl.Editor.Components/VersionControlTab/Views/VersionControlTabLayout.cs create mode 100644 src/Beutl.Editor.Components/VersionControlTab/Views/VersionControlTabView.axaml create mode 100644 src/Beutl.Editor.Components/VersionControlTab/Views/VersionControlTabView.axaml.cs create mode 100644 src/Beutl.Editor/Services/ElementFileNaming.cs create mode 100644 src/Beutl.Editor/VersionControl/AtomicFileExchange.cs create mode 100644 src/Beutl.Editor/VersionControl/GitCliRunner.cs create mode 100644 src/Beutl.Editor/VersionControl/GitCliVersionControlService.cs create mode 100644 src/Beutl.Editor/VersionControl/GitInstallationLocator.cs create mode 100644 src/Beutl.Editor/VersionControl/IProjectFileWriteLease.cs create mode 100644 src/Beutl.Editor/VersionControl/IProjectVersionControlCoordinator.cs create mode 100644 src/Beutl.Editor/VersionControl/IProjectVersionControlInitializer.cs create mode 100644 src/Beutl.Editor/VersionControl/IProjectVersionControlService.cs create mode 100644 src/Beutl.Editor/VersionControl/IProjectVersionControlSession.cs create mode 100644 src/Beutl.Editor/VersionControl/ProjectConflictMarkerScanner.cs create mode 100644 src/Beutl.Editor/VersionControl/RepositoryWatcher.cs create mode 100644 src/Beutl.Editor/VersionControl/SerializedProjectGraph.cs create mode 100644 src/Beutl.Editor/VersionControl/VersionControlModels.cs create mode 100644 src/Beutl.Editor/VersionControl/VersionControlPathComparison.cs create mode 100644 src/Beutl.Editor/VersionControl/VersionControlSerializationGraph.cs create mode 100644 src/Beutl/Services/PrimitiveImpls/VersionControlTabExtension.cs create mode 100644 src/Beutl/Services/ProjectCloseAbortedException.cs create mode 100644 src/Beutl/Services/VersionControlCoordinator.cs create mode 100644 src/Beutl/ViewModels/Dialogs/GitIdentityDialogViewModel.cs create mode 100644 src/Beutl/Views/TitleBarBranchView.axaml create mode 100644 src/Beutl/Views/TitleBarBranchView.axaml.cs create mode 100644 tests/Beutl.HeadlessUITests/EditorServiceTests.cs create mode 100644 tests/Beutl.HeadlessUITests/VersionControlConflictTests.cs create mode 100644 tests/Beutl.HeadlessUITests/VersionControlRestoreTests.cs create mode 100644 tests/Beutl.HeadlessUITests/VersionControlSaveTests.cs create mode 100644 tests/Beutl.HeadlessUITests/VersionControlTabViewTests.cs create mode 100644 tests/Beutl.UnitTests/Configuration/VersionControlConfigTests.cs create mode 100644 tests/Beutl.UnitTests/Editor/ElementFileNamingTests.cs create mode 100644 tests/Beutl.UnitTests/Editor/VersionControl/GitCliRunnerTests.cs create mode 100644 tests/Beutl.UnitTests/Editor/VersionControl/GitCliVersionControlServiceTests.cs create mode 100644 tests/Beutl.UnitTests/Editor/VersionControl/GitInstallationLocatorTests.cs create mode 100644 tests/Beutl.UnitTests/Editor/VersionControl/NestedRepositoryTests.cs create mode 100644 tests/Beutl.UnitTests/Editor/VersionControl/ProjectConflictMarkerScannerTests.cs create mode 100644 tests/Beutl.UnitTests/Editor/VersionControl/RealGitTestRepository.cs create mode 100644 tests/Beutl.UnitTests/Editor/VersionControl/RemoteOperationsTests.cs create mode 100644 tests/Beutl.UnitTests/Editor/VersionControl/RepositoryWatcherStressTests.cs create mode 100644 tests/Beutl.UnitTests/Editor/VersionControl/RepositoryWatcherTests.cs create mode 100644 tests/Beutl.UnitTests/Editor/VersionControl/TitleBarBranchViewModelTests.cs create mode 100644 tests/Beutl.UnitTests/Editor/VersionControl/VersionControlCommandSurfaceTests.cs create mode 100644 tests/Beutl.UnitTests/Editor/VersionControl/VersionControlModelsTests.cs create mode 100644 tests/Beutl.UnitTests/Editor/VersionControl/VersionControlPerformanceTests.cs create mode 100644 tests/Beutl.UnitTests/Editor/VersionControl/VersionControlPolicyTests.cs create mode 100644 tests/Beutl.UnitTests/Editor/VersionControl/VersionControlSnapshotScopeTests.cs create mode 100644 tests/Beutl.UnitTests/Editor/VersionControl/VersionControlTabLayoutTests.cs create mode 100644 tests/Beutl.UnitTests/Editor/VersionControl/VersionControlTabViewModelTests.cs create mode 100644 tests/Beutl.UnitTests/Editor/VersionControl/VersionControlTabViewTests.cs create mode 100644 tests/Beutl.UnitTests/Language/VersionControlStringsTests.cs create mode 100644 tests/Beutl.UnitTests/ProjectSystem/SceneTests.cs 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..6b5c78eb6e 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.36; **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 1bcda45fa1..9406be9c4e 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..1756b745bd --- /dev/null +++ b/docs/specs/005-project-git-versioning/contracts/coordinator-lifecycle.md @@ -0,0 +1,79 @@ +# 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. await 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. await 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. For Restore to New Branch, the failed restore branch is retained: Git cannot atomically prove both ref representation and cross-worktree non-use while deleting it. Only after the original project has reopened successfully does the coordinator identify that exact branch in a localized warning and direct the user to verify other worktrees before deleting it manually; uncertain recovery emits no cleanup guidance. + +Push runs outside the cycle (no work-tree mutation): progress dialog + cancel only. + +Guard: restore and branch-switch refuse to begin while the existing output service reports an active export. They acquire the 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 hold the exclusive lease through staging and commit, so Git never stages a partially written project file. + +## 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 existing shell `EnableVersionControl` context command (also reachable from the command palette, gated on `ProjectService.IsOpened`). +- **Command lifecycle**: `MenuBarViewModel` delegates version-control availability, tracking state, and save notification to the coordinator. Project close remains the responsibility of `ProjectService`; no package-operation or context-command public contract is changed by this feature. +- **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..7cd23c4564 --- /dev/null +++ b/docs/specs/005-project-git-versioning/contracts/git-cli-invocation.md @@ -0,0 +1,63 @@ +# 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. Up to 64 KiB of complete `stderr` records is captured on `GitOperationException` after credentials embedded in each record are redacted. An oversized undelimited record is replaced instead of retaining a mid-token suffix that could separate a secret from the URL prefix needed to redact it. The same bounded reader is used whether or not a progress sink is present. +- 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 snapshot commits + +Git 2.36 is the minimum because snapshot commits use `git hook run`. The service captures the attached branch ref/tip and the byte-exact live index while holding the worktree-private `HEAD.lock`, then builds the project tree in a temporary index. Broad staging and the generated portable `*.[tT][mM][pP]` ignore rule exclude `.beutl` state and every `.tmp` extension casing; serialized required `.tmp` files are force-added by exact repository path, and files required by the prior committed graph but no longer referenced are removed. Tracked scratch `.tmp` content is otherwise inherited unchanged. A later live-index change fails the byte/metadata compare-and-swap before publication and is never overwritten. + +Before creating the commit object, the service reacquires `HEAD.lock`, revalidates the captured ref/tip, freezes author, committer, signing, cleanup, and automatic comment-character settings, and runs `pre-commit`, `prepare-commit-msg`, and `commit-msg` against the frozen temporary index. Message hooks receive a unique owned file through their normal arguments; `GIT_COMMIT_EDITMSG` also names that file. Message bytes are preserved according to `i18n.commitEncoding`, and cleanup follows `git commit -m` ordering. The final hook-modified tree must remain inside the project scope, contain only regular required entries, contain no gitlinks or reserved-state references, and retain the same required `.tmp` identity set. Invalid or empty hook output is rejected before any ref update. + +The service creates the object with `git commit-tree`, reconciles the live index from that exact commit through its earlier compare-and-swap, and publishes the captured branch with `git update-ref ` from a temporary detached worktree. A lost update-ref response is accepted only when the captured ref is observed at the exact new object; a competing ref update restores only a still-owned reconciled index. `post-commit` runs once, with the real worktree index and frozen identity environment, only after durable publication. The HEAD lease is released first so the hook has ordinary Git mutation freedom. A post-hook failure is diagnostic only and cannot turn a durable commit into a retryable failure. Owned message files and the temporary worktree are cleaned on success and failure. + +## 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, report that specific stale lock. Never auto-delete silently. + +One-click removal requires explicit user consent and an atomic conditional-delete primitive. On Windows, capture the lock's volume/file ID when offering recovery, then open the path with `DELETE | FILE_READ_ATTRIBUTES` and no sharing, revalidate the mtime and volume/file ID through that exclusive handle, and set `FileDispositionInfo` on the same handle. A replacement cannot be removed by the stale path after the identity check. Platforms without an equivalent handle-bound primitive refuse in-app deletion and surface manual guidance; an ordinary path recheck followed by `File.Delete`, Unix unlink, or `FileShare`/`DeleteOnClose` sequence is not sufficient. 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..01e9e85252 --- /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. With Git LFS installed, the cancellable prefetch inspects the transition target even when the current project has no LFS paths. Restore uses `Repository.Pathspec/**` as its fetch include; because Git LFS parses that option as comma-separated gitignore globs, a path that cannot be represented literally is resolved with `git ls-tree` and LFS scans that exact project subtree object without a path filter. Branch and pull prefetches clear both LFS path filters and fetch the whole repository transition target. Every prefetch disables `lfs.fetchrecentalways` for that invocation, so configured recent branches cannot widen its explicit target. If fetch fails or no remote exists, the bounded target listing must contain canonical SHA-256 OIDs and every cached object must stream-hash to its OID before the project may close; missing, corrupt, malformed, or truncated evidence fails closed. `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. The built-in Git backend captures the attached ref/tip and live index, builds an exact pathspec-scoped tree in a temporary index, runs commit hooks there, validates the final tree, creates the object with `commit-tree`, reconciles the live index through a byte-exact compare-and-swap, and publishes only the captured ref through an expected-old `update-ref`. Non-`Manual` kinds write the `Beutl-Snapshot` trailer. Any hook, HEAD, index, or ref race before publication leaves the branch unchanged and restores only state still proven to be service-owned. A lost publication response is accepted only after observing the exact known object at the captured ref. `post-commit` runs once after that durable boundary and cannot reverse success. The built-in backend therefore returns `Committed(CommitRevision.Known)` with a non-empty SHA; `Unavailable` remains a defensive public result state for alternative coordinator implementations. +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..191ae73db4 --- /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.36 (needs `git hook run`, `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: manual|save|close|safety|restore|recovery|init` (absent ⇒ `Manual`, including legacy and external commits). `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 built-in Git backend constructs the object before its expected-old ref publication and therefore always returns `Known`; `Unavailable` remains a defensive state for alternative coordinator implementations whose durable commit cannot be identified safely. +- `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/`, `*.[tT][mM][pP]` (portable case-insensitive `.tmp` matching). +- **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; Beutl-created commits carry the canonical trailer `Beutl-Snapshot: `, including `manual` for named milestones. + +## 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..3f5ddc5804 --- /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.36) 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, path-comparison, and policy-notice primitives remain internal to the version-control implementation. The version-control service/initializer additions and the appVersion serialization change carry the required migration notes; unrelated output, context-command, filesystem, package, agent-host, and application-shutdown APIs are unchanged by this feature. + +## 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`/case-insensitive `.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..508f66f8b5 --- /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 every `.tmp` extension casing 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..98b16ff074 --- /dev/null +++ b/docs/specs/005-project-git-versioning/research.md @@ -0,0 +1,141 @@ +# 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.36+, for `git hook run`, `git switch`, worktree, and the required plumbing behavior). Repository initialization uses `git init` followed by `git symbolic-ref HEAD refs/heads/main` so the branch name remains explicit and consistent. +- 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 Save that finishes while repository activation is still running transfers its completed write lease directly into the worktree-mutation lease and waits for that exact activation before committing; later saves remain behind the write gate, so intermediate save points cannot coalesce. 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: manual|save|close|safety|restore|recovery|init`. Beutl-created manual commits retain the user's message verbatim and append the canonical `manual` trailer; legacy and external commits without the trailer still display as manual. 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 → `await ProjectService.CloseProject()` → git operation → `await 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 every `.tmp` extension casing 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), `*.[tT][mM][pP]` (atomic-write leftovers, with portable case-insensitive matching). +- `.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). + +An existing hygiene file is updated with an OS-native atomic exchange (Linux `renameat2(RENAME_EXCHANGE)`, macOS `renamex_np(RENAME_SWAP)`, Windows `ReplaceFileW`). Beutl verifies the exact displaced snapshot before deleting it; a concurrent edit is exchanged back and merged on retry. If another edit lands during rollback, the original path is restored and the later contents remain in a named recovery file rather than being discarded. Unsupported exchange semantics fail closed. + +**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 captures the attached local ref and resolves its configured origin upstream (even when the remote-tracking ref is absent), then sends that captured ref to the explicit remote branch; `-u` is used only when no upstream exists, and an upstream on another remote is never retargeted. 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), with scanning capped at 8 MiB per file and 32 MiB per open and prioritized toward serialized project files before unknown sidecars. + +**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..d585d85016 --- /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 with any `.tmp` extension casing) 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 detects it and either offers identity-checked one-click recovery when the platform can delete the opened file handle atomically, or gives manual recovery guidance when that guarantee is unavailable; it never deletes a replacement lock by path. + +--- + +### 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**: version operations are serialized against each other, and a restore/branch switch/pull is refused while an export is reading project files; a snapshot only captures fully written files (never a half-written save). +- **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 on next open. One-click removal is available only when the platform can exclusively open the same file identity and bind deletion to that handle; otherwise Beutl refuses deletion and directs the user to close external Git processes and inspect/remove the lock manually. 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 detect 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. It MUST delete a stale lock only after explicit user consent and only when the offered file identity can be revalidated through an exclusive handle and deletion can be bound to that same handle; when the platform cannot guarantee conditional deletion, it MUST fail closed and provide manual recovery guidance. + +**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. +- **FR-013**: Closing a tracked project with changes since the last version MUST record a close snapshot. +- **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. + +**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..2d45cfbc07 --- /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.36, `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 case-insensitive `.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`, generate `.gitignore` `**/.beutl/` + `*.[tT][mM][pP]` 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.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/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..c85bc189a9 100644 --- a/src/Beutl.Core/Project.cs +++ b/src/Beutl.Core/Project.cs @@ -65,6 +65,16 @@ 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); @@ -79,22 +89,30 @@ 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); } + // Project migrations must call this only after they have rewritten persisted content. + // A plain load/save keeps the version from disk so a newer Beutl release does not dirty the project. + internal void MarkAsMigrated() + { + AppVersion = BeutlApplication.Version; + MinAppVersion = DefaultMinAppVersion; + } + 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.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..9f380111e0 --- /dev/null +++ b/src/Beutl.Editor.Components/VersionControlTab/ViewModels/VersionControlTabViewModel.cs @@ -0,0 +1,2365 @@ +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); + StaleLockGuidance = new ReactivePropertySlim( + Strings.VersionControl_StaleLockGuidance) + .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 StaleLockGuidance { 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; + } + + RepositoryLockInfo? expectedLock = _lockRecoveryService.RecoverableLock; + if (expectedLock is null) + { + return; + } + + bool removed = await _lockRecoveryService.RemoveRecoverableLockAsync( + expectedLock, + CancellationToken.None); + RepositoryLockInfo? remainingLock = _lockRecoveryService.RecoverableLock; + HasRecoverableLock.Value = remainingLock is not null; + if (!removed && remainingLock is not null) + { + StaleLockGuidance.Value = string.Format( + CultureInfo.CurrentCulture, + Strings.VersionControl_StaleLockManualRemovalRequiredFormat, + remainingLock.LockPath); + } + } + + 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; + StaleLockGuidance.Value = Strings.VersionControl_StaleLockGuidance; + 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) + && ReferenceEquals(_lockRecoveryService?.RecoverableLock, lockInfo)) + { + StaleLockGuidance.Value = Strings.VersionControl_StaleLockGuidance; + 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); + bool inHunk = false; + var lines = new List(); + foreach (string line in diff.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n')) + { + if (line.StartsWith("diff ", StringComparison.Ordinal)) + { + inHunk = false; + } + else if (line.StartsWith("@@", StringComparison.Ordinal)) + { + inHunk = true; + } + + lines.Add(new VersionControlDiffLineViewModel(line, GetKind(line, inHunk))); + } + + return lines.ToArray(); + } + + private static VersionControlDiffLineKind GetKind(string line, bool inHunk) + { + if (line.StartsWith("@@", StringComparison.Ordinal) + || line.StartsWith("diff ", StringComparison.Ordinal) + || line.StartsWith("index ", StringComparison.Ordinal)) + { + return VersionControlDiffLineKind.Header; + } + + if (!inHunk + && (line.StartsWith("+++ ", StringComparison.Ordinal) + || line.StartsWith("--- ", 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..d8fe40778c --- /dev/null +++ b/src/Beutl.Editor.Components/VersionControlTab/Views/VersionControlTabView.axaml.cs @@ -0,0 +1,145 @@ +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() + { + if (TopLevel.GetTopLevel(this)?.DataContext is IContextCommandHandler handler) + { + var execution = new ContextCommandExecution("EnableVersionControl"); + if (handler.CanExecute(execution)) + { + handler.Execute(execution); + } + } + + return Task.CompletedTask; + } + + 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/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/AtomicFileExchange.cs b/src/Beutl.Editor/VersionControl/AtomicFileExchange.cs new file mode 100644 index 0000000000..d57c744e6e --- /dev/null +++ b/src/Beutl.Editor/VersionControl/AtomicFileExchange.cs @@ -0,0 +1,122 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; + +namespace Beutl.Editor.VersionControl; + +internal static partial class AtomicFileExchange +{ + private const int AtCurrentWorkingDirectory = -100; + private const uint RenameExchange = 0x00000002; + private const uint ReplaceFileIgnoreMergeErrors = 0x00000002; + + public static string ReplacePreservingTarget(string targetPath, string replacementPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(targetPath); + ArgumentException.ThrowIfNullOrWhiteSpace(replacementPath); + + string target = Path.GetFullPath(targetPath); + string replacement = Path.GetFullPath(replacementPath); + string targetDirectory = Path.GetDirectoryName(target) + ?? throw new ArgumentException( + "The target must have a parent directory.", + nameof(targetPath)); + string replacementDirectory = Path.GetDirectoryName(replacement) + ?? throw new ArgumentException( + "The replacement must have a parent directory.", + nameof(replacementPath)); + if (!string.Equals(targetDirectory, replacementDirectory, StringComparison.Ordinal)) + { + throw new ArgumentException( + "Atomic file exchange requires sibling paths.", + nameof(replacementPath)); + } + + if (OperatingSystem.IsWindows()) + { + string displacedPath = Path.Combine( + targetDirectory, + $".{Path.GetFileName(target)}.{Guid.NewGuid():N}.displaced.tmp"); + if (ReplaceFileW( + target, + replacement, + displacedPath, + ReplaceFileIgnoreMergeErrors, + 0, + 0) == 0) + { + ThrowExchangeFailure(target, replacement); + } + + return displacedPath; + } + + int result; + if (OperatingSystem.IsLinux()) + { + result = RenameAt2( + AtCurrentWorkingDirectory, + replacement, + AtCurrentWorkingDirectory, + target, + RenameExchange); + } + else if (OperatingSystem.IsMacOS()) + { + result = RenameX(replacement, target, RenameExchange); + } + else + { + throw new PlatformNotSupportedException( + "Atomic repository-hygiene updates are not supported on this platform."); + } + + if (result != 0) + { + ThrowExchangeFailure(target, replacement); + } + + // renameat2(RENAME_EXCHANGE) and renamex_np(RENAME_SWAP) leave the displaced target at + // the replacement path. The caller verifies that exact snapshot before deleting it. + return replacement; + } + + private static void ThrowExchangeFailure(string target, string replacement) + { + int error = Marshal.GetLastPInvokeError(); + throw new IOException( + $"Could not atomically exchange '{target}' with '{replacement}': " + + new Win32Exception(error).Message); + } + + [LibraryImport( + "libc", + EntryPoint = "renameat2", + SetLastError = true, + StringMarshalling = StringMarshalling.Utf8)] + private static partial int RenameAt2( + int oldDirectory, + string oldPath, + int newDirectory, + string newPath, + uint flags); + + [LibraryImport( + "libc", + EntryPoint = "renamex_np", + SetLastError = true, + StringMarshalling = StringMarshalling.Utf8)] + private static partial int RenameX(string oldPath, string newPath, uint flags); + + [LibraryImport( + "kernel32.dll", + EntryPoint = "ReplaceFileW", + SetLastError = true, + StringMarshalling = StringMarshalling.Utf16)] + private static partial int ReplaceFileW( + string replacedFileName, + string replacementFileName, + string backupFileName, + uint replaceFlags, + nint exclude, + nint reserved); +} diff --git a/src/Beutl.Editor/VersionControl/GitCliRunner.cs b/src/Beutl.Editor/VersionControl/GitCliRunner.cs new file mode 100644 index 0000000000..15c5e5fe96 --- /dev/null +++ b/src/Beutl.Editor/VersionControl/GitCliRunner.cs @@ -0,0 +1,1421 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32.SafeHandles; + +namespace Beutl.Editor.VersionControl; + +internal sealed record GitCommandResult( + int ExitCode, + string Stdout, + string Stderr, + bool StdoutTruncated = false, + byte[]? StdoutBytes = null); + +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, + byte[]? StandardInputBytes = null, + bool CaptureStdoutBytes = false) +{ + 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 readonly record struct RepositoryLockFileIdentity( + uint VolumeSerialNumber, + ulong FileIndex); + +internal readonly record struct RepositoryLockFileSnapshot( + DateTimeOffset LastWriteTimeUtc, + RepositoryLockFileIdentity? Identity); + +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 partial class GitCliRunner : IGitCliRunner +{ + private const string DefaultSshCommand = "ssh -oBatchMode=yes"; + private static readonly string[] s_repositoryLocalEnvironmentVariables = + [ + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_AUTHOR_DATE", + "GIT_AUTHOR_EMAIL", + "GIT_AUTHOR_NAME", + "GIT_CEILING_DIRECTORIES", + "GIT_COMMITTER_DATE", + "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 bool _supportsConditionalLockDeletion; + private readonly Func _readLockFileSnapshot; + private readonly Func _deleteLockFileConditionally; + private readonly Func> _enumerateFileSystemEntries; + private readonly ConditionalWeakTable + _lockFileIdentities = new(); + 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, + bool? supportsConditionalLockDeletion = null, + Func? readLockFileSnapshot = null, + Func? deleteLockFileConditionally = null, + Func>? enumerateFileSystemEntries = 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; + _supportsConditionalLockDeletion = supportsConditionalLockDeletion + ?? OperatingSystem.IsWindows(); + _readLockFileSnapshot = readLockFileSnapshot + ?? (OperatingSystem.IsWindows() + ? TryReadWindowsLockFileSnapshot + : TryReadPortableLockFileSnapshot); + _deleteLockFileConditionally = deleteLockFileConditionally + ?? TryDeleteWindowsLockFileConditionally; + _enumerateFileSystemEntries = enumerateFileSystemEntries + ?? (static path => Directory.EnumerateFileSystemEntries( + path, + "*", + SearchOption.TopDirectoryOnly)); + } + + 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)); + } + + if (options.StandardInput is not null && options.StandardInputBytes is not null) + { + throw new ArgumentException( + "Only one standard-input representation can be supplied.", + 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, + options.StandardInputBytes, + options.CaptureStdoutBytes, + 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, + byte[]? standardInputBytes, + bool captureStdoutBytes, + 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, byte[]? OutputBytes, bool Truncated)> stdoutTask = + CaptureStandardOutputAsync( + process.StandardOutput.BaseStream, + maxStdoutBytes, + captureStdoutBytes); + Task stderrTask = 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, + standardInputBytes, + 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, byte[]? stdoutBytes, 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, + stdoutBytes); + } + 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)) + { + RepositoryLockFileSnapshot? snapshot = _readLockFileSnapshot(lockPath); + if (snapshot is not { } lockSnapshot) + { + continue; + } + + if (_timeProvider.GetUtcNow() - lockSnapshot.LastWriteTimeUtc > StaleLockAge) + { + var lockInfo = new RepositoryLockInfo( + lockPath, + lockSnapshot.LastWriteTimeUtc); + if (lockSnapshot.Identity is { } identity) + { + _lockFileIdentities.Add( + lockInfo, + new RepositoryLockFileIdentityBox(identity)); + } + + return lockInfo; + } + } + + 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); + if (!_supportsConditionalLockDeletion + || !_lockFileIdentities.TryGetValue( + lockInfo, + out RepositoryLockFileIdentityBox? offered) + || offered is null) + { + return false; + } + + RepositoryLockInfo? current = GetRecoverableRepositoryLock(repository); + if (current is null + || !_lockFileIdentities.TryGetValue( + current, + out RepositoryLockFileIdentityBox? currentIdentity) + || currentIdentity is null + || !VersionControlPathComparison.AreSameCanonicalPath( + current.LockPath, + Path.GetFullPath(lockInfo.LockPath)) + || current.LastWriteTimeUtc != lockInfo.LastWriteTimeUtc + || currentIdentity.Identity != offered.Identity) + { + return false; + } + + try + { + var expected = new RepositoryLockFileSnapshot( + current.LastWriteTimeUtc, + currentIdentity.Identity); + return _deleteLockFileConditionally(current.LockPath, expected); + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or ArgumentException + or NotSupportedException) + { + 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, + standardInputBytes: null, + captureStdoutBytes: false, + 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( + StreamWriter writer, + string? input, + byte[]? inputBytes, + CancellationToken cancellationToken) + { + try + { + if (inputBytes is not null) + { + await writer.BaseStream.WriteAsync(inputBytes, cancellationToken) + .ConfigureAwait(false); + await writer.BaseStream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + else 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; + + private const string OmittedStandardErrorRecord = + "[stderr record omitted because it exceeded the retention limit]"; + + private const string OmittedProgressRecord = + "[progress record omitted because it exceeded the retention limit]"; + + internal static async Task ReadStandardErrorAsync( + TextReader reader, + IProgress? progress) + { + var retainedRecords = new Queue(); + int retainedLength = 0; + var errorRecord = new StringBuilder(); + var progressRecord = new StringBuilder(); + bool errorRecordOmitted = false; + bool progressRecordOmitted = false; + bool pendingCarriageReturn = false; + var buffer = new char[256]; + + void CompleteErrorRecord(string delimiter) + { + string retainedContent = errorRecordOmitted + ? OmittedStandardErrorRecord + : GitDiagnosticSanitizer.RedactCredentials(errorRecord.ToString()); + if (retainedContent.Length + delimiter.Length + > MaxRetainedStandardErrorLength) + { + retainedContent = OmittedStandardErrorRecord; + retainedRecords.Clear(); + retainedLength = 0; + } + + RetainCompleteStandardErrorRecord( + retainedRecords, + ref retainedLength, + retainedContent + delimiter); + errorRecord.Clear(); + errorRecordOmitted = false; + } + + void CompleteProgressRecord() + { + if (progress is not null + && !progressRecordOmitted + && progressRecord.Length > 0) + { + progress.Report(GitDiagnosticSanitizer.RedactCredentials( + progressRecord.ToString())); + } + + progressRecord.Clear(); + progressRecordOmitted = false; + } + + void AppendRecordCharacter(char value) + { + if (!errorRecordOmitted) + { + if (errorRecord.Length == MaxRetainedStandardErrorLength) + { + // Never retain a suffix cut out of a larger raw record: the removed prefix may + // contain the URL scheme that the sanitizer needs in order to recognize + // credentials in the retained suffix. + errorRecord.Clear(); + errorRecordOmitted = true; + retainedRecords.Clear(); + retainedLength = 0; + } + else + { + errorRecord.Append(value); + } + } + + if (progress is not null && !progressRecordOmitted) + { + if (progressRecord.Length == MaxProgressRecordLength) + { + progressRecord.Clear(); + progressRecordOmitted = true; + progress.Report(OmittedProgressRecord); + } + else + { + progressRecord.Append(value); + } + } + } + + int count; + while ((count = await reader.ReadAsync(buffer).ConfigureAwait(false)) > 0) + { + for (int i = 0; i < count; i++) + { + char value = buffer[i]; + if (pendingCarriageReturn) + { + if (value == '\n') + { + CompleteErrorRecord("\r\n"); + pendingCarriageReturn = false; + continue; + } + + CompleteErrorRecord("\r"); + pendingCarriageReturn = false; + } + + if (value == '\r') + { + CompleteProgressRecord(); + pendingCarriageReturn = true; + } + else if (value == '\n') + { + CompleteErrorRecord("\n"); + CompleteProgressRecord(); + } + else + { + AppendRecordCharacter(value); + } + } + } + + if (pendingCarriageReturn) + { + CompleteErrorRecord("\r"); + } + + if (errorRecordOmitted || errorRecord.Length > 0) + { + CompleteErrorRecord(string.Empty); + CompleteProgressRecord(); + } + + return string.Concat(retainedRecords); + } + + private static void RetainCompleteStandardErrorRecord( + Queue records, + ref int retainedLength, + string record) + { + Debug.Assert(record.Length <= MaxRetainedStandardErrorLength); + + while (retainedLength + record.Length > MaxRetainedStandardErrorLength + && records.TryDequeue(out string? removed)) + { + retainedLength -= removed.Length; + } + + records.Enqueue(record); + retainedLength += record.Length; + } + + 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 async Task<(string Output, byte[]? OutputBytes, bool Truncated)> + CaptureStandardOutputAsync( + Stream stream, + int? maxBytes, + bool captureBytes) + { + if (!captureBytes) + { + (string output, bool truncated) = await ReadStandardOutputAsync(stream, maxBytes) + .ConfigureAwait(false); + return (output, null, truncated); + } + + (byte[] outputBytes, bool outputTruncated) = + await ReadStandardOutputBytesAsync(stream, maxBytes).ConfigureAwait(false); + return (Encoding.UTF8.GetString(outputBytes), outputBytes, outputTruncated); + } + + internal static async Task<(byte[] Output, bool Truncated)> ReadStandardOutputBytesAsync( + Stream stream, + int? maxBytes) + { + ArgumentNullException.ThrowIfNull(stream); + if (maxBytes is < 0) + { + throw new ArgumentOutOfRangeException(nameof(maxBytes)); + } + + if (maxBytes is null) + { + using var output = new MemoryStream(); + await stream.CopyToAsync(output).ConfigureAwait(false); + return (output.ToArray(), 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; + } + + if (capturedCount != captured.Length) + { + Array.Resize(ref captured, capturedCount); + } + + return (captured, 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 []; + } + + if (ContainsReparsePoint(commonDirectory, refsDirectory)) + { + return []; + } + + string canonicalRefsDirectory = Path.TrimEndingDirectorySeparator( + VersionControlPathComparison.ResolveCanonicalPath(refsDirectory)); + var visitedDirectories = new HashSet(StringComparer.Ordinal); + var pendingDirectories = new Stack(); + var lockPaths = new List(); + pendingDirectories.Push(refsDirectory); + + while (pendingDirectories.TryPop(out string? directory)) + { + try + { + string fullDirectory = Path.GetFullPath(directory); + FileAttributes directoryAttributes = File.GetAttributes(fullDirectory); + if ((directoryAttributes & FileAttributes.Directory) == 0 + || (directoryAttributes & FileAttributes.ReparsePoint) != 0 + || ContainsReparsePoint(commonDirectory, fullDirectory)) + { + continue; + } + + string canonicalDirectory = Path.TrimEndingDirectorySeparator( + VersionControlPathComparison.ResolveCanonicalPath(fullDirectory)); + FileAttributes verifiedDirectoryAttributes = File.GetAttributes(fullDirectory); + if (!IsCanonicalSameOrDescendant( + canonicalRefsDirectory, + canonicalDirectory) + || (verifiedDirectoryAttributes & FileAttributes.Directory) == 0 + || (verifiedDirectoryAttributes & FileAttributes.ReparsePoint) != 0 + || !visitedDirectories.Add(canonicalDirectory)) + { + continue; + } + + foreach (string candidate in _enumerateFileSystemEntries(fullDirectory)) + { + try + { + string fullPath = Path.GetFullPath(candidate); + FileAttributes attributes = File.GetAttributes(fullPath); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + continue; + } + + string canonicalPath = Path.TrimEndingDirectorySeparator( + VersionControlPathComparison.ResolveCanonicalPath(fullPath)); + FileAttributes verifiedAttributes = File.GetAttributes(fullPath); + if (!IsCanonicalSameOrDescendant( + canonicalRefsDirectory, + canonicalPath) + || (verifiedAttributes & FileAttributes.ReparsePoint) != 0 + || (verifiedAttributes & FileAttributes.Directory) + != (attributes & FileAttributes.Directory)) + { + continue; + } + + if ((verifiedAttributes & FileAttributes.Directory) != 0) + { + if (!ContainsReparsePoint(commonDirectory, fullPath)) + { + pendingDirectories.Push(fullPath); + } + + continue; + } + + if (IsRefLockFile(fullPath) + && !ContainsReparsePoint( + commonDirectory, + Path.GetDirectoryName(fullPath)!)) + { + lockPaths.Add(fullPath); + } + } + catch (Exception ex) when (IsFileInspectionFailure(ex)) + { + } + } + } + catch (Exception ex) when (IsFileInspectionFailure(ex)) + { + } + } + + return lockPaths; + } + + private static bool IsCanonicalSameOrDescendant(string canonicalRoot, string canonicalPath) + { + 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); + } + + private static bool IsRefLockFile(string path) + { + const string LockSuffix = ".lock"; + if (!Path.GetFileName(path).EndsWith(LockSuffix, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + string lowercaseSuffixPath = path[..^LockSuffix.Length] + LockSuffix; + return Path.Exists(lowercaseSuffixPath) + && VersionControlPathComparison.AreSameCanonicalPath(path, lowercaseSuffixPath); + } + + private static bool IsFileInspectionFailure(Exception exception) + { + return exception is IOException + or UnauthorizedAccessException + or ArgumentException + or NotSupportedException; + } + + 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))); + if (!VersionControlPathComparison.IsSameOrDescendant(headsDirectory, refPath)) + { + 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 (VersionControlPathComparison.AreSameCanonicalPath(current, boundary)) + { + return false; + } + + string? parent = Path.GetDirectoryName(current); + if (parent is null || VersionControlPathComparison.AreSameCanonicalPath(parent, current)) + { + 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 RepositoryLockFileSnapshot? TryReadPortableLockFileSnapshot(string path) + { + if (!File.Exists(path)) + { + return null; + } + + return new RepositoryLockFileSnapshot( + new DateTimeOffset(File.GetLastWriteTimeUtc(path), TimeSpan.Zero), + Identity: null); + } + + private static RepositoryLockFileSnapshot? TryReadWindowsLockFileSnapshot(string path) + { + if (!OperatingSystem.IsWindows()) + { + return null; + } + + using SafeFileHandle handle = WindowsNativeMethods.CreateFileW( + path, + WindowsNativeMethods.FileReadAttributes, + WindowsNativeMethods.FileShareRead + | WindowsNativeMethods.FileShareWrite + | WindowsNativeMethods.FileShareDelete, + 0, + WindowsNativeMethods.OpenExisting, + WindowsNativeMethods.FileFlagOpenReparsePoint, + 0); + return handle.IsInvalid + ? null + : TryReadWindowsLockFileSnapshot(handle); + } + + private static RepositoryLockFileSnapshot? TryReadWindowsLockFileSnapshot( + SafeFileHandle handle) + { + if (WindowsNativeMethods.GetFileInformationByHandle( + handle, + out ByHandleFileInformation info) == 0) + { + return null; + } + + long lastWriteFileTime = ((long)info.LastWriteTimeHigh << 32) + | info.LastWriteTimeLow; + ulong fileIndex = ((ulong)info.FileIndexHigh << 32) | info.FileIndexLow; + return new RepositoryLockFileSnapshot( + new DateTimeOffset(DateTime.FromFileTimeUtc(lastWriteFileTime), TimeSpan.Zero), + new RepositoryLockFileIdentity(info.VolumeSerialNumber, fileIndex)); + } + + private static bool TryDeleteWindowsLockFileConditionally( + string path, + RepositoryLockFileSnapshot expected) + { + if (!OperatingSystem.IsWindows() || expected.Identity is null) + { + return false; + } + + using SafeFileHandle handle = WindowsNativeMethods.CreateFileW( + path, + WindowsNativeMethods.Delete | WindowsNativeMethods.FileReadAttributes, + shareMode: 0, + 0, + WindowsNativeMethods.OpenExisting, + WindowsNativeMethods.FileFlagOpenReparsePoint, + 0); + RepositoryLockFileSnapshot? actual = handle.IsInvalid + ? null + : TryReadWindowsLockFileSnapshot(handle); + if (actual != expected) + { + return false; + } + + var disposition = new FileDispositionInfo { DeleteFile = 1 }; + return WindowsNativeMethods.SetFileInformationByHandle( + handle, + WindowsNativeMethods.FileDispositionInfo, + ref disposition, + (uint)Marshal.SizeOf()) != 0; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ByHandleFileInformation + { + public uint FileAttributes; + public uint CreationTimeLow; + public uint CreationTimeHigh; + public uint LastAccessTimeLow; + public uint LastAccessTimeHigh; + public uint LastWriteTimeLow; + public uint LastWriteTimeHigh; + public uint VolumeSerialNumber; + public uint FileSizeHigh; + public uint FileSizeLow; + public uint NumberOfLinks; + public uint FileIndexHigh; + public uint FileIndexLow; + } + + [StructLayout(LayoutKind.Sequential)] + private struct FileDispositionInfo + { + public byte DeleteFile; + } + + private sealed class RepositoryLockFileIdentityBox(RepositoryLockFileIdentity identity) + { + public RepositoryLockFileIdentity Identity { get; } = identity; + } + + private static partial class WindowsNativeMethods + { + internal const uint Delete = 0x00010000; + internal const uint FileReadAttributes = 0x00000080; + internal const uint FileShareRead = 0x00000001; + internal const uint FileShareWrite = 0x00000002; + internal const uint FileShareDelete = 0x00000004; + internal const uint OpenExisting = 3; + internal const uint FileFlagOpenReparsePoint = 0x00200000; + internal const int FileDispositionInfo = 4; + + [LibraryImport( + "kernel32.dll", + EntryPoint = "CreateFileW", + SetLastError = true, + StringMarshalling = StringMarshalling.Utf16)] + internal static partial SafeFileHandle CreateFileW( + string fileName, + uint desiredAccess, + uint shareMode, + nint securityAttributes, + uint creationDisposition, + uint flagsAndAttributes, + nint templateFile); + + [LibraryImport("kernel32.dll", SetLastError = true)] + internal static partial int GetFileInformationByHandle( + SafeFileHandle file, + out ByHandleFileInformation fileInformation); + + [LibraryImport("kernel32.dll", SetLastError = true)] + internal static partial int SetFileInformationByHandle( + SafeFileHandle file, + int fileInformationClass, + ref FileDispositionInfo fileInformation, + uint bufferSize); + } +} diff --git a/src/Beutl.Editor/VersionControl/GitCliVersionControlService.cs b/src/Beutl.Editor/VersionControl/GitCliVersionControlService.cs new file mode 100644 index 0000000000..4f998a3f18 --- /dev/null +++ b/src/Beutl.Editor/VersionControl/GitCliVersionControlService.cs @@ -0,0 +1,12114 @@ +using System.Collections.Concurrent; +using System.Formats.Tar; +using System.Security.Cryptography; +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; + private const int MaxHistoricalGraphListBytes = 4 * 1024 * 1024; + private const int MaxHistoricalGraphFileCount = 16 * 1024; + private const long MaxHistoricalGraphBytes = 128L * 1024 * 1024; + private const int MaxHistoricalGraphCacheEntries = 32; + private const int MaxHistoricalArchivePathspecCharacters = 16 * 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 its checkout clears those + // filters: an excluded pointer is copied through unchanged, which would leave pointer text in + // the work tree where the media belongs. Repository-wide prefetches use the same cleared + // baseline; project restore narrows the scan with an explicit include or exact subtree. + 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 LfsPrefetchTarget( + string Reference, + IReadOnlyList PathArguments); + + private sealed record WorktreeStateFingerprint(string Tree, string IndexEntries); + + private sealed record IndexFileSnapshot( + bool Exists, + byte[] Contents, + FileAttributes? Attributes, + UnixFileMode? UnixMode, + DateTime? LastWriteTimeUtc); + + private sealed record SnapshotTreeCapture( + string Tree, + string IndexPath, + IndexFileSnapshot Index, + IReadOnlyList TemporaryPathspecsToReconcile); + + private sealed record SnapshotIndexCommandPlan( + IReadOnlyList> Commands, + IReadOnlyList TemporaryPathspecsToReconcile); + + private sealed record SnapshotTreeBuildResult( + string Tree, + IReadOnlyList TemporaryPathspecsToReconcile); + + private sealed record SnapshotCommit( + string Commit, + string Tree, + string MessagePath, + SnapshotIdentity Author, + SnapshotIdentity Committer); + + private sealed record SnapshotIdentity( + string Name, + string Email, + string Date); + + private sealed class IndexRollbackAmbiguousException : InvalidOperationException + { + public IndexRollbackAmbiguousException(string message) + : base(message) + { + } + + public IndexRollbackAmbiguousException(string message, Exception innerException) + : base(message, innerException) + { + } + } + + private enum TreeTransitionOutcome + { + AppliedTarget, + RestoredCurrent, + OwnershipLost, + RecoveryFailed, + } + + private enum PullRelation + { + Equal, + LocalBehind, + LocalAhead, + Diverged, + } + + private enum CommitCleanupMode + { + Whitespace, + Strip, + Verbatim, + } + + private sealed record PullFetchTarget( + IReadOnlyList Arguments, + string UpstreamRef); + + private sealed record BranchUpstreamConfiguration( + string RemoteName, + string RemoteRef); + + 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 readonly string _headPath; + private readonly string _expectedRefName; + private FileStream? _stream; + + private HeadOwnershipLease( + string headPath, + string expectedRefName, + string lockPath, + FileStream stream, + Action? releaseFailureSink) + { + _headPath = headPath; + _expectedRefName = expectedRefName; + 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( + headPath, + expectedRefName, + lockPath, + stream, + releaseFailureSink); + try + { + lease.VerifyStillOwned(); + return lease; + } + catch + { + lease.Dispose(); + throw; + } + } + + public void VerifyStillOwned() + { + if (_stream is null) + { + throw new ObjectDisposedException(nameof(HeadOwnershipLease)); + } + + string expected = $"ref: {_expectedRefName}\n"; + string actual = File.ReadAllText(_headPath, new UTF8Encoding(false)); + if (!string.Equals(actual, expected, StringComparison.Ordinal)) + { + throw new ProjectCheckpointStateChangedException(); + } + } + + 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 const int MaxLfsFetchOutputBytes = 64 * 1024; + private const int MaxLfsObjectListOutputBytes = 4 * 1024 * 1024; + private const int MaxSnapshotTreeInspectionBytes = 4 * 1024 * 1024; + private const int MaxCommitMessageBytes = 1024 * 1024; + + private static readonly string[] s_gitIgnoreLines = + [ + "**/.beutl/", + "*.[tT][mM][pP]", + ]; + + 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) + { + string prefix = repository.Pathspec == "." + ? string.Empty + : EscapeGitGlobPath(repository.Pathspec) + "/"; + // Broad staging always excludes `.tmp` scratch files. Serialized `.tmp` sidecars are + // added by exact literal path through CreateSnapshotIndexCommands so one required sidecar + // never widens the snapshot to every temporary file in the project. + return s_ignoredOptionalProjectPathspecSuffixes + .Select(suffix => $":(top,exclude,glob){prefix}{suffix}") + .ToArray(); + } + + private async Task CreateSnapshotIndexCommandsAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string? baseCommit, + CancellationToken cancellationToken) + { + var addArguments = new List + { + "-c", + "advice.addIgnoredFile=false", + "add", + "-A", + "--", + CreateSnapshotBasePathspec(repository), + }; + addArguments.AddRange(CreateSnapshotExcludePathspecs(repository)); + + var commands = new List> { addArguments }; + IReadOnlyList requiredTemporaryPathspecs = + GetRequiredTemporaryRepositoryPathspecs(repository); + IReadOnlySet previousRequiredTemporaryPaths = baseCommit is null + ? new HashSet(StringComparer.Ordinal) + : await GetRequiredTemporaryProjectPathsAtCommitAsync( + repository, + runner, + baseCommit, + cancellationToken) + .ConfigureAwait(false); + string[] noLongerRequiredPathspecs = previousRequiredTemporaryPaths + .Where(previousPath => !_requiredTemporaryProjectPaths.Any( + currentPath => AreSameProjectRelativePath( + repository.ProjectRoot, + previousPath, + currentPath))) + .Select(path => CreateRequiredTemporaryPathspec(repository, path)) + .ToArray(); + string[] pathspecsToRemove = requiredTemporaryPathspecs + .Concat(noLongerRequiredPathspecs) + .Distinct(StringComparer.Ordinal) + .ToArray(); + if (pathspecsToRemove.Length > 0) + { + // Removing current paths refreshes their blob or records a physical deletion. Removing + // paths required by the base graph but not the current graph prevents a dereferenced + // sidecar from leaking into the next tree. Unrelated tracked scratch files stay in the + // base tree and are not widened into the snapshot. + commands.Add( + [ + "--literal-pathspecs", + "update-index", + "--force-remove", + "--", + .. pathspecsToRemove.Select(GetRepositoryPathFromLiteralPathspec), + ]); + } + + string[] existingPathspecs = requiredTemporaryPathspecs + .Where(pathspec => File.Exists(GetProjectPathFromLiteralPathspec(repository, pathspec))) + .ToArray(); + if (existingPathspecs.Length > 0) + { + commands.Add( + [ + "-c", + "advice.addIgnoredFile=false", + "add", + "-A", + "-f", + "--", + .. existingPathspecs, + ]); + } + + return new SnapshotIndexCommandPlan(commands, pathspecsToRemove); + } + + private IReadOnlyList> CreateSnapshotIndexReconciliationCommands( + RepositoryInfo repository, + string commit, + IReadOnlyList temporaryPathspecsToReconcile) + { + var resetProject = new List + { + "reset", + "-q", + commit, + "--", + CreateSnapshotBasePathspec(repository), + }; + resetProject.AddRange(CreateSnapshotExcludePathspecs(repository)); + + var commands = new List> { resetProject }; + if (temporaryPathspecsToReconcile.Count > 0) + { + commands.Add( + [ + "reset", + "-q", + commit, + "--", + .. temporaryPathspecsToReconcile, + ]); + } + + return commands; + } + + private IReadOnlyList GetRequiredTemporaryRepositoryPathspecs( + RepositoryInfo repository) + { + return _requiredTemporaryProjectPaths + .Select(path => CreateRequiredTemporaryPathspec(repository, path)) + .ToArray(); + } + + private static string CreateRequiredTemporaryPathspec( + RepositoryInfo repository, + string projectRelativePath) + { + string prefix = repository.Pathspec == "." ? string.Empty : repository.Pathspec + "/"; + return $":(top,literal){prefix}{projectRelativePath}"; + } + + private static bool AreSameProjectRelativePath( + string projectRoot, + string left, + string right) + { + string leftPath = Path.Combine( + projectRoot, + left.Replace('/', Path.DirectorySeparatorChar)); + string rightPath = Path.Combine( + projectRoot, + right.Replace('/', Path.DirectorySeparatorChar)); + return VersionControlPathComparison.AreSameCanonicalPath(leftPath, rightPath); + } + + private async Task> GetRequiredTemporaryProjectPathsAtCommitAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string commit, + CancellationToken cancellationToken) + { + if (_historicalRequiredTemporaryPaths.TryGetValue( + commit, + out IReadOnlySet? cachedPaths)) + { + return cachedPaths; + } + + if (_projectFile is null + || !RepositoryPathComparer.IsContainedWithin(repository.ProjectRoot, _projectFile)) + { + return CacheHistoricalRequiredTemporaryPaths(commit, []); + } + + if (!await CommitHasTrackedTemporaryPathsAsync( + repository, + runner, + commit, + cancellationToken).ConfigureAwait(false)) + { + return CacheHistoricalRequiredTemporaryPaths(commit, []); + } + + string temporaryRoot = Path.Combine( + Path.GetTempPath(), + $"beutl-historical-graph-{Guid.NewGuid():N}"); + string materializedRepositoryRoot = Path.Combine(temporaryRoot, "tree"); + try + { + Directory.CreateDirectory(materializedRepositoryRoot); + string projectFileRepositoryPath = NormalizeGitPath( + Path.GetRelativePath(repository.RepoRoot, _projectFile)); + Dictionary graphFiles = await ListHistoricalGraphFilesAsync( + repository, + runner, + commit, + projectFileRepositoryPath, + cancellationToken) + .ConfigureAwait(false); + if (!graphFiles.ContainsKey(projectFileRepositoryPath)) + { + return CacheHistoricalRequiredTemporaryPaths(commit, []); + } + + await MaterializeHistoricalGraphFilesAsync( + repository, + runner, + commit, + materializedRepositoryRoot, + graphFiles, + cancellationToken) + .ConfigureAwait(false); + + string materializedProjectRoot = repository.Pathspec == "." + ? materializedRepositoryRoot + : GetMaterializedHistoricalPath( + materializedRepositoryRoot, + repository.Pathspec); + string materializedProjectFile = GetMaterializedHistoricalPath( + materializedRepositoryRoot, + projectFileRepositoryPath); + ValidateNoReservedProjectReferences(materializedProjectFile); + IReadOnlySet serializedPaths = SerializedProjectGraph.GetRelativePaths( + materializedProjectFile, + materializedProjectRoot); + HashSet requiredTemporaryPaths = serializedPaths + .Where(static path => path.EndsWith( + ".tmp", + StringComparison.OrdinalIgnoreCase)) + .ToHashSet(StringComparer.Ordinal); + return CacheHistoricalRequiredTemporaryPaths(commit, requiredTemporaryPaths); + } + catch (Exception ex) when (ex is not OutOfMemoryException + and not OperationCanceledException) + { + LogWarningBestEffort( + ex, + "The base commit's serialized project graph could not be read safely; previously required temporary files will be retained."); + return new HashSet(StringComparer.Ordinal); + } + finally + { + TryDeleteHistoricalGraphDirectory(temporaryRoot); + } + } + + private async Task CommitHasTrackedTemporaryPathsAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string commit, + CancellationToken cancellationToken) + { + GitCommandResult result = await runner.RunAsync( + repository, + [ + "ls-tree", + "-r", + "-z", + "--name-only", + commit, + "--", + CreateSnapshotBasePathspec(repository), + ], + new GitCommandOptions( + GitCommandExecutionKind.Local, + MaxStdoutBytes: MaxHistoricalGraphListBytes, + UseLiteralPathspecs: false), + cancellationToken) + .ConfigureAwait(false); + return result.StdoutTruncated + || GitCliRunner.SplitNullSeparated(result.Stdout) + .Any(static path => path.EndsWith( + ".tmp", + StringComparison.OrdinalIgnoreCase)); + } + + private IReadOnlySet CacheHistoricalRequiredTemporaryPaths( + string commit, + IEnumerable paths) + { + if (_historicalRequiredTemporaryPaths.Count >= MaxHistoricalGraphCacheEntries) + { + string oldest = _historicalRequiredTemporaryPaths.Keys.First(); + _historicalRequiredTemporaryPaths.Remove(oldest); + } + + var snapshot = new HashSet(paths, StringComparer.Ordinal); + _historicalRequiredTemporaryPaths[commit] = snapshot; + return snapshot; + } + + private static async Task> ListHistoricalGraphFilesAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string commit, + string projectFileRepositoryPath, + CancellationToken cancellationToken) + { + GitCommandResult listed = await runner.RunAsync( + repository, + [ + "ls-tree", + "-r", + "-z", + "--long", + commit, + "--", + CreateSnapshotBasePathspec(repository), + ], + new GitCommandOptions( + GitCommandExecutionKind.Local, + MaxStdoutBytes: MaxHistoricalGraphListBytes, + UseLiteralPathspecs: false), + cancellationToken) + .ConfigureAwait(false); + if (listed.StdoutTruncated) + { + throw new InvalidOperationException( + "The base commit's serialized project file list exceeded its safety limit."); + } + + var result = new Dictionary(StringComparer.Ordinal); + long totalBytes = 0; + foreach (string record in GitCliRunner.SplitNullSeparated(listed.Stdout)) + { + int separator = record.IndexOf('\t'); + string[] metadata = separator < 0 + ? [] + : record[..separator].Split(' ', StringSplitOptions.RemoveEmptyEntries); + string path = separator < 0 ? string.Empty : record[(separator + 1)..]; + bool isSerializedGraphFile = string.Equals( + path, + projectFileRepositoryPath, + StringComparison.Ordinal) + || Path.GetExtension(path) is { } extension + && (string.Equals( + extension, + ".scene", + StringComparison.OrdinalIgnoreCase) + || string.Equals( + extension, + ".belm", + StringComparison.OrdinalIgnoreCase)); + if (!isSerializedGraphFile) + { + continue; + } + + if (metadata.Length != 4 + || metadata[1] != "blob" + || metadata[0] is not ("100644" or "100755") + || !long.TryParse(metadata[3], out long size) + || size < 0 + || !IsSafeHistoricalGraphPath(repository, path) + || !result.TryAdd(path, size)) + { + throw new InvalidOperationException( + "The base commit contains an unsafe serialized project graph entry."); + } + + totalBytes = checked(totalBytes + size); + if (result.Count > MaxHistoricalGraphFileCount + || totalBytes > MaxHistoricalGraphBytes) + { + throw new InvalidOperationException( + "The base commit's serialized project graph exceeded its safety limit."); + } + } + + return result; + } + + private static async Task MaterializeHistoricalGraphFilesAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string commit, + string destinationRoot, + IReadOnlyDictionary graphFiles, + CancellationToken cancellationToken) + { + int archiveIndex = 0; + foreach (IReadOnlyList batch in BatchHistoricalGraphPaths(graphFiles.Keys)) + { + string archivePath = Path.Combine( + Path.GetDirectoryName(destinationRoot)!, + $"graph-{archiveIndex++}.tar"); + await runner.RunAsync( + repository, + [ + "archive", + "--format=tar", + $"--output={archivePath}", + $"{commit}^{{tree}}", + "--", + .. batch, + ], + GitCommandOptions.Local, + cancellationToken) + .ConfigureAwait(false); + var expectedBatch = batch.ToDictionary( + static path => path, + path => graphFiles[path], + StringComparer.Ordinal); + await ExtractHistoricalGraphArchiveAsync( + archivePath, + destinationRoot, + expectedBatch, + cancellationToken) + .ConfigureAwait(false); + } + } + + private static IEnumerable> BatchHistoricalGraphPaths( + IEnumerable paths) + { + var batch = new List(); + int batchCharacters = 0; + foreach (string path in paths) + { + if (batch.Count > 0 + && batchCharacters + path.Length > MaxHistoricalArchivePathspecCharacters) + { + yield return batch; + batch = []; + batchCharacters = 0; + } + + batch.Add(path); + batchCharacters += path.Length; + } + + if (batch.Count > 0) + { + yield return batch; + } + } + + private static bool IsSafeHistoricalGraphPath( + RepositoryInfo repository, + string repositoryRelativePath) + { + if (!TryGetSafeHistoricalPathComponents(repositoryRelativePath, out _)) + { + return false; + } + + string path = Path.Combine( + repository.RepoRoot, + repositoryRelativePath.Replace('/', Path.DirectorySeparatorChar)); + return RepositoryPathComparer.IsContainedWithin(repository.ProjectRoot, path); + } + + private static async Task ExtractHistoricalGraphArchiveAsync( + string archivePath, + string destinationRoot, + IReadOnlyDictionary expectedFiles, + CancellationToken cancellationToken) + { + await using var archive = new FileStream( + archivePath, + new FileStreamOptions + { + Mode = FileMode.Open, + Access = FileAccess.Read, + Share = FileShare.Read, + Options = FileOptions.Asynchronous | FileOptions.SequentialScan, + }); + await using var reader = new TarReader(archive, leaveOpen: false); + var extractedFiles = new HashSet(StringComparer.Ordinal); + TarEntry? entry; + while ((entry = await reader.GetNextEntryAsync( + copyData: false, + cancellationToken).ConfigureAwait(false)) is not null) + { + string entryName = entry.EntryType == TarEntryType.Directory + ? entry.Name.TrimEnd('/') + : entry.Name; + if (!TryGetSafeHistoricalPathComponents(entryName, out string[] components)) + { + throw new InvalidOperationException( + "The base commit archive contains an unsafe path."); + } + + string destination = Path.Combine([destinationRoot, .. components]); + if (!RepositoryPathComparer.IsContainedWithin(destinationRoot, destination)) + { + throw new InvalidOperationException( + "The base commit archive escaped its materialization root."); + } + + if (entry.EntryType == TarEntryType.Directory) + { + Directory.CreateDirectory(destination); + continue; + } + + if (entry.EntryType is not (TarEntryType.RegularFile or TarEntryType.V7RegularFile) + || !expectedFiles.TryGetValue(entryName, out long expectedLength) + || entry.Length != expectedLength + || entry.DataStream is null + || !extractedFiles.Add(entryName)) + { + throw new InvalidOperationException( + "The base commit archive did not match its validated file list."); + } + + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + await using var output = new FileStream( + destination, + new FileStreamOptions + { + Mode = FileMode.CreateNew, + Access = FileAccess.Write, + Share = FileShare.None, + Options = FileOptions.Asynchronous, + }); + await entry.DataStream.CopyToAsync(output, cancellationToken).ConfigureAwait(false); + await output.FlushAsync(cancellationToken).ConfigureAwait(false); + if (output.Length != expectedLength) + { + throw new InvalidOperationException( + "The base commit archive entry changed length during extraction."); + } + } + + if (!extractedFiles.SetEquals(expectedFiles.Keys)) + { + throw new InvalidOperationException( + "The base commit archive omitted a serialized project graph file."); + } + } + + private static bool TryGetSafeHistoricalPathComponents( + string path, + out string[] components) + { + components = path.Split('/'); + return !string.IsNullOrEmpty(path) + && path[0] != '/' + && (!OperatingSystem.IsWindows() || !path.Contains('\\')) + && components.All(static component => component is not ("" or "." or "..")); + } + + private static string GetMaterializedHistoricalPath(string root, string gitPath) + { + if (!TryGetSafeHistoricalPathComponents(gitPath, out string[] components)) + { + throw new InvalidOperationException( + "The historical project path is unsafe to materialize."); + } + + string result = Path.Combine([root, .. components]); + if (!RepositoryPathComparer.IsContainedWithin(root, result)) + { + throw new InvalidOperationException( + "The historical project path escaped its materialization root."); + } + + return result; + } + + private static string GetProjectPathFromLiteralPathspec( + RepositoryInfo repository, + string pathspec) + { + const string literalPrefix = ":(top,literal)"; + string repositoryRelativePath = pathspec[literalPrefix.Length..]; + string projectRelativePath = repository.Pathspec == "." + ? repositoryRelativePath + : repositoryRelativePath[(repository.Pathspec.Length + 1)..]; + return Path.Combine( + repository.ProjectRoot, + projectRelativePath.Replace('/', Path.DirectorySeparatorChar)); + } + + private static string GetRepositoryPathFromLiteralPathspec(string pathspec) + { + const string literalPrefix = ":(top,literal)"; + return pathspec[literalPrefix.Length..]; + } + + private static string CreateSnapshotBasePathspec(RepositoryInfo repository) + { + return repository.Pathspec == "." + ? "." + : $":(top,literal){repository.Pathspec}"; + } + + 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 readonly Dictionary> _historicalRequiredTemporaryPaths = + new(StringComparer.OrdinalIgnoreCase); + private readonly Func? _policyNoticeSink; + private readonly Func? _beforeHygieneFileReplace; + private readonly Func? _beforeHygieneFileCommit; + private readonly Func? _afterHygieneFileExchange; + private readonly Func _deleteVerifiedHygieneFile; + private readonly Action _statusNotificationScheduler; + private readonly Action _lockNotificationScheduler; + 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 IReadOnlySet _requiredTemporaryProjectPaths = + new HashSet(StringComparer.Ordinal); + 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, + afterHygieneFileExchange: null, + deleteVerifiedHygieneFile: null, + statusNotificationScheduler: null, + lockNotificationScheduler: 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, + afterHygieneFileExchange: null, + deleteVerifiedHygieneFile: null, + statusNotificationScheduler: null, + lockNotificationScheduler: null, + logger: null) + { + } + + internal GitCliVersionControlService( + GitInstallationLocator installationLocator, + RepositoryInfo? repository, + RepositoryWatcher? watcher, + Func runnerFactory, + ILogger? logger = null, + Func? beforeHygieneFileReplace = null, + Func? beforeHygieneFileCommit = null, + Func? afterHygieneFileExchange = null, + Func? deleteVerifiedHygieneFile = null, + Func? policyNoticeSink = null, + Action? statusNotificationScheduler = null, + Action? lockNotificationScheduler = null, + string? projectFile = null) + : this( + installationLocator, + repository, + watcher, + runnerFactory, + createWatcherWhenRepositoryAvailable: false, + isWorktreeMutationAllowed: static () => true, + projectFile: projectFile, + policyNoticeSink, + beforeHygieneFileReplace: beforeHygieneFileReplace, + beforeHygieneFileCommit: beforeHygieneFileCommit, + afterHygieneFileExchange: afterHygieneFileExchange, + deleteVerifiedHygieneFile: deleteVerifiedHygieneFile, + statusNotificationScheduler: statusNotificationScheduler, + lockNotificationScheduler: lockNotificationScheduler, + logger: logger) + { + } + + private GitCliVersionControlService( + GitInstallationLocator installationLocator, + RepositoryInfo? repository, + RepositoryWatcher? watcher, + Func runnerFactory, + bool createWatcherWhenRepositoryAvailable, + Func isWorktreeMutationAllowed, + string? projectFile, + Func? policyNoticeSink, + Func? beforeHygieneFileReplace, + Func? beforeHygieneFileCommit, + Func? afterHygieneFileExchange, + Func? deleteVerifiedHygieneFile, + Action? statusNotificationScheduler, + Action? lockNotificationScheduler, + 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; + _afterHygieneFileExchange = afterHygieneFileExchange; + _deleteVerifiedHygieneFile = deleteVerifiedHygieneFile + ?? TryDeleteVerifiedHygieneFile; + _statusNotificationScheduler = statusNotificationScheduler ?? ScheduleStatusNotificationDrain; + _lockNotificationScheduler = lockNotificationScheduler ?? ScheduleLockNotification; + _logger = logger ?? Log.CreateLogger(); + _createWatcherWhenRepositoryAvailable = createWatcherWhenRepositoryAvailable; + if (_watcher is not null) + { + if (repository is not null) + { + try + { + IReadOnlySet serializedPaths = + GetSerializedProjectRelativePaths(repository.ProjectRoot); + _requiredTemporaryProjectPaths = serializedPaths + .Where(static path => path.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase)) + .ToHashSet(StringComparer.Ordinal); + _watcher.UpdateRequiredPaths(serializedPaths); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + LogWarningBestEffort( + ex, + "The initial repository watcher paths could not be read from the serialized project graph."); + } + } + + _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( + RepositoryLockInfo expectedLock, + CancellationToken cancellationToken) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(expectedLock); + return RunSerializedAsync( + async () => + { + RepositoryInfo repository = GetRepository(); + IGitCliRunner runner = await GetInstalledRunnerCoreAsync(cancellationToken) + .ConfigureAwait(false); + RepositoryLockInfo? lockInfo = RecoverableLock; + if (!ReferenceEquals(lockInfo, expectedLock)) + { + return false; + } + + bool removed = runner.RemoveRecoverableRepositoryLock(repository, expectedLock); + 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 (Exception 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 (Exception 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); + SnapshotIndexCommandPlan indexPlan = await CreateSnapshotIndexCommandsAsync( + repository, + runner, + baseHead.Commit, + cancellationToken) + .ConfigureAwait(false); + foreach (IReadOnlyList command in indexPlan.Commands) + { + await runner.RunAsync( + repository, + command, + indexOptions with + { + ExecutionKind = GitCommandExecutionKind.LocalWithLfs, + UseLiteralPathspecs = false, + }, + cancellationToken) + .ConfigureAwait(false); + } + + GitCommandResult tree = await runner.RunAsync( + repository, + ["write-tree"], + indexOptions, + cancellationToken).ConfigureAwait(false); + string treeId = tree.Stdout.Trim(); + await EnsureSnapshotTreeContainsNoGitlinksAsync( + repository, + runner, + treeId, + cancellationToken) + .ConfigureAwait(false); + + cancellationToken.ThrowIfCancellationRequested(); + GitCommandResult commit = await runner.RunAsync( + repository, + [ + "commit-tree", + treeId, + "-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 static async Task CreateReservedPathCleanupCommitAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string tree, + string parentCommit, + CancellationToken cancellationToken) + { + GitCommandResult result = await runner.RunAsync( + repository, + [ + "commit-tree", + tree, + "-p", + parentCommit, + "-m", + "beutl: stop tracking reserved project state", + "-m", + "Beutl-Snapshot: init", + ], + GitCommandOptions.Local, + cancellationToken) + .ConfigureAwait(false); + string commit = result.Stdout.Trim(); + if (commit.Length == 0) + { + throw new InvalidOperationException( + "Git did not return the reserved-path cleanup 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 + && !ex.IsRepositoryLockFailure) + { + return null; + } + } + + private static async Task TryResolveCommitWithRetryAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string revision) + { + Exception? observationFailure = null; + for (int attempt = 0; attempt < 2; attempt++) + { + try + { + string? commit = await TryResolveCommitAsync( + repository, + runner, + revision, + CancellationToken.None) + .ConfigureAwait(false); + if (commit is not null) + { + return commit; + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + observationFailure = ex; + } + } + + if (observationFailure is not null) + { + throw observationFailure; + } + + 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 async Task BuildSnapshotTreeAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string? baseCommit, + CancellationToken cancellationToken) + { + string temporaryIndex = Path.Combine( + Path.GetTempPath(), + $"beutl-git-index-{Guid.NewGuid():N}"); + var indexOptions = new GitCommandOptions( + GitCommandExecutionKind.LocalWithLfs, + new Dictionary + { + ["GIT_INDEX_FILE"] = temporaryIndex, + }) + { + UseLiteralPathspecs = false, + }; + + try + { + await runner.RunAsync( + repository, + baseCommit is null + ? ["read-tree", "--empty"] + : ["read-tree", baseCommit], + indexOptions with { ExecutionKind = GitCommandExecutionKind.Local }, + cancellationToken) + .ConfigureAwait(false); + SnapshotIndexCommandPlan indexPlan = await CreateSnapshotIndexCommandsAsync( + repository, + runner, + baseCommit, + cancellationToken) + .ConfigureAwait(false); + foreach (IReadOnlyList command in indexPlan.Commands) + { + await runner.RunAsync( + repository, + command, + indexOptions, + cancellationToken) + .ConfigureAwait(false); + } + + GitCommandResult tree = await runner.RunAsync( + repository, + ["write-tree"], + indexOptions with { ExecutionKind = GitCommandExecutionKind.Local }, + cancellationToken) + .ConfigureAwait(false); + string treeId = tree.Stdout.Trim(); + GitRevisionValidator.ValidateCommitId(treeId, nameof(treeId)); + await EnsureSnapshotTreeContainsNoGitlinksAsync( + repository, + runner, + treeId, + cancellationToken) + .ConfigureAwait(false); + return new SnapshotTreeBuildResult( + treeId, + indexPlan.TemporaryPathspecsToReconcile); + } + finally + { + TryDeleteTemporaryIndex(temporaryIndex); + } + } + + private async Task BuildSnapshotTreeForCapturedHeadAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string branchRef, + string? expectedBranchTip, + CancellationToken cancellationToken) + { + using HeadOwnershipLease headLease = await AcquireSnapshotHeadLeaseAsync( + repository, + runner, + branchRef, + expectedBranchTip, + cancellationToken) + .ConfigureAwait(false); + string indexPath = await ResolveGitPathAsync( + repository, + runner, + "index", + cancellationToken) + .ConfigureAwait(false); + IndexFileSnapshot index = await CaptureIndexFileSnapshotAsync( + indexPath, + cancellationToken) + .ConfigureAwait(false); + SnapshotTreeBuildResult tree = await BuildSnapshotTreeAsync( + repository, + runner, + expectedBranchTip, + cancellationToken) + .ConfigureAwait(false); + return new SnapshotTreeCapture( + tree.Tree, + indexPath, + index, + tree.TemporaryPathspecsToReconcile); + } + + private async Task AcquireSnapshotHeadLeaseAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string branchRef, + string? expectedBranchTip, + CancellationToken cancellationToken) + { + string headPath = await ResolveGitPathAsync( + repository, + runner, + "HEAD", + cancellationToken) + .ConfigureAwait(false); + HeadOwnershipLease lease = HeadOwnershipLease.Acquire( + headPath, + branchRef, + ex => LogWarningBestEffort( + ex, + "Failed to release the protected Git HEAD lock after a snapshot operation.")); + try + { + string? currentBranchTip = await TryResolveCommitAsync( + repository, + runner, + branchRef, + cancellationToken) + .ConfigureAwait(false); + if (!string.Equals( + currentBranchTip, + expectedBranchTip, + StringComparison.OrdinalIgnoreCase)) + { + throw new ProjectCheckpointStateChangedException(); + } + + await EnsureNoExternalRepositoryOperationAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + return lease; + } + catch + { + lease.Dispose(); + throw; + } + } + + private static async Task EnsureSnapshotTreeContainsNoGitlinksAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string tree, + CancellationToken cancellationToken) + { + GitCommandResult entries = await runner.RunAsync( + repository, + ["ls-tree", "-r", "-z", tree, "--", repository.Pathspec], + GitCommandOptions.Local with + { + MaxStdoutBytes = MaxSnapshotTreeInspectionBytes, + }, + cancellationToken) + .ConfigureAwait(false); + if (entries.StdoutTruncated) + { + throw new InvalidOperationException( + "Git could not safely inspect the complete project tree for nested repositories."); + } + + string? gitlink = GitCliRunner.SplitNullSeparated(entries.Stdout) + .FirstOrDefault(static entry => entry.StartsWith("160000 ", StringComparison.Ordinal)); + if (gitlink is null) + { + return; + } + + int pathSeparator = gitlink.IndexOf('\t'); + string path = pathSeparator >= 0 ? gitlink[(pathSeparator + 1)..] : gitlink; + throw new InvalidOperationException( + $"The nested Git repository '{path}' cannot be snapshotted safely."); + } + + private async Task CreateSnapshotCommitAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string tree, + string? parentCommit, + string message, + SnapshotKind kind, + CancellationToken cancellationToken) + { + string temporaryIndex = Path.Combine( + Path.GetTempPath(), + $"beutl-git-hook-index-{Guid.NewGuid():N}"); + string? messagePath = null; + bool retainMessage = false; + + try + { + SnapshotIdentity author = await ResolveSnapshotIdentityAsync( + repository, + runner, + "GIT_AUTHOR_IDENT", + "author", + cancellationToken) + .ConfigureAwait(false); + SnapshotIdentity committer = await ResolveSnapshotIdentityAsync( + repository, + runner, + "GIT_COMMITTER_IDENT", + "committer", + cancellationToken) + .ConfigureAwait(false); + CommitCleanupMode cleanupMode = await ResolveCommitCleanupModeAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + bool signCommit = kind == SnapshotKind.Manual + && await IsCommitSigningEnabledAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + string standardMessagePath = await ResolveGitPathAsync( + repository, + runner, + "COMMIT_EDITMSG", + cancellationToken) + .ConfigureAwait(false); + messagePath = Path.Combine( + Path.GetDirectoryName(standardMessagePath) + ?? throw new InvalidOperationException( + "The Git commit-message path has no parent directory."), + $"beutl-commit-message-{Guid.NewGuid():N}.tmp"); + var hookEnvironment = new Dictionary + { + ["GIT_INDEX_FILE"] = temporaryIndex, + ["GIT_EDITOR"] = ":", + ["GIT_COMMIT_EDITMSG"] = messagePath, + ["GIT_AUTHOR_NAME"] = author.Name, + ["GIT_AUTHOR_EMAIL"] = author.Email, + ["GIT_AUTHOR_DATE"] = author.Date, + ["GIT_COMMITTER_NAME"] = committer.Name, + ["GIT_COMMITTER_EMAIL"] = committer.Email, + ["GIT_COMMITTER_DATE"] = committer.Date, + }; + var hookOptions = new GitCommandOptions( + GitCommandExecutionKind.Local, + hookEnvironment); + byte[] initialMessage = await StripCommitMessageAsync( + repository, + runner, + new UTF8Encoding(false).GetBytes( + CreateSnapshotCommitMessage(message, kind)), + cleanupMode == CommitCleanupMode.Verbatim + ? CommitCleanupMode.Verbatim + : CommitCleanupMode.Whitespace, + commentChar: null, + hookOptions, + cancellationToken) + .ConfigureAwait(false); + char commentChar = cleanupMode == CommitCleanupMode.Strip + ? await ResolveCommitCommentCharAsync( + repository, + runner, + initialMessage, + cancellationToken) + .ConfigureAwait(false) + : '#'; + await runner.RunAsync( + repository, + ["read-tree", tree], + hookOptions, + cancellationToken) + .ConfigureAwait(false); + await RunCommitHookAsync( + repository, + runner, + "pre-commit", + [], + hookOptions, + cancellationToken) + .ConfigureAwait(false); + + await WriteCommitMessageAsync( + messagePath, + initialMessage, + createNew: true, + cancellationToken) + .ConfigureAwait(false); + await RunCommitHookAsync( + repository, + runner, + "prepare-commit-msg", + [messagePath, "message"], + hookOptions, + cancellationToken) + .ConfigureAwait(false); + + await RunCommitHookAsync( + repository, + runner, + "commit-msg", + [messagePath], + hookOptions, + cancellationToken) + .ConfigureAwait(false); + byte[] finalMessage = await StripCommitMessageAsync( + repository, + runner, + await ReadCommitMessageAsync(messagePath, cancellationToken) + .ConfigureAwait(false), + cleanupMode, + commentChar, + hookOptions, + cancellationToken) + .ConfigureAwait(false); + if (IsEmptyCommitMessage(finalMessage)) + { + throw new InvalidOperationException( + "The snapshot commit message was empty after commit hooks ran."); + } + + await WriteCommitMessageAsync( + messagePath, + finalMessage, + createNew: false, + cancellationToken) + .ConfigureAwait(false); + + GitCommandResult hookTreeResult = await runner.RunAsync( + repository, + ["write-tree"], + hookOptions, + cancellationToken) + .ConfigureAwait(false); + string hookTree = hookTreeResult.Stdout.Trim(); + GitRevisionValidator.ValidateCommitId(hookTree, nameof(hookTree)); + await ValidateHookModifiedSnapshotTreeAsync( + repository, + runner, + tree, + hookTree, + cancellationToken) + .ConfigureAwait(false); + bool isEmptyCommit; + if (parentCommit is null) + { + GitCommandResult entries = await runner.RunAsync( + repository, + ["ls-tree", "-r", "-z", hookTree], + GitCommandOptions.Local with { MaxStdoutBytes = 1 }, + cancellationToken) + .ConfigureAwait(false); + isEmptyCommit = !entries.StdoutTruncated && entries.Stdout.Length == 0; + } + else + { + string parentTree = await ResolveTreeAsync( + repository, + runner, + parentCommit, + cancellationToken) + .ConfigureAwait(false); + isEmptyCommit = string.Equals( + hookTree, + parentTree, + StringComparison.OrdinalIgnoreCase); + } + + if (isEmptyCommit) + { + if (kind == SnapshotKind.Init) + { + throw new InvalidOperationException( + "A commit hook removed every change from the initial snapshot."); + } + + return null; + } + + var arguments = new List + { + "commit-tree", + hookTree, + }; + if (parentCommit is not null) + { + arguments.Add("-p"); + arguments.Add(parentCommit); + } + + if (signCommit) + { + arguments.Add("-S"); + } + else if (kind != SnapshotKind.Manual) + { + arguments.Add("--no-gpg-sign"); + } + + arguments.Add("-F"); + arguments.Add(messagePath); + + cancellationToken.ThrowIfCancellationRequested(); + GitCommandResult commit = await runner.RunAsync( + repository, + arguments, + hookOptions, + CancellationToken.None) + .ConfigureAwait(false); + string commitId = commit.Stdout.Trim(); + GitRevisionValidator.ValidateCommitId(commitId, nameof(commitId)); + retainMessage = true; + return new SnapshotCommit( + commitId, + hookTree, + messagePath, + author, + committer); + } + finally + { + TryDeleteTemporaryIndex(temporaryIndex); + if (!retainMessage && messagePath is not null) + { + TryDeleteTemporaryIndex(messagePath); + } + } + } + + private static string CreateSnapshotCommitMessage(string message, SnapshotKind kind) + { + return $"{message}\n\nBeutl-Snapshot: {kind.ToString().ToLowerInvariant()}\n"; + } + + private static async Task ReadCommitMessageAsync( + string messagePath, + CancellationToken cancellationToken) + { + var file = new FileInfo(messagePath); + file.Refresh(); + if (!file.Exists || file.Length > MaxCommitMessageBytes) + { + throw new InvalidOperationException( + "The snapshot commit hook produced an invalid commit message file."); + } + + await using var stream = new FileStream( + messagePath, + new FileStreamOptions + { + Mode = FileMode.Open, + Access = FileAccess.Read, + Share = FileShare.Read, + Options = FileOptions.Asynchronous | FileOptions.SequentialScan, + }); + var contents = new byte[MaxCommitMessageBytes + 1]; + int count = 0; + while (count < contents.Length) + { + int read = await stream.ReadAsync(contents.AsMemory(count), cancellationToken) + .ConfigureAwait(false); + if (read == 0) + { + break; + } + + count += read; + } + + if (count > MaxCommitMessageBytes) + { + throw new InvalidOperationException( + "The snapshot commit hook produced an invalid commit message file."); + } + + Array.Resize(ref contents, count); + return contents; + } + + private static async Task WriteCommitMessageAsync( + string messagePath, + byte[] contents, + bool createNew, + CancellationToken cancellationToken) + { + if (contents.Length > MaxCommitMessageBytes) + { + throw new InvalidOperationException( + "The snapshot commit message exceeded its safety limit."); + } + + await using var stream = new FileStream( + messagePath, + new FileStreamOptions + { + Mode = createNew ? FileMode.CreateNew : FileMode.Create, + Access = FileAccess.Write, + Share = FileShare.None, + Options = FileOptions.Asynchronous | FileOptions.WriteThrough, + }); + await stream.WriteAsync(contents, cancellationToken).ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + private static async Task StripCommitMessageAsync( + RepositoryInfo repository, + IGitCliRunner runner, + byte[] message, + CommitCleanupMode cleanupMode, + char? commentChar, + GitCommandOptions options, + CancellationToken cancellationToken) + { + if (message.Length > MaxCommitMessageBytes) + { + throw new InvalidOperationException( + "The snapshot commit message exceeded its safety limit."); + } + + if (cleanupMode == CommitCleanupMode.Verbatim) + { + return message; + } + + IReadOnlyList arguments = cleanupMode == CommitCleanupMode.Strip + ? ["-c", $"core.commentChar={commentChar ?? '#'}", "stripspace", "--strip-comments"] + : ["stripspace"]; + GitCommandResult stripped = await runner.RunAsync( + repository, + arguments, + options with + { + MaxStdoutBytes = MaxCommitMessageBytes, + StandardInputBytes = message, + CaptureStdoutBytes = true, + }, + cancellationToken) + .ConfigureAwait(false); + if (stripped.StdoutTruncated) + { + throw new InvalidOperationException( + "The snapshot commit message exceeded its safety limit."); + } + + return stripped.StdoutBytes + ?? throw new InvalidOperationException( + "Git did not return the cleaned snapshot commit message bytes."); + } + + private static bool IsEmptyCommitMessage(byte[] message) + { + // Git runs under LC_ALL=C and treats only ASCII whitespace as empty. Non-ASCII bytes are + // message content regardless of i18n.commitEncoding. + return message.All(static value => value is (byte)' ' + or (byte)'\t' + or (byte)'\r' + or (byte)'\n' + or (byte)'\v' + or (byte)'\f'); + } + + private static async Task ResolveCommitCommentCharAsync( + RepositoryInfo repository, + IGitCliRunner runner, + byte[] initialMessage, + CancellationToken cancellationToken) + { + string configured; + try + { + GitCommandResult result = await runner.RunAsync( + repository, + ["config", "--get", "core.commentChar"], + GitCommandOptions.Local, + cancellationToken) + .ConfigureAwait(false); + configured = result.Stdout.TrimEnd('\r', '\n'); + } + catch (GitOperationException ex) when (ex.ExitCode == 1) + { + return '#'; + } + + if (!string.Equals(configured, "auto", StringComparison.OrdinalIgnoreCase)) + { + if (configured.Length != 1 || char.IsControl(configured[0])) + { + throw new InvalidOperationException( + "Git returned an invalid core.commentChar value."); + } + + return configured[0]; + } + + const string Candidates = "#;@!$%^&|:"; + foreach (char candidate in Candidates) + { + byte candidateByte = checked((byte)candidate); + bool startsLine = false; + for (int i = 0; i < initialMessage.Length; i++) + { + if ((i == 0 || initialMessage[i - 1] == (byte)'\n') + && initialMessage[i] == candidateByte) + { + startsLine = true; + break; + } + } + + if (!startsLine) + { + return candidate; + } + } + + throw new InvalidOperationException( + "Git could not select an automatic commit comment character."); + } + + private static async Task ResolveCommitCleanupModeAsync( + RepositoryInfo repository, + IGitCliRunner runner, + CancellationToken cancellationToken) + { + try + { + GitCommandResult result = await runner.RunAsync( + repository, + ["config", "--get", "commit.cleanup"], + GitCommandOptions.Local, + cancellationToken) + .ConfigureAwait(false); + return result.Stdout.Trim().ToLowerInvariant() switch + { + "" or "default" or "whitespace" or "scissors" => + CommitCleanupMode.Whitespace, + "strip" => CommitCleanupMode.Strip, + "verbatim" => CommitCleanupMode.Verbatim, + var value => throw new InvalidOperationException( + $"Git returned an unsupported commit.cleanup value: '{value}'."), + }; + } + catch (GitOperationException ex) when (ex.ExitCode == 1) + { + return CommitCleanupMode.Whitespace; + } + } + + private static async Task ResolveSnapshotIdentityAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string variable, + string description, + CancellationToken cancellationToken) + { + GitCommandResult result = await runner.RunAsync( + repository, + ["var", variable], + GitCommandOptions.Local, + cancellationToken) + .ConfigureAwait(false); + string ident = result.Stdout.TrimEnd('\r', '\n'); + int closeBracket = ident.LastIndexOf('>'); + int openBracket = closeBracket < 0 + ? -1 + : ident.LastIndexOf('<', closeBracket); + string name = openBracket <= 0 ? string.Empty : ident[..openBracket].TrimEnd(); + string email = openBracket < 0 || closeBracket <= openBracket + ? string.Empty + : ident[(openBracket + 1)..closeBracket]; + string date = closeBracket < 0 ? string.Empty : ident[(closeBracket + 1)..].Trim(); + if (string.IsNullOrWhiteSpace(name) + || string.IsNullOrWhiteSpace(email) + || string.IsNullOrWhiteSpace(date) + || name.Any(char.IsControl) + || email.Any(char.IsControl) + || date.Any(char.IsControl)) + { + throw new InvalidOperationException( + $"Git returned an invalid snapshot {description} identity."); + } + + return new SnapshotIdentity(name, email, date); + } + + private static Task RunCommitHookAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string hookName, + IReadOnlyList hookArguments, + GitCommandOptions options, + CancellationToken cancellationToken) + { + var arguments = new List + { + "hook", + "run", + "--ignore-missing", + hookName, + }; + if (hookArguments.Count > 0) + { + arguments.Add("--"); + arguments.AddRange(hookArguments); + } + + return RunHookCoreAsync(); + + async Task RunHookCoreAsync() + { + await runner.RunAsync( + repository, + arguments, + options, + cancellationToken) + .ConfigureAwait(false); + } + } + + private async Task RunPostCommitHookBestEffortAsync( + RepositoryInfo repository, + IGitCliRunner runner, + SnapshotCommit commit, + string indexPath) + { + try + { + await RunCommitHookAsync( + repository, + runner, + "post-commit", + [], + new GitCommandOptions( + GitCommandExecutionKind.Local, + new Dictionary + { + ["GIT_EDITOR"] = ":", + ["GIT_INDEX_FILE"] = indexPath, + ["GIT_COMMIT_EDITMSG"] = commit.MessagePath, + ["GIT_AUTHOR_NAME"] = commit.Author.Name, + ["GIT_AUTHOR_EMAIL"] = commit.Author.Email, + ["GIT_AUTHOR_DATE"] = commit.Author.Date, + ["GIT_COMMITTER_NAME"] = commit.Committer.Name, + ["GIT_COMMITTER_EMAIL"] = commit.Committer.Email, + ["GIT_COMMITTER_DATE"] = commit.Committer.Date, + }), + CancellationToken.None) + .ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + // post-commit cannot reject a commit that is already durable. Match Git's one-way + // lifecycle: report the hook failure for diagnostics without making callers retry. + LogWarningBestEffort( + ex, + "The post-commit hook failed after the snapshot commit became durable."); + } + } + + private bool ReleaseSnapshotHeadLeaseForPostCommit(HeadOwnershipLease headLease) + { + try + { + headLease.VerifyStillOwned(); + return true; + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + LogWarningBestEffort( + ex, + "The post-commit hook was skipped because the checked-out branch changed after the snapshot became durable."); + return false; + } + finally + { + headLease.Dispose(); + } + } + + private async Task ValidateHookModifiedSnapshotTreeAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string originalTree, + string hookTree, + CancellationToken cancellationToken) + { + await EnsureSnapshotTreeContainsNoGitlinksAsync( + repository, + runner, + hookTree, + cancellationToken) + .ConfigureAwait(false); + if (string.Equals(originalTree, hookTree, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + GitCommandResult changed = await runner.RunAsync( + repository, + [ + "diff-tree", + "--no-commit-id", + "--name-only", + "-r", + "-z", + originalTree, + hookTree, + ], + GitCommandOptions.Local with + { + MaxStdoutBytes = MaxSnapshotTreeInspectionBytes, + }, + cancellationToken) + .ConfigureAwait(false); + IReadOnlyList changedPaths = GitCliRunner.SplitNullSeparated(changed.Stdout); + if (changed.StdoutTruncated + || changedPaths.Any(path => !IsHookWritableSnapshotPath(repository, path))) + { + throw new InvalidOperationException( + "A commit hook changed content outside the safe project snapshot scope."); + } + + GitCommandResult entries = await runner.RunAsync( + repository, + ["ls-tree", "-r", "-z", hookTree, "--", repository.Pathspec], + GitCommandOptions.Local with + { + MaxStdoutBytes = MaxSnapshotTreeInspectionBytes, + }, + cancellationToken) + .ConfigureAwait(false); + if (entries.StdoutTruncated) + { + throw new InvalidOperationException( + "A commit hook produced a project tree that exceeded its safety limit."); + } + + var finalModes = new Dictionary(StringComparer.Ordinal); + foreach (string entry in GitCliRunner.SplitNullSeparated(entries.Stdout)) + { + int metadataSeparator = entry.IndexOf('\t'); + int modeSeparator = entry.IndexOf(' '); + if (metadataSeparator <= 0 || modeSeparator <= 0 || modeSeparator > metadataSeparator) + { + throw new InvalidOperationException( + "A commit hook produced an invalid project tree entry."); + } + + finalModes[entry[(metadataSeparator + 1)..]] = entry[..modeSeparator]; + } + + if (changedPaths.Any(path => + !finalModes.TryGetValue(path, out string? mode) + || mode is not ("100644" or "100755"))) + { + throw new InvalidOperationException( + "A commit hook removed content or introduced a non-regular project tree entry."); + } + + await ValidateFinalHookProjectGraphAsync( + repository, + runner, + hookTree, + finalModes, + cancellationToken) + .ConfigureAwait(false); + } + + private bool IsHookWritableSnapshotPath( + RepositoryInfo repository, + string repositoryRelativePath) + { + string projectRelativePath; + if (repository.Pathspec == ".") + { + projectRelativePath = repositoryRelativePath; + } + else + { + string prefix = repository.Pathspec + "/"; + if (!repositoryRelativePath.StartsWith(prefix, StringComparison.Ordinal)) + { + return false; + } + + projectRelativePath = repositoryRelativePath[prefix.Length..]; + } + + if (projectRelativePath + .Split('/', StringSplitOptions.RemoveEmptyEntries) + .Any(static segment => string.Equals( + segment, + ".beutl", + StringComparison.OrdinalIgnoreCase))) + { + return false; + } + + return !projectRelativePath.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase) + || _requiredTemporaryProjectPaths.Any(requiredPath => + AreSameProjectRelativePath( + repository.ProjectRoot, + requiredPath, + projectRelativePath)); + } + + private async Task ValidateFinalHookProjectGraphAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string hookTree, + IReadOnlyDictionary finalModes, + CancellationToken cancellationToken) + { + if (_projectFile is null + || !RepositoryPathComparer.IsContainedWithin(repository.ProjectRoot, _projectFile)) + { + return; + } + + string projectFileRepositoryPath = NormalizeGitPath( + Path.GetRelativePath(repository.RepoRoot, _projectFile)); + if (!finalModes.TryGetValue(projectFileRepositoryPath, out string? projectMode) + || projectMode is not ("100644" or "100755")) + { + throw new InvalidOperationException( + "A commit hook removed the project file from the snapshot tree."); + } + + string temporaryRoot = Path.Combine( + Path.GetTempPath(), + $"beutl-hook-graph-{Guid.NewGuid():N}"); + string materializedRepositoryRoot = Path.Combine(temporaryRoot, "tree"); + try + { + Directory.CreateDirectory(materializedRepositoryRoot); + Dictionary graphFiles = await ListHistoricalGraphFilesAsync( + repository, + runner, + hookTree, + projectFileRepositoryPath, + cancellationToken) + .ConfigureAwait(false); + await MaterializeHistoricalGraphFilesAsync( + repository, + runner, + hookTree, + materializedRepositoryRoot, + graphFiles, + cancellationToken) + .ConfigureAwait(false); + string materializedProjectRoot = repository.Pathspec == "." + ? materializedRepositoryRoot + : GetMaterializedHistoricalPath( + materializedRepositoryRoot, + repository.Pathspec); + string materializedProjectFile = GetMaterializedHistoricalPath( + materializedRepositoryRoot, + projectFileRepositoryPath); + ValidateNoReservedProjectReferences(materializedProjectFile); + IReadOnlySet serializedPaths = SerializedProjectGraph.GetRelativePaths( + materializedProjectFile, + materializedProjectRoot); + string[] finalTemporaryPaths = serializedPaths + .Where(static path => path.EndsWith( + ".tmp", + StringComparison.OrdinalIgnoreCase)) + .ToArray(); + if (finalTemporaryPaths.Length != _requiredTemporaryProjectPaths.Count + || finalTemporaryPaths.Any(finalPath => + !_requiredTemporaryProjectPaths.Any(currentPath => + AreSameProjectRelativePath( + repository.ProjectRoot, + finalPath, + currentPath))) + || _requiredTemporaryProjectPaths.Any(currentPath => + !finalTemporaryPaths.Any(finalPath => + AreSameProjectRelativePath( + repository.ProjectRoot, + currentPath, + finalPath)))) + { + throw new InvalidOperationException( + "A commit hook changed the set of required temporary project files."); + } + + foreach (string projectRelativePath in serializedPaths) + { + string repositoryRelativePath = repository.Pathspec == "." + ? projectRelativePath + : repository.Pathspec + "/" + projectRelativePath; + if (!finalModes.TryGetValue(repositoryRelativePath, out string? mode) + || mode is not ("100644" or "100755")) + { + throw new InvalidOperationException( + $"A commit hook left required project content '{projectRelativePath}' out of the snapshot tree."); + } + } + } + finally + { + TryDeleteHistoricalGraphDirectory(temporaryRoot); + } + } + + private static async Task IsCommitSigningEnabledAsync( + RepositoryInfo repository, + IGitCliRunner runner, + CancellationToken cancellationToken) + { + try + { + GitCommandResult result = await runner.RunAsync( + repository, + ["config", "--bool", "--get", "commit.gpgSign"], + GitCommandOptions.Local, + cancellationToken) + .ConfigureAwait(false); + return result.Stdout.Trim() switch + { + "true" => true, + "false" or "" => false, + var value => throw new InvalidOperationException( + $"Git returned an invalid commit.gpgSign value: '{value}'."), + }; + } + catch (GitOperationException ex) when (ex.ExitCode == 1) + { + return false; + } + } + + private async Task PublishSnapshotCommitAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string branchRef, + string? expectedOldCommit, + string commit, + string reflogMessage) + { + try + { + await runner.RunAsync( + repository, + [ + "update-ref", + "--create-reflog", + "-m", + reflogMessage, + branchRef, + commit, + expectedOldCommit ?? string.Empty, + ], + GitCommandOptions.Local, + CancellationToken.None) + .ConfigureAwait(false); + return; + } + catch (Exception publicationException) + { + string? observedCommit; + try + { + observedCommit = await TryResolveCommitWithRetryAsync( + repository, + runner, + branchRef) + .ConfigureAwait(false); + } + catch (Exception observationException) + { + throw new AggregateException( + $"The snapshot ref '{branchRef}' could not be published or observed safely.", + publicationException, + observationException); + } + + if (string.Equals(observedCommit, commit, StringComparison.OrdinalIgnoreCase)) + { + LogWarningBestEffort( + publicationException, + "Git reported a snapshot publication failure after the captured branch was updated."); + return; + } + + if (string.Equals( + observedCommit, + expectedOldCommit, + StringComparison.OrdinalIgnoreCase)) + { + throw; + } + + throw new AggregateException( + $"The captured branch '{branchRef}' changed before the snapshot could be published.", + publicationException, + new ProjectCheckpointStateChangedException()); + } + } + + private async Task PublishSnapshotAndReconcileIndexAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string branchRef, + string? expectedOldCommit, + string commit, + SnapshotTreeCapture snapshot, + HeadOwnershipLease headLease, + string reflogMessage, + CancellationToken cancellationToken) + { + string refUpdateWorktreePath = Path.Combine( + Path.GetTempPath(), + $"beutl-git-ref-update-{Guid.NewGuid():N}"); + var refUpdateRepository = new RepositoryInfo( + refUpdateWorktreePath, + refUpdateWorktreePath); + cancellationToken.ThrowIfCancellationRequested(); + try + { + await runner.RunAsync( + repository, + [ + "worktree", + "add", + "--detach", + "--no-checkout", + refUpdateWorktreePath, + commit, + ], + GitCommandOptions.Local, + CancellationToken.None) + .ConfigureAwait(false); + + headLease.VerifyStillOwned(); + await EnsureNoExternalRepositoryOperationAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + IndexFileSnapshot indexAfter = await TransformIndexSnapshotAsync( + repository, + runner, + snapshot.IndexPath, + snapshot.Index, + CreateSnapshotIndexReconciliationCommands( + repository, + commit, + snapshot.TemporaryPathspecsToReconcile), + new GitCommandOptions(GitCommandExecutionKind.Local) + { + UseLiteralPathspecs = false, + }, + $"The Git index '{snapshot.IndexPath}' changed after the snapshot tree was captured; the live index was left untouched.", + cancellationToken) + .ConfigureAwait(false); + try + { + headLease.VerifyStillOwned(); + await PublishSnapshotCommitAsync( + refUpdateRepository, + runner, + branchRef, + expectedOldCommit, + commit, + reflogMessage) + .ConfigureAwait(false); + } + catch (Exception publicationException) + { + try + { + await RestoreFailedIndexSnapshotAsync( + snapshot.IndexPath, + snapshot.Index, + indexAfter) + .ConfigureAwait(false); + } + catch (Exception restoreException) + { + throw new AggregateException( + "The snapshot could not be published and the prior index could not be restored.", + publicationException, + restoreException); + } + + throw; + } + + CacheHistoricalRequiredTemporaryPaths( + commit, + _requiredTemporaryProjectPaths); + } + finally + { + await RemoveRefUpdateWorktreeBestEffortAsync( + repository, + runner, + refUpdateWorktreePath) + .ConfigureAwait(false); + } + } + + 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[] 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 VersionControlPathComparison.IsSameOrDescendant(repositoryRoot, fullPath) + && (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 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) + .Where(path => !IsRequiredTemporaryRepositoryPath(repository, path)) + .ToArray(); + } + + private bool IsRequiredTemporaryRepositoryPath( + RepositoryInfo repository, + string repositoryRelativePath) + { + string repositoryPath = Path.Combine( + repository.RepoRoot, + repositoryRelativePath.Replace('/', Path.DirectorySeparatorChar)); + if (!RepositoryPathComparer.IsContainedWithin(repository.ProjectRoot, repositoryPath)) + { + return false; + } + + foreach (string requiredPath in _requiredTemporaryProjectPaths) + { + string requiredProjectPath = Path.Combine( + repository.ProjectRoot, + requiredPath.Replace('/', Path.DirectorySeparatorChar)); + if (VersionControlPathComparison.AreSameCanonicalPath(repositoryPath, requiredProjectPath)) + { + return true; + } + } + + return false; + } + + // .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? temporaryIndex = null; + string? refUpdateWorktreePath = null; + bool cleanupRefPublished = false; + try + { + string branchRef = await GetAttachedBranchRefCoreAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + CheckedOutBranchTip expectedHead = await GetCheckedOutBranchTipCoreAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + if (!string.Equals(branchRef, expectedHead.RefName, StringComparison.Ordinal)) + { + throw new ProjectCheckpointStateChangedException(); + } + + await EnsureNoExternalRepositoryOperationAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + refUpdateWorktreePath = Path.Combine( + Path.GetTempPath(), + $"beutl-git-ref-update-{Guid.NewGuid():N}"); + await runner.RunAsync( + repository, + [ + "worktree", + "add", + "--detach", + "--no-checkout", + refUpdateWorktreePath, + expectedHead.Commit, + ], + GitCommandOptions.Local, + cancellationToken) + .ConfigureAwait(false); + var refUpdateRepository = new RepositoryInfo( + refUpdateWorktreePath, + refUpdateWorktreePath); + + string headPath = await ResolveGitPathAsync( + repository, + runner, + "HEAD", + cancellationToken) + .ConfigureAwait(false); + using HeadOwnershipLease headLease = HeadOwnershipLease.Acquire( + headPath, + expectedHead.RefName, + ex => LogWarningBestEffort( + ex, + "Failed to release the protected Git HEAD lock while untracking reserved project paths.")); + + temporaryIndex = Path.Combine( + Path.GetTempPath(), + $"beutl-git-index-{Guid.NewGuid():N}"); + var indexOptions = new GitCommandOptions( + GitCommandExecutionKind.Local, + new Dictionary + { + ["GIT_INDEX_FILE"] = temporaryIndex, + }); + await runner.RunAsync( + repository, + ["read-tree", expectedHead.Commit], + indexOptions, + cancellationToken) + .ConfigureAwait(false); + + var removeArguments = new List + { + "update-index", + "--force-remove", + "--", + }; + removeArguments.AddRange(reservedPaths); + await runner.RunAsync( + repository, + removeArguments, + indexOptions, + cancellationToken) + .ConfigureAwait(false); + + GitCommandResult desiredTreeResult = await runner.RunAsync( + repository, + ["write-tree"], + indexOptions, + cancellationToken) + .ConfigureAwait(false); + string desiredTree = desiredTreeResult.Stdout.Trim(); + string currentTree = await ResolveTreeAsync( + repository, + runner, + expectedHead.Commit, + cancellationToken) + .ConfigureAwait(false); + + // If the reserved paths are only staged additions, there is no tree change to publish. + // Do not mutate the live index: a detached ref movement can race this no-op and the + // staged-only additions belong to the caller, not to reserved-path hygiene. + if (string.Equals(desiredTree, currentTree, StringComparison.OrdinalIgnoreCase)) + { + if (await IsReservedPathCleanupCommitAsync( + repository, + runner, + expectedHead.Commit, + reservedPaths, + cancellationToken) + .ConfigureAwait(false)) + { + _logger.LogInformation( + "The reserved-path cleanup commit is already durable; reconciling only the live index when its ownership can be proven."); + await ReconcileReservedPathsInLiveIndexAsync( + repository, + runner, + refUpdateRepository, + expectedHead.RefName, + expectedHead.Commit, + removeArguments, + cancellationToken) + .ConfigureAwait(false); + } + else + { + _logger.LogInformation( + "Reserved project paths are staged additions with no cleanup commit; leaving the live index untouched."); + } + + return; + } + + string cleanupCommit = await CreateReservedPathCleanupCommitAsync( + repository, + runner, + desiredTree, + expectedHead.Commit, + cancellationToken: cancellationToken) + .ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + await VerifyUntrackHeadOwnershipAsync( + repository, + runner, + expectedHead) + .ConfigureAwait(false); + await EnsureNoExternalRepositoryOperationAsync( + repository, + runner, + CancellationToken.None) + .ConfigureAwait(false); + + try + { + await runner.RunAsync( + refUpdateRepository, + [ + "update-ref", + "-m", + "beutl: stop tracking reserved project state", + expectedHead.RefName, + cleanupCommit, + expectedHead.Commit, + ], + GitCommandOptions.Local, + CancellationToken.None) + .ConfigureAwait(false); + cleanupRefPublished = true; + } + catch (Exception publicationException) + { + string? observedTip; + try + { + observedTip = await TryResolveCommitWithRetryAsync( + refUpdateRepository, + runner, + expectedHead.RefName) + .ConfigureAwait(false); + } + catch (Exception observationException) + { + if (observationException is GitOperationException + { + IsRepositoryLockFailure: true, + } observationLockException) + { + throw observationLockException; + } + + throw new AggregateException( + "The reserved-path cleanup ref update failed and its result could not be observed after a retry.", + publicationException, + observationException); + } + + if (!string.Equals( + observedTip, + cleanupCommit, + StringComparison.OrdinalIgnoreCase)) + { + if (publicationException is GitOperationException + { + IsRepositoryLockFailure: true, + } lockException + && string.Equals( + observedTip, + expectedHead.Commit, + StringComparison.OrdinalIgnoreCase)) + { + throw lockException; + } + + throw new AggregateException( + "The reserved-path cleanup ref update was not published because the branch tip changed.", + publicationException, + new InvalidOperationException( + $"Expected branch '{expectedHead.RefName}' at '{expectedHead.Commit}', but observed '{observedTip ?? ""}'.")); + } + + cleanupRefPublished = true; + } + + string? reconciledTip = await TryResolveCommitWithRetryAsync( + refUpdateRepository, + runner, + expectedHead.RefName) + .ConfigureAwait(false); + if (!string.Equals( + reconciledTip, + cleanupCommit, + StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning( + "Reserved-path cleanup was published, but branch {Branch} moved to {ObservedTip} before the live index could be reconciled; leaving the index untouched.", + expectedHead.RefName, + reconciledTip ?? ""); + return; + } + + await ReconcileReservedPathsInLiveIndexAsync( + repository, + runner, + refUpdateRepository, + expectedHead.RefName, + cleanupCommit, + removeArguments, + CancellationToken.None) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // All tree construction happens in the temporary index. Cancellation before the ref + // publication therefore leaves both the live index and the branch untouched. + throw; + } + catch (GitOperationException ex) when (ex.IsRepositoryLockFailure) + { + // Preserve lock failures for the serialized-operation boundary, which records the + // recoverable lock instead of silently leaving a stale HEAD.lock/index.lock behind. + throw; + } + catch (Exception ex) + { + LogWarningBestEffort( + ex, + cleanupRefPublished + ? "Reserved-path cleanup was committed, but the live index could not be reconciled safely." + : "Could not stop tracking reserved project paths; pulls will report the repository dirty until they are untracked manually."); + } + finally + { + if (refUpdateWorktreePath is not null) + { + await RemoveRefUpdateWorktreeBestEffortAsync( + repository, + runner, + refUpdateWorktreePath) + .ConfigureAwait(false); + } + + if (temporaryIndex is not null) + { + TryDeleteTemporaryIndex(temporaryIndex); + } + } + } + + private static async Task VerifyUntrackHeadOwnershipAsync( + RepositoryInfo repository, + IGitCliRunner runner, + CheckedOutBranchTip expectedHead) + { + CheckedOutBranchTip actualHead = await GetCheckedOutBranchTipCoreAsync( + repository, + runner, + CancellationToken.None) + .ConfigureAwait(false); + if (!EqualsBranchTip(actualHead, expectedHead)) + { + throw new ProjectCheckpointStateChangedException(); + } + } + + private static async Task IsReservedPathCleanupCommitAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string commit, + IReadOnlyList reservedPaths, + CancellationToken cancellationToken) + { + var historyArguments = new List + { + "log", + "--first-parent", + "--format=%H", + "-z", + "--max-count=128", + commit, + "--", + }; + historyArguments.AddRange(reservedPaths); + GitCommandResult history = await runner.RunAsync( + repository, + historyArguments, + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + foreach (string candidate in history.Stdout.Split( + '\0', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (await IsExactReservedPathCleanupCommitAsync( + repository, + runner, + candidate, + reservedPaths, + cancellationToken) + .ConfigureAwait(false)) + { + return true; + } + } + + return false; + } + + private static async Task IsExactReservedPathCleanupCommitAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string commit, + IReadOnlyList reservedPaths, + CancellationToken cancellationToken) + { + GitCommandResult message = await runner.RunAsync( + repository, + ["show", "-s", "--format=%s%n%b", commit], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + if (!message.Stdout.StartsWith( + "beutl: stop tracking reserved project state\n", + StringComparison.Ordinal) + || !message.Stdout.Contains( + "Beutl-Snapshot: init", + StringComparison.Ordinal)) + { + return false; + } + + GitCommandResult parent = await runner.RunAsync( + repository, + ["rev-parse", "--verify", $"{commit}^{{commit}}^"], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + var diffArguments = new List + { + "diff", + "--name-only", + "-z", + parent.Stdout.Trim(), + commit, + "--", + }; + diffArguments.AddRange(reservedPaths); + GitCommandResult changed = await runner.RunAsync( + repository, + diffArguments, + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + string[] changedPaths = changed.Stdout.Split( + '\0', + StringSplitOptions.RemoveEmptyEntries); + return changedPaths.Length > 0 + && changedPaths.All(path => reservedPaths.Contains(path, StringComparer.Ordinal)); + } + + private async Task ReconcileReservedPathsInLiveIndexAsync( + RepositoryInfo repository, + IGitCliRunner runner, + RepositoryInfo refUpdateRepository, + string branchRef, + string expectedTip, + IReadOnlyList removeArguments, + CancellationToken cancellationToken) + { + await EnsureNoExternalRepositoryOperationAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + string liveIndexPath = await ResolveGitPathAsync( + repository, + runner, + "index", + cancellationToken) + .ConfigureAwait(false); + IndexFileSnapshot liveIndexBefore = await CaptureIndexFileSnapshotAsync( + liveIndexPath, + cancellationToken) + .ConfigureAwait(false); + IndexFileSnapshot liveIndexAfter = await TransformIndexSnapshotAsync( + repository, + runner, + liveIndexPath, + liveIndexBefore, + removeArguments, + GitCommandOptions.Local, + "The live Git index changed while reserved paths were being reconciled; it was left untouched.", + cancellationToken) + .ConfigureAwait(false); + + bool externalOperationStarted = false; + try + { + await EnsureNoExternalRepositoryOperationAsync( + repository, + runner, + CancellationToken.None) + .ConfigureAwait(false); + } + catch (VersionControlConflictedException) + { + externalOperationStarted = true; + } + + string? finalTip; + try + { + finalTip = await TryResolveCommitWithRetryAsync( + refUpdateRepository, + runner, + branchRef) + .ConfigureAwait(false); + } + catch (Exception observationException) + { + try + { + await ApplyIndexSnapshotAsync( + liveIndexPath, + expectedCurrent: liveIndexAfter, + replacement: liveIndexBefore, + mismatchMessage: + "The branch tip became unobservable after reserved-path reconciliation and the live index changed concurrently; the external index state was preserved.") + .ConfigureAwait(false); + } + catch (IndexRollbackAmbiguousException rollbackException) + { + LogWarningBestEffort( + rollbackException, + "The branch tip became unobservable after reserved-path reconciliation; a concurrent index change was preserved instead of restoring the prior index."); + } + + LogWarningBestEffort( + observationException, + "The branch tip could not be observed after reserved-path index reconciliation; the prior live index was restored when ownership could be proven."); + if (observationException is GitOperationException + { + IsRepositoryLockFailure: true, + } observationLockException) + { + throw observationLockException; + } + + return; + } + + if (!externalOperationStarted + && string.Equals(finalTip, expectedTip, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + try + { + await ApplyIndexSnapshotAsync( + liveIndexPath, + expectedCurrent: liveIndexAfter, + replacement: liveIndexBefore, + mismatchMessage: + "The branch moved after reserved-path reconciliation and the live index changed concurrently; the external index state was preserved.") + .ConfigureAwait(false); + } + catch (IndexRollbackAmbiguousException rollbackException) + { + LogWarningBestEffort( + rollbackException, + "The branch moved after reserved-path reconciliation; a concurrent index change was preserved instead of restoring the prior index."); + } + } + + private Task PrefetchBranchLfsObjectsCoreAsync( + string name, + CancellationToken cancellationToken) + { + ValidateSwitchBranchName(name); + return PrefetchLfsObjectsCoreAsync( + name, + LfsPrefetchScope.RepositoryWide, + cancellationToken); + } + + private Task PrefetchCommitLfsObjectsCoreAsync( + string sha, + LfsPrefetchScope scope, + CancellationToken cancellationToken) + { + GitRevisionValidator.ValidateCommitId(sha, nameof(sha)); + return PrefetchLfsObjectsCoreAsync(sha, scope, cancellationToken); + } + + private async Task PrefetchLfsObjectsCoreAsync( + string reference, + LfsPrefetchScope scope, + CancellationToken cancellationToken) + { + 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."); + } + + if (!availability.LfsInstalled) + { + return; + } + + LfsPrefetchTarget target = await GetLfsPrefetchTargetAsync( + repository, + runner, + reference, + scope, + cancellationToken) + .ConfigureAwait(false); + + IReadOnlyList remotes = await GetRemotesCoreAsync(cancellationToken) + .ConfigureAwait(false); + if (remotes.Count == 0) + { + if (await HasUncachedLfsObjectsAsync( + repository, + runner, + target.Reference, + target.PathArguments, + cancellationToken) + .ConfigureAwait(false)) + { + throw new InvalidOperationException( + "The transition requires Git LFS objects that are missing or corrupt, and no remote is configured."); + } + + return; + } + + try + { + await runner.RunAsync( + repository, + [ + .. s_lfsPathFilterOverrides, + "-c", + "lfs.fetchrecentalways=false", + "lfs", + "fetch", + .. target.PathArguments, + remotes[0].Name, + target.Reference, + ], + GitCommandOptions.Network with + { + MaxStdoutBytes = MaxLfsFetchOutputBytes, + }, + 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, + target.Reference, + target.PathArguments, + 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, + IReadOnlyList lfsPathArguments, + 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", + .. lfsPathArguments, + reference, + ], + GitCommandOptions.Local with + { + MaxStdoutBytes = MaxLfsObjectListOutputBytes, + }, + cancellationToken).ConfigureAwait(false); + } + catch (GitOperationException ex) + { + LogWarningBestEffort( + ex, + "Could not list the Git LFS objects required by a transition target."); + return true; + } + + if (listed.StdoutTruncated) + { + return true; + } + + var verifiedObjects = new HashSet(StringComparer.Ordinal); + foreach (string line in listed.Stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + if (!TryParseCanonicalLfsObjectLine(line, out string oid)) + { + return true; + } + + if (verifiedObjects.Add(oid) + && !await IsCachedLfsObjectValidAsync( + storage, + oid, + cancellationToken) + .ConfigureAwait(false)) + { + return true; + } + } + + return false; + } + + private static bool TryParseCanonicalLfsObjectLine(string line, out string oid) + { + if (line.EndsWith('\r')) + { + line = line[..^1]; + } + + const int OidLength = 64; + const int PathOffset = OidLength + 3; + if (line.Length <= PathOffset + || line[OidLength] != ' ' + || line[OidLength + 1] is not ('*' or '-') + || line[OidLength + 2] != ' ' + || !IsCanonicalLfsOid(line.AsSpan(0, OidLength))) + { + oid = string.Empty; + return false; + } + + oid = line[..OidLength]; + return true; + } + + private static bool IsCanonicalLfsOid(ReadOnlySpan value) + { + foreach (char character in value) + { + if (character is not (>= '0' and <= '9' or >= 'a' and <= 'f')) + { + return false; + } + } + + return true; + } + + private static async Task IsCachedLfsObjectValidAsync( + string storage, + string oid, + CancellationToken cancellationToken) + { + string path = Path.Combine(storage, oid[..2], oid[2..4], oid); + try + { + await using var stream = new FileStream( + path, + new FileStreamOptions + { + Mode = FileMode.Open, + Access = FileAccess.Read, + Share = FileShare.Read, + Options = FileOptions.Asynchronous | FileOptions.SequentialScan, + }); + using SHA256 sha256 = SHA256.Create(); + byte[] actual = await sha256.ComputeHashAsync(stream, cancellationToken) + .ConfigureAwait(false); + byte[] expected = Convert.FromHexString(oid); + return CryptographicOperations.FixedTimeEquals(actual, expected); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return false; + } + } + + private static async Task GetLfsPrefetchTargetAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string reference, + LfsPrefetchScope scope, + CancellationToken cancellationToken) + { + if (scope == LfsPrefetchScope.RepositoryWide + || (scope == LfsPrefetchScope.ProjectPathspec && repository.Pathspec == ".")) + { + return new LfsPrefetchTarget(reference, []); + } + + if (scope != LfsPrefetchScope.ProjectPathspec) + { + throw new ArgumentOutOfRangeException(nameof(scope)); + } + + // Git LFS parses --include as a comma-separated list of gitignore globs, not as a + // literal Git pathspec. Use it only when every character is literal in that grammar. + // For any other legal repository path, resolve the exact subtree with Git's literal + // pathspec handling and let LFS scan that tree object without a path filter. + if (repository.Pathspec.All(static character => + character is >= 'a' and <= 'z' + or >= 'A' and <= 'Z' + or >= '0' and <= '9' + or '/' or '.' or '_' or '-')) + { + return new LfsPrefetchTarget( + reference, + [ + $"--include={repository.Pathspec}/**", + "--exclude=", + ]); + } + + GitCommandResult tree = await runner.RunAsync( + repository, + [ + "ls-tree", + "-d", + "-z", + "--format=%(objectname)", + reference, + "--", + repository.Pathspec, + ], + GitCommandOptions.Local with + { + MaxStdoutBytes = 128, + }, + cancellationToken) + .ConfigureAwait(false); + string[] objectIds = GitCliRunner.SplitNullSeparated(tree.Stdout).ToArray(); + if (tree.StdoutTruncated || objectIds.Length != 1) + { + throw new InvalidOperationException( + "The target revision does not contain exactly one project subtree for Git LFS prefetch."); + } + + string treeId = objectIds[0]; + GitRevisionValidator.ValidateCommitId(treeId, nameof(treeId)); + return new LfsPrefetchTarget(treeId, []); + } + + 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 + { + CheckedOutBranchTip currentTip = await GetCheckedOutBranchTipCoreAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + BranchUpstreamConfiguration? upstream = await GetBranchUpstreamConfigurationAsync( + repository, + runner, + currentTip.RefName, + cancellationToken) + .ConfigureAwait(false); + string branchName = GetBranchShortName(currentTip.RefName); + string remoteRef = upstream is not null + && string.Equals(upstream.RemoteName, "origin", StringComparison.Ordinal) + && IsValidLocalBranchRef(upstream.RemoteRef) + ? upstream.RemoteRef + : $"refs/heads/{branchName}"; + var arguments = new List + { + "push", + "--progress", + }; + if (upstream is null) + { + arguments.Add("-u"); + } + + arguments.Add("origin"); + arguments.Add($"{currentTip.RefName}:{remoteRef}"); + await runner.RunAsync( + repository, + arguments, + GitCommandOptions.Network, + cancellationToken, + progress).ConfigureAwait(false); + await TryQueueStatusChangedCoreAsync().ConfigureAwait(false); + return new RemoteOpResult.Success(); + } + catch (GitOperationException ex) + { + CaptureRecoverableLock(ex); + return MapRemoteFailure(ex); + } + } + + private static async Task GetBranchUpstreamConfigurationAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string localBranchRef, + CancellationToken cancellationToken) + { + GitCommandResult result = await runner.RunAsync( + repository, + [ + "for-each-ref", + "--format=%(refname)%00%(upstream:remotename)%00%(upstream:remoteref)", + localBranchRef, + ], + GitCommandOptions.Local, + cancellationToken).ConfigureAwait(false); + foreach (string record in result.Stdout + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + string[] fields = record.Split('\0'); + if (fields.Length != 3 + || !string.Equals(fields[0], localBranchRef, StringComparison.Ordinal)) + { + continue; + } + + string remoteName = fields[1]; + string remoteRef = fields[2]; + if (remoteName.Length == 0 && remoteRef.Length == 0) + { + return null; + } + + return new BranchUpstreamConfiguration(remoteName, remoteRef); + } + + throw new GitOperationException( + 128, + $"The captured local branch '{localBranchRef}' no longer exists."); + } + + 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 + && !VersionControlPathComparison.AreSameCanonicalPath(Repository.ProjectRoot, projectRoot) + && !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"); + bool useLfs = options.UseLfsWhenAvailable && availability.LfsInstalled; + if (useLfs) + { + useLfs = await TryInstallLfsLocallyAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + } + + await EnsureLinesAsync( + ignorePath, + s_gitIgnoreLines, + cancellationToken) + .ConfigureAwait(false); + await EnsureAttributesAsync( + attributesPath, + useLfs, + cancellationToken) + .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); + string branchRef = await GetAttachedBranchRefCoreAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + string? originalBranchTip = await TryResolveCommitAsync( + repository, + runner, + branchRef, + cancellationToken) + .ConfigureAwait(false); + SnapshotTreeCapture snapshot = await BuildSnapshotTreeForCapturedHeadAsync( + repository, + runner, + branchRef, + originalBranchTip, + cancellationToken) + .ConfigureAwait(false); + string desiredTree = snapshot.Tree; + string? originalTree = originalBranchTip is null + ? null + : await ResolveTreeAsync( + repository, + runner, + originalBranchTip, + cancellationToken) + .ConfigureAwait(false); + if (originalTree is null + || !string.Equals(desiredTree, originalTree, StringComparison.OrdinalIgnoreCase)) + { + using HeadOwnershipLease headLease = await AcquireSnapshotHeadLeaseAsync( + repository, + runner, + branchRef, + originalBranchTip, + cancellationToken) + .ConfigureAwait(false); + SnapshotCommit commit = await CreateSnapshotCommitAsync( + repository, + runner, + desiredTree, + originalBranchTip, + "beutl: initialize version control", + SnapshotKind.Init, + cancellationToken) + .ConfigureAwait(false) + ?? throw new InvalidOperationException( + "The initial snapshot did not produce a commit."); + try + { + await PublishSnapshotAndReconcileIndexAsync( + repository, + runner, + branchRef, + originalBranchTip, + commit.Commit, + snapshot, + headLease, + "beutl: initialize version control", + cancellationToken) + .ConfigureAwait(false); + if (ReleaseSnapshotHeadLeaseForPostCommit(headLease)) + { + await RunPostCommitHookBestEffortAsync( + repository, + runner, + commit, + snapshot.IndexPath) + .ConfigureAwait(false); + } + } + finally + { + TryDeleteTemporaryIndex(commit.MessagePath); + } + } + + 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); + + string? originalBranchTip = await TryResolveCommitAsync( + repository, + runner, + branchRef, + cancellationToken) + .ConfigureAwait(false); + SnapshotTreeCapture snapshot = await BuildSnapshotTreeForCapturedHeadAsync( + repository, + runner, + branchRef, + originalBranchTip, + cancellationToken) + .ConfigureAwait(false); + string desiredTree = snapshot.Tree; + if (originalBranchTip is not null) + { + string originalTree = await ResolveTreeAsync( + repository, + runner, + originalBranchTip, + cancellationToken) + .ConfigureAwait(false); + if (string.Equals(desiredTree, originalTree, StringComparison.OrdinalIgnoreCase)) + { + return new CommitResult.NoChanges(); + } + } + else if (status.IsClean && _requiredTemporaryProjectPaths.Count == 0) + { + 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); + + await EnsureNoExternalRepositoryOperationAsync( + repository, + runner, + cancellationToken) + .ConfigureAwait(false); + using HeadOwnershipLease headLease = await AcquireSnapshotHeadLeaseAsync( + repository, + runner, + branchRef, + originalBranchTip, + cancellationToken) + .ConfigureAwait(false); + SnapshotCommit? commit = await CreateSnapshotCommitAsync( + repository, + runner, + desiredTree, + originalBranchTip, + message, + kind, + cancellationToken) + .ConfigureAwait(false); + if (commit is null) + { + return new CommitResult.NoChanges(); + } + + try + { + await PublishSnapshotAndReconcileIndexAsync( + repository, + runner, + branchRef, + originalBranchTip, + commit.Commit, + snapshot, + headLease, + $"beutl: {kind.ToString().ToLowerInvariant()} snapshot", + cancellationToken) + .ConfigureAwait(false); + if (ReleaseSnapshotHeadLeaseForPostCommit(headLease)) + { + await RunPostCommitHookBestEffortAsync( + repository, + runner, + commit, + snapshot.IndexPath) + .ConfigureAwait(false); + } + } + finally + { + TryDeleteTemporaryIndex(commit.MessagePath); + } + + await TryQueueStatusChangedCoreAsync().ConfigureAwait(false); + return new CommitResult.Committed(new CommitRevision.Known(commit.Commit)); + } + + 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) + { + string[] values = trailer.Split( + ['\r', '\n'], + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (values.Length != 1) + { + return SnapshotKind.Manual; + } + + return values[0].ToLowerInvariant() switch + { + "manual" => SnapshotKind.Manual, + "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; + } + + // The hygiene files are ordinary user-visible files. Portable .NET has no atomic + // compare-and-delete/replace primitive, so rollback never mutates them after initialization; + // retaining Beutl's additions is safer than risking deletion or overwrite of an external edit. + private static async Task CaptureIndexFileSnapshotAsync( + string indexPath, + CancellationToken cancellationToken) + { + EnsureIndexPathIsRegular(indexPath); + try + { + if (!File.Exists(indexPath)) + { + return new IndexFileSnapshot( + Exists: false, + Contents: [], + Attributes: null, + UnixMode: null, + LastWriteTimeUtc: null); + } + + byte[] contents = await File.ReadAllBytesAsync(indexPath, cancellationToken) + .ConfigureAwait(false); + FileAttributes attributes = File.GetAttributes(indexPath); + UnixFileMode? unixMode = null; + if (!OperatingSystem.IsWindows()) + { + unixMode = File.GetUnixFileMode(indexPath); + } + + EnsureIndexPathIsRegular(indexPath); + return new IndexFileSnapshot( + Exists: true, + contents, + attributes, + unixMode, + File.GetLastWriteTimeUtc(indexPath)); + } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) + { + EnsureIndexPathIsRegular(indexPath); + return new IndexFileSnapshot( + Exists: false, + Contents: [], + Attributes: null, + UnixMode: null, + LastWriteTimeUtc: null); + } + } + + private static void EnsureIndexPathIsRegular(string path) + { + try + { + var file = new FileInfo(path); + file.Refresh(); + if (file.Exists + && (file.LinkTarget is not null + || (file.Attributes & FileAttributes.ReparsePoint) != 0)) + { + throw new InvalidOperationException( + $"Git index path '{path}' must be a regular file."); + } + + if (Directory.Exists(path)) + { + throw new InvalidOperationException( + $"Git index path '{path}' must be a regular file."); + } + } + catch (InvalidOperationException) + { + throw; + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or NotSupportedException) + { + throw new InvalidOperationException( + $"The Git index path '{path}' could not be inspected safely.", + ex); + } + } + + private static bool IndexBytesEqual( + IndexFileSnapshot left, + IndexFileSnapshot right) + { + return left.Exists == right.Exists + && left.Contents.AsSpan().SequenceEqual(right.Contents) + && left.Attributes == right.Attributes + && left.UnixMode == right.UnixMode; + } + + private static Task TransformIndexSnapshotAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string indexPath, + IndexFileSnapshot beforeTransform, + IReadOnlyList arguments, + GitCommandOptions options, + string mismatchMessage, + CancellationToken cancellationToken) + => TransformIndexSnapshotAsync( + repository, + runner, + indexPath, + beforeTransform, + [arguments], + options, + mismatchMessage, + cancellationToken); + + private static async Task TransformIndexSnapshotAsync( + RepositoryInfo repository, + IGitCliRunner runner, + string indexPath, + IndexFileSnapshot beforeTransform, + IReadOnlyList> commands, + GitCommandOptions options, + string mismatchMessage, + CancellationToken cancellationToken) + { + string indexDirectory = Path.GetDirectoryName(indexPath) + ?? throw new InvalidOperationException( + $"The Git index path '{indexPath}' has no parent directory."); + string temporaryIndexPath = Path.Combine( + indexDirectory, + $".beutl-index-{Guid.NewGuid():N}.tmp"); + try + { + if (beforeTransform.Exists) + { + await using var stream = new FileStream( + temporaryIndexPath, + new FileStreamOptions + { + Mode = FileMode.CreateNew, + Access = FileAccess.Write, + Share = FileShare.None, + Options = FileOptions.Asynchronous, + }); + await stream.WriteAsync(beforeTransform.Contents, cancellationToken) + .ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + if (beforeTransform.LastWriteTimeUtc is { } lastWriteTimeUtc) + { + File.SetLastWriteTimeUtc(temporaryIndexPath, lastWriteTimeUtc); + } + + var environmentOverrides = options.EnvironmentOverrides is null + ? new Dictionary() + : new Dictionary(options.EnvironmentOverrides); + environmentOverrides["GIT_INDEX_FILE"] = temporaryIndexPath; + GitCommandOptions temporaryIndexOptions = options with + { + EnvironmentOverrides = environmentOverrides, + }; + foreach (IReadOnlyList arguments in commands) + { + await runner.RunAsync( + repository, + arguments, + temporaryIndexOptions, + cancellationToken) + .ConfigureAwait(false); + } + IndexFileSnapshot afterAdd = await CaptureIndexFileSnapshotAsync( + temporaryIndexPath, + cancellationToken) + .ConfigureAwait(false); + if (beforeTransform.Exists) + { + afterAdd = afterAdd with + { + Attributes = beforeTransform.Attributes, + UnixMode = beforeTransform.UnixMode, + }; + } + await ApplyIndexSnapshotAsync( + indexPath, + expectedCurrent: beforeTransform, + replacement: afterAdd, + mismatchMessage: mismatchMessage) + .ConfigureAwait(false); + return afterAdd; + } + finally + { + TryDeleteTemporaryIndex(temporaryIndexPath); + } + } + + private static FileStream AcquireIndexLock(string indexPath) + { + string lockPath = indexPath + ".lock"; + try + { + return new FileStream( + lockPath, + new FileStreamOptions + { + Mode = FileMode.CreateNew, + Access = FileAccess.ReadWrite, + Share = FileShare.None, + Options = FileOptions.WriteThrough, + }); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + throw new GitOperationException( + 128, + $"Unable to acquire the worktree Git index lock '{lockPath}': {ex.Message}"); + } + } + + private static async Task ApplyIndexSnapshotAsync( + string indexPath, + IndexFileSnapshot expectedCurrent, + IndexFileSnapshot replacement, + string mismatchMessage) + { + string lockPath = indexPath + ".lock"; + FileStream lockStream = AcquireIndexLock(indexPath); + bool lockMoved = false; + try + { + // The lockfile blocks Git's normal index writers for the entire compare/write/rename + // sequence. A changed byte stream means another writer owns the index and this + // operation must not overwrite it. + IndexFileSnapshot current = await CaptureIndexFileSnapshotAsync( + indexPath, + CancellationToken.None) + .ConfigureAwait(false); + if (!IndexBytesEqual(current, expectedCurrent)) + { + throw new IndexRollbackAmbiguousException(mismatchMessage); + } + + await lockStream.WriteAsync(replacement.Contents, CancellationToken.None) + .ConfigureAwait(false); + await lockStream.FlushAsync(CancellationToken.None).ConfigureAwait(false); + lockStream.Flush(flushToDisk: true); + lockStream.Dispose(); + + if (replacement.Exists) + { + if (replacement.Attributes is { } attributes) + { + File.SetAttributes(lockPath, attributes); + } + + if (!OperatingSystem.IsWindows() && replacement.UnixMode is { } unixMode) + { + File.SetUnixFileMode(lockPath, unixMode); + } + + if (replacement.LastWriteTimeUtc is { } lastWriteTimeUtc) + { + File.SetLastWriteTimeUtc(lockPath, lastWriteTimeUtc); + } + + File.Move(lockPath, indexPath, overwrite: true); + } + else + { + // An absent replacement has no byte stream to rename. Keep the lockfile in place + // while removing the index so compliant Git writers cannot recreate it between + // the compare and delete operations. + File.Delete(indexPath); + File.Delete(lockPath); + } + + lockMoved = true; + } + catch (IndexRollbackAmbiguousException) + { + throw; + } + catch (GitOperationException) + { + throw; + } + catch (Exception ex) + { + throw new IndexRollbackAmbiguousException( + $"The Git index '{indexPath}' could not be changed without risking an overwrite.", + ex); + } + finally + { + lockStream.Dispose(); + + if (!lockMoved) + { + try + { + File.Delete(lockPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Preserve the original ownership/operation failure. The lock is still + // visible to compliant Git writers until the caller can recover it. + } + } + } + } + + private static async Task RestoreFailedIndexSnapshotAsync( + string indexPath, + IndexFileSnapshot beforeAdd, + IndexFileSnapshot afterAdd) + { + await ApplyIndexSnapshotAsync( + indexPath, + expectedCurrent: afterAdd, + replacement: beforeAdd, + mismatchMessage: + $"The Git index '{indexPath}' changed after staging; its prior bytes were not restored.") + .ConfigureAwait(false); + } + + 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(); + HygieneFileSnapshot finalSnapshot = await ReadHygieneFileSnapshotAsync( + path, + cancellationToken) + .ConfigureAwait(false); + if (finalSnapshot != snapshot) + { + continue; + } + + cancellationToken.ThrowIfCancellationRequested(); + if (_beforeHygieneFileCommit is not null) + { + await _beforeHygieneFileCommit(path, cancellationToken).ConfigureAwait(false); + } + + cancellationToken.ThrowIfCancellationRequested(); + if (snapshot.Exists) + { + string candidatePath = temporaryPath; + temporaryPath = null; + if (await TryCommitExistingHygieneFileAsync( + path, + candidatePath, + snapshot) + .ConfigureAwait(false)) + { + return; + } + + continue; + } + + try + { + File.Move(temporaryPath, path, overwrite: false); + } + catch (IOException) when (File.Exists(path)) + { + continue; + } + + return; + } + finally + { + if (temporaryPath is not null) + { + TryDeleteHygieneTemporaryFile(temporaryPath); + } + } + } + + throw new InvalidOperationException( + $"Repository hygiene could not update '{path}' because it kept changing."); + } + + private async Task TryCommitExistingHygieneFileAsync( + string path, + string candidatePath, + HygieneFileSnapshot expectedSnapshot) + { + HygieneFileSnapshot candidateSnapshot = await ReadHygieneFileSnapshotAsync( + candidatePath, + CancellationToken.None) + .ConfigureAwait(false); + string displacedPath; + try + { + displacedPath = AtomicFileExchange.ReplacePreservingTarget(path, candidatePath); + } + catch + { + await TryDeleteHygieneFileIfUnchangedAsync(candidatePath, candidateSnapshot) + .ConfigureAwait(false); + throw; + } + + Exception? verificationFailure = null; + HygieneFileSnapshot? displacedSnapshot = null; + try + { + if (_afterHygieneFileExchange is not null) + { + await _afterHygieneFileExchange(path, CancellationToken.None) + .ConfigureAwait(false); + } + + displacedSnapshot = await ReadHygieneFileSnapshotAsync( + displacedPath, + CancellationToken.None) + .ConfigureAwait(false); + } + catch (Exception ex) + { + verificationFailure = ex; + } + + if (verificationFailure is null && displacedSnapshot == expectedSnapshot) + { + if (!_deleteVerifiedHygieneFile(displacedPath)) + { + throw new InvalidOperationException( + $"Repository hygiene was updated, but the verified prior contents could not be removed and were retained at '{displacedPath}'."); + } + + return true; + } + + string recoveredCandidatePath; + try + { + recoveredCandidatePath = AtomicFileExchange.ReplacePreservingTarget( + path, + displacedPath); + } + catch (Exception rollbackFailure) + { + Exception failure = verificationFailure is null + ? rollbackFailure + : new AggregateException(verificationFailure, rollbackFailure); + throw new InvalidOperationException( + $"Repository hygiene changed concurrently; the prior contents were retained at '{displacedPath}' because they could not be restored safely.", + failure); + } + + HygieneFileSnapshot recoveredCandidate; + try + { + recoveredCandidate = await ReadHygieneFileSnapshotAsync( + recoveredCandidatePath, + CancellationToken.None) + .ConfigureAwait(false); + } + catch (Exception inspectionFailure) + { + throw new InvalidOperationException( + $"Repository hygiene changed concurrently; the displaced replacement was retained at '{recoveredCandidatePath}' because it could not be inspected safely.", + inspectionFailure); + } + + if (recoveredCandidate != candidateSnapshot) + { + throw new InvalidOperationException( + $"Repository hygiene changed more than once during recovery. The original file was restored and the later contents were retained at '{recoveredCandidatePath}'."); + } + + if (!_deleteVerifiedHygieneFile(recoveredCandidatePath)) + { + throw new InvalidOperationException( + $"Repository hygiene was restored, but the displaced replacement could not be removed and was retained at '{recoveredCandidatePath}'."); + } + + if (verificationFailure is not null) + { + throw new InvalidOperationException( + "Repository hygiene changed to an entry that could not be inspected safely; the original entry was restored.", + verificationFailure); + } + + return false; + } + + private static async Task TryDeleteHygieneFileIfUnchangedAsync( + string path, + HygieneFileSnapshot expectedSnapshot) + { + try + { + HygieneFileSnapshot current = await ReadHygieneFileSnapshotAsync( + path, + CancellationToken.None) + .ConfigureAwait(false); + if (current == expectedSnapshot) + { + TryDeleteHygieneTemporaryFile(path); + } + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or InvalidOperationException + or NotSupportedException) + { + // A path that no longer identifies our exact candidate is retained rather than deleted. + } + } + + 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 static bool TryDeleteVerifiedHygieneFile(string path) + { + try + { + File.Delete(path); + return !Path.Exists(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return false; + } + } + + 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) + .Where(static path => !path.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase)) + .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) + .Where(static path => !path.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase)) + .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, StringComparison.Ordinal) + || 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) + .Where(static path => !path.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase)), + 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); + } + + string projectFileDirectory = Path.GetDirectoryName(_projectFile) + ?? throw new InvalidOperationException( + "The project file has no parent directory."); + string serializationRoot = VersionControlPathComparison.AreSameCanonicalPath( + projectFileDirectory, + projectRoot) + ? projectFileDirectory + : projectRoot; + return SerializedProjectGraph.GetRelativePaths(_projectFile, serializationRoot); + } + + 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); + _requiredTemporaryProjectPaths = serializedPaths + .Where(static path => path.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase)) + .ToHashSet(StringComparer.Ordinal); + _watcher?.UpdateRequiredPaths(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)>(); + pending.Push(( + projectRoot, + IsResourceDirectory: false, + SymbolicLinkDirectory: 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)); + bool isSerializedPath = serializedPaths.Contains(relativeFile); + if (isSerializedPath + || !string.Equals(extension, ".tmp", StringComparison.OrdinalIgnoreCase) + && (item.IsResourceDirectory + || s_projectFileExtensions.Contains(extension) + || s_mediaExtensions.Contains(extension))) + { + 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."); + } + + 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)) + { + if (Directory.Exists(Path.Combine(child, ".git")) + || File.Exists(Path.Combine(child, ".git"))) + { + string relativeRepository = NormalizeGitPath(Path.GetRelativePath( + projectRoot, + child)); + throw new InvalidOperationException( + $"The nested Git repository '{relativeRepository}' cannot be snapshotted safely."); + } + + pending.Push(( + child, + item.IsResourceDirectory + || string.Equals(name, "resources", StringComparison.OrdinalIgnoreCase), + item.SymbolicLinkDirectory + ?? ((File.GetAttributes(child) & FileAttributes.ReparsePoint) != 0 + ? child + : null))); + } + } + } + } + + private void ValidateProjectSnapshotLayout(string projectRoot) + { + ValidateRequiredProjectFileLayout(projectRoot); + if (_projectFile is null || !File.Exists(_projectFile)) + { + return; + } + + ValidateNoReservedProjectReferences(_projectFile); + } + + private static void ValidateNoReservedProjectReferences(string projectFile) + { + Project project = CoreSerializer.RestoreFromUri(new Uri(projectFile)); + VersionControlSerializationGraph.SerializationGraph graph = + VersionControlSerializationGraph.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 + && VersionControlSerializationGraph.IsInReservedProjectPath( + uri, + projectDirectory)); + reservedReference ??= VersionControlSerializationGraph + .Collect(graph, projectDirectory, stagedStorageObjects: null) + .FileSources + .Select(static source => source.OriginalUri) + .FirstOrDefault(uri => VersionControlSerializationGraph.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 TryDeleteHistoricalGraphDirectory(string path) + { + try + { + if (Directory.Exists(path)) + { + 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.UpdateRequiredPaths(_requiredTemporaryProjectPaths); + _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 (Exception 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 (Exception ex) + { + CaptureRecoverableLock(ex); + throw; + } + finally + { + _operationGate.Release(); + } + } + + private void OnRepositoryChanged(object? sender, EventArgs e) + { + if (!IsDisposed) + { + _ = RefreshStatusFromWatcherAsync(); + } + } + + private void CaptureRecoverableLock(Exception exception) + { + var pending = new Stack(); + var visited = new HashSet(ReferenceEqualityComparer.Instance); + pending.Push(exception); + while (pending.TryPop(out Exception? current)) + { + if (!visited.Add(current)) + { + continue; + } + + if (current is GitOperationException gitException + && TryCaptureRecoverableLock(gitException)) + { + return; + } + + if (current is AggregateException aggregate) + { + for (int i = aggregate.InnerExceptions.Count - 1; i >= 0; i--) + { + pending.Push(aggregate.InnerExceptions[i]); + } + + continue; + } + + if (current.InnerException is { } innerException) + { + pending.Push(innerException); + } + } + } + + private bool TryCaptureRecoverableLock(GitOperationException exception) + { + IGitCliRunner? runner = _runner; + RepositoryInfo? repository = Repository; + if (!exception.IsRepositoryLockFailure + || repository is null + || runner is null) + { + return false; + } + + RepositoryLockInfo? lockInfo = runner.GetRecoverableRepositoryLock(repository); + if (lockInfo is null) + { + return false; + } + + RecoverableLock = lockInfo; + _lockNotificationScheduler(() => NotifyRecoverableLockAvailable(lockInfo)); + return true; + } + + private void NotifyRecoverableLockAvailable(RepositoryLockInfo lockInfo) + { + if (IsDisposed + || !ReferenceEquals(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 static void ScheduleLockNotification(Action notification) + { + ThreadPool.UnsafeQueueUserWorkItem( + static state => ((Action)state!).Invoke(), + notification, + preferLocal: false); + } + + 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, + LfsPrefetchScope scope, + CancellationToken cancellationToken) + => _service.PrefetchCommitLfsObjectsCoreAsync(sha, scope, 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, + } + +} + +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..9da1ae7b8b --- /dev/null +++ b/src/Beutl.Editor/VersionControl/GitInstallationLocator.cs @@ -0,0 +1,533 @@ +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, 36); + + 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; + + StringComparer candidateComparer = _platform == GitHostPlatform.Windows + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + foreach (string candidate in candidates.Distinct(candidateComparer)) + { + 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; + } + + [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) + => FindOnPath( + executableName, + pathValue, + pathExtensionsValue, + OperatingSystem.IsWindows() ? GitHostPlatform.Windows : GitHostPlatform.Linux, + Environment.CurrentDirectory); + + internal static IReadOnlyList FindOnPath( + string executableName, + string? pathValue, + string? pathExtensionsValue, + GitHostPlatform platform, + string currentDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executableName); + ArgumentException.ThrowIfNullOrWhiteSpace(currentDirectory); + if (pathValue is null) + { + return []; + } + + char pathSeparator = platform == GitHostPlatform.Windows ? ';' : ':'; + StringComparer candidateComparer = platform == GitHostPlatform.Windows + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + string[] extensions = platform == GitHostPlatform.Windows + ? [ + string.Empty, + .. (pathExtensionsValue ?? string.Empty) + .Split(pathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Distinct(candidateComparer) + ] + : [string.Empty]; + string[] directories = platform == GitHostPlatform.Windows + ? pathValue.Split( + pathSeparator, + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + : pathValue.Split(pathSeparator); + string resolvedCurrentDirectory = Path.GetFullPath(currentDirectory); + var candidates = new List(); + foreach (string pathEntry in directories) + { + string directory; + try + { + // POSIX specifies an empty PATH component as the current directory. Every relative + // component has the same captured-CWD dependency, so resolve both forms now before + // a later process-wide working-directory change can redirect the executable. + directory = pathEntry.Length == 0 + ? resolvedCurrentDirectory + : Path.IsPathFullyQualified(pathEntry) + ? pathEntry + : Path.GetFullPath(pathEntry, resolvedCurrentDirectory); + } + catch (Exception ex) when (ex is ArgumentException + or IOException + or NotSupportedException) + { + continue; + } + + 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, candidateComparer)) + { + 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..ee18df3f85 --- /dev/null +++ b/src/Beutl.Editor/VersionControl/IProjectVersionControlService.cs @@ -0,0 +1,221 @@ +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; } + + /// Removes the exact lock offer that the caller inspected and confirmed. + /// + /// The same instance obtained from or + /// . An equal copy or a superseded offer is rejected. + /// + /// Cancels the operation before deletion begins. + Task RemoveRecoverableLockAsync( + RepositoryLockInfo expectedLock, + 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. A pull uses + /// because it transitions the enclosing repository; a restore uses + /// because it changes only the project tree. + /// Best effort, exactly like the branch variant. + /// + Task PrefetchCommitLfsObjectsAsync( + string sha, + LfsPrefetchScope scope, + CancellationToken cancellationToken); + + Task SwitchBranchAsync(string name, CancellationToken cancellationToken); + + Task PullFastForwardAsync( + CheckedOutBranchTip expectedCurrent, + ProjectCheckpoint? checkpoint, + string projectFile, + CancellationToken cancellationToken); +} + +internal enum LfsPrefetchScope +{ + RepositoryWide, + ProjectPathspec, +} + +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..c511c75343 --- /dev/null +++ b/src/Beutl.Editor/VersionControl/ProjectConflictMarkerScanner.cs @@ -0,0 +1,565 @@ +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; + // Project opening waits for this scan, so bound both one unexpectedly large sidecar and a + // project that references many otherwise-small files. + private const long DefaultMaxBytesPerFile = 8L * 1024 * 1024; + private const long DefaultMaxBytesPerInvocation = 32L * 1024 * 1024; + + private static readonly byte[] s_utf8Bom = [0xef, 0xbb, 0xbf]; + private static readonly HashSet s_projectExtensions = new( + [".bep", ".scene", ".belm"], + StringComparer.OrdinalIgnoreCase); + private static readonly string[] s_prunedDirectories = + [ + ".beutl", + ".git", + ".idea", + ".vs", + ]; + + 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) + { + return await FindFirstAsync( + projectFile, + referencedRelativePaths, + cancellationToken, + DefaultMaxBytesPerFile, + DefaultMaxBytesPerInvocation) + .ConfigureAwait(false); + } + + internal static async Task FindFirstAsync( + string projectFile, + IReadOnlySet referencedRelativePaths, + CancellationToken cancellationToken, + long maxBytesPerFile, + long maxBytesPerInvocation) + { + ArgumentException.ThrowIfNullOrWhiteSpace(projectFile); + ArgumentNullException.ThrowIfNull(referencedRelativePaths); + ArgumentOutOfRangeException.ThrowIfNegative(maxBytesPerFile); + ArgumentOutOfRangeException.ThrowIfNegative(maxBytesPerInvocation); + 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; + } + + var budget = new ScanBudget(maxBytesPerFile, maxBytesPerInvocation); + var scannedFiles = new HashSet(StringComparer.Ordinal); + string fullProjectFile = Path.GetFullPath(projectFile); + string canonicalProjectFile = VersionControlPathComparison.ResolveCanonicalPath(fullProjectFile); + scannedFiles.Add(canonicalProjectFile); + if (await ContainsConflictMarkerAsync( + projectRoot, + canonicalProjectFile, + budget, + cancellationToken) + .ConfigureAwait(false)) + { + return fullProjectFile; + } + + if (budget.IsExhausted) + { + return null; + } + + var referencedProjectFiles = new SortedSet(StringComparer.Ordinal); + var referencedExtensionFiles = new SortedSet(StringComparer.Ordinal); + foreach (string relativePath in referencedRelativePaths) + { + cancellationToken.ThrowIfCancellationRequested(); + string referenced = VersionControlPathComparison.ResolveCanonicalPath( + Path.GetFullPath( + Path.Combine( + projectRoot, + relativePath.Replace('/', Path.DirectorySeparatorChar)))); + if (GitCliVersionControlService.IsSupportedMediaPath(referenced)) + { + continue; + } + + SortedSet destination = s_projectExtensions.Contains(Path.GetExtension(referenced)) + ? referencedProjectFiles + : referencedExtensionFiles; + destination.Add(referenced); + } + + // Serialized project state has to win the finite budget over arbitrary extension assets. + foreach (string referenced in referencedProjectFiles) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!scannedFiles.Add(referenced)) + { + continue; + } + + if (await ContainsConflictMarkerAsync( + projectRoot, + referenced, + budget, + cancellationToken) + .ConfigureAwait(false)) + { + return referenced; + } + + if (budget.IsExhausted) + { + return null; + } + } + + 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.OrderByDescending( + static path => path, + StringComparer.Ordinal)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (ShouldDescendInto(childDirectory)) + { + pendingDirectories.Push(childDirectory); + } + } + + foreach (string file in files.OrderBy( + static path => path, + StringComparer.Ordinal)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!s_projectExtensions.Contains(Path.GetExtension(file))) + { + continue; + } + + string canonicalFile = VersionControlPathComparison.ResolveCanonicalPath(file); + if (!scannedFiles.Add(canonicalFile)) + { + continue; + } + + if (await ContainsConflictMarkerAsync( + projectRoot, + canonicalFile, + budget, + cancellationToken) + .ConfigureAwait(false)) + { + return file; + } + + if (budget.IsExhausted) + { + return null; + } + } + } + + foreach (string referenced in referencedExtensionFiles) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!scannedFiles.Add(referenced)) + { + continue; + } + + if (await ContainsConflictMarkerAsync( + projectRoot, + referenced, + budget, + cancellationToken) + .ConfigureAwait(false)) + { + return referenced; + } + + if (budget.IsExhausted) + { + return null; + } + } + + return null; + } + + private static async Task ContainsConflictMarkerAsync( + string projectRoot, + string file, + ScanBudget budget, + CancellationToken cancellationToken) + { + if (budget.IsExhausted + || !TryGetScannableLength(projectRoot, file, budget, 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, + ScanBudget budget, + 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 = budget.Reserve(info.Length); + return scanLength > 0; + } + 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)); + string parent = Path.GetDirectoryName(Path.TrimEndingDirectorySeparator(directory)) + ?? directory; + if (s_prunedDirectories.Any(pruned => + VersionControlPathComparison.TryAreSameChildPath( + parent, + name, + pruned, + out bool areSame) + && areSame)) + { + return false; + } + + try + { + return new DirectoryInfo(directory).LinkTarget is null; + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + } + + private sealed class ScanBudget(long maxBytesPerFile, long maxBytesPerInvocation) + { + private long _remainingBytes = maxBytesPerInvocation; + + public bool IsExhausted => _remainingBytes == 0 || maxBytesPerFile == 0; + + public long Reserve(long fileLength) + { + long reserved = Math.Min(fileLength, Math.Min(maxBytesPerFile, _remainingBytes)); + _remainingBytes -= reserved; + return reserved; + } + } + + 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..4aec9df8b2 --- /dev/null +++ b/src/Beutl.Editor/VersionControl/RepositoryWatcher.cs @@ -0,0 +1,725 @@ +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 string[] _requiredTemporaryPaths = []; + 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) + { + return ShouldExcludePath(projectRoot, path, []); + } + + private static bool ShouldExcludePath( + string projectRoot, + string path, + IReadOnlyList requiredTemporaryPaths) + { + if (!TryGetCanonicalRelativePath(projectRoot, path, out string? relativePath)) + { + return true; + } + + if (relativePath.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase) + && !requiredTemporaryPaths.Any(requiredPath => + VersionControlPathComparison.AreSameCanonicalPath(requiredPath, path))) + { + return true; + } + + string parent = VersionControlPathComparison.ResolveCanonicalPath(projectRoot); + foreach (string segment in relativePath.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries)) + { + if (AreSameChildPath(parent, segment, ".git") + || AreSameChildPath(parent, segment, ".beutl")) + { + return true; + } + + parent = Path.Combine(parent, segment); + } + + return false; + } + + internal static bool ShouldIncludeGitMetadataPath(string metadataRoot, string path) + { + if (!TryGetCanonicalRelativePath(metadataRoot, path, out string? relativePath)) + { + return false; + } + + relativePath = NormalizeDirectorySeparators(relativePath); + + 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; + + private static bool AreSameChildPath(string parent, string leftName, string rightName) + { + return VersionControlPathComparison.TryAreSameChildPath( + parent, + leftName, + rightName, + out bool areSame) + && areSame; + } + + private static bool TryGetCanonicalRelativePath( + string root, + string path, + out string relativePath) + { + try + { + string canonicalRoot = Path.TrimEndingDirectorySeparator( + VersionControlPathComparison.ResolveCanonicalPath(root)); + string canonicalPath = Path.TrimEndingDirectorySeparator( + VersionControlPathComparison.ResolveCanonicalPath(path)); + if (!VersionControlPathComparison.IsSameOrDescendant(canonicalRoot, canonicalPath)) + { + relativePath = string.Empty; + return false; + } + + if (string.Equals(canonicalRoot, canonicalPath, StringComparison.Ordinal)) + { + relativePath = "."; + return true; + } + + string prefix = canonicalRoot.EndsWith(Path.DirectorySeparatorChar) + || canonicalRoot.EndsWith(Path.AltDirectorySeparatorChar) + ? canonicalRoot + : canonicalRoot + Path.DirectorySeparatorChar; + if (!canonicalPath.StartsWith(prefix, StringComparison.Ordinal)) + { + relativePath = string.Empty; + return false; + } + + relativePath = canonicalPath[prefix.Length..]; + return true; + } + catch (Exception ex) + when (ex is IOException + or UnauthorizedAccessException + or NotSupportedException + or ArgumentException) + { + relativePath = string.Empty; + return false; + } + } + + 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 (ShouldExcludeWatchedPath(path)) + { + return; + } + + ScheduleChanged(); + } + + internal void NotifyPathRenamed(string oldPath, string newPath) + { + if (ShouldExcludeWatchedPath(oldPath) + && ShouldExcludeWatchedPath(newPath)) + { + return; + } + + ScheduleChanged(); + } + + internal void UpdateRequiredPaths(IReadOnlySet requiredProjectRelativePaths) + { + ArgumentNullException.ThrowIfNull(requiredProjectRelativePaths); + var requiredTemporaryPaths = new List(); + foreach (string relativePath in requiredProjectRelativePaths) + { + if (!relativePath.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase) + || Path.IsPathFullyQualified(relativePath)) + { + continue; + } + + string path = Path.GetFullPath(Path.Combine( + _projectRoot, + relativePath.Replace('/', Path.DirectorySeparatorChar))); + if (VersionControlPathComparison.IsSameOrDescendant(_projectRoot, path)) + { + requiredTemporaryPaths.Add(path); + } + } + + Volatile.Write(ref _requiredTemporaryPaths, [.. requiredTemporaryPaths]); + } + + internal bool ShouldExcludeWatchedPath(string path) + { + return ShouldExcludePath( + _projectRoot, + path, + Volatile.Read(ref _requiredTemporaryPaths)); + } + + 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 (!VersionControlPathComparison.AreSameCanonicalPath( + 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) + { + return VersionControlPathComparison.AreSameCanonicalPath(left, right); + } + + 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..6d1c6f34e3 --- /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)); + VersionControlSerializationGraph.SerializationGraph graph = + VersionControlSerializationGraph.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..75431067fa --- /dev/null +++ b/src/Beutl.Editor/VersionControl/VersionControlModels.cs @@ -0,0 +1,534 @@ +using System.Text.RegularExpressions; + +namespace Beutl.Editor.VersionControl; + +internal static class RepositoryPathComparer +{ + internal static bool AreEquivalent(string left, string right) + => VersionControlPathComparison.AreSameCanonicalPath(left, right); + + internal static bool IsContainedWithin(string root, string path) + => VersionControlPathComparison.IsSameOrDescendant(root, path); + + internal static string ResolveCanonicalPath(string path) + => VersionControlPathComparison.ResolveCanonicalPath(path); +} + +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; +} + +/// A specific stale-lock recovery offer whose identity is its object reference. +public sealed class RepositoryLockInfo +{ + public RepositoryLockInfo(string lockPath, DateTimeOffset lastWriteTimeUtc) + { + ArgumentException.ThrowIfNullOrWhiteSpace(lockPath); + LockPath = lockPath; + LastWriteTimeUtc = lastWriteTimeUtc; + } + + public string LockPath { get; } + + public DateTimeOffset LastWriteTimeUtc { get; } +} + +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.Editor/VersionControl/VersionControlPathComparison.cs b/src/Beutl.Editor/VersionControl/VersionControlPathComparison.cs new file mode 100644 index 0000000000..2cfa4b470f --- /dev/null +++ b/src/Beutl.Editor/VersionControl/VersionControlPathComparison.cs @@ -0,0 +1,345 @@ +using System.Text; + +namespace Beutl.Editor.VersionControl; + +/// Compares file paths after resolving their existing filesystem identity. +/// +/// Existing components are rewritten to their on-disk spelling and symbolic links are followed. +/// Nonexistent suffixes retain their lexical spelling. Results describe a point-in-time lookup and +/// do not lock the inspected entries against concurrent replacement. If several non-exact +/// case/normalization candidates are visible, the supplied spelling is retained rather than +/// guessing which distinct entry the filesystem resolved. +/// +internal static class VersionControlPathComparison +{ + private const int MaxSymbolicLinkHops = 64; + + /// Determines whether two paths have the same canonical spelling. + public static bool AreSameCanonicalPath(string left, string right) + { + ArgumentException.ThrowIfNullOrWhiteSpace(left); + ArgumentException.ThrowIfNullOrWhiteSpace(right); + + return string.Equals( + ResolveCanonicalPath(left), + ResolveCanonicalPath(right), + StringComparison.Ordinal); + } + + /// Determines whether a path is the root itself or one of its descendants. + public static bool IsSameOrDescendant(string root, string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(root); + ArgumentException.ThrowIfNullOrWhiteSpace(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); + } + + /// + /// Tries to determine whether two single-component child paths identify the same canonical path. + /// + /// + /// The method returns when case- or normalization-variant children are + /// both absent or when their filesystem metadata cannot be inspected. A successful result is + /// only a snapshot; callers that mutate either path still need an operation-specific ownership + /// mechanism. + /// + public static bool TryAreSameChildPath( + string parentPath, + string leftName, + string rightName, + out bool areSame) + { + ArgumentException.ThrowIfNullOrWhiteSpace(parentPath); + ValidateChildName(leftName, nameof(leftName)); + ValidateChildName(rightName, nameof(rightName)); + + areSame = false; + if (string.Equals(leftName, rightName, StringComparison.Ordinal)) + { + areSame = true; + return true; + } + + if (!CouldHaveEquivalentFilesystemSpelling(leftName, rightName)) + { + return true; + } + + string leftPath = Path.Combine(parentPath, leftName); + string rightPath = Path.Combine(parentPath, rightName); + bool leftExists = Path.Exists(leftPath); + bool rightExists = Path.Exists(rightPath); + if (!leftExists || !rightExists) + { + return leftExists != rightExists; + } + + try + { + areSame = AreSameCanonicalPath(leftPath, rightPath); + return true; + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or ArgumentException + or NotSupportedException) + { + return false; + } + } + + /// + /// Resolves symbolic links and the on-disk spelling of each existing path component. + /// + /// Any nonexistent suffix is retained exactly as supplied. + public static string ResolveCanonicalPath(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + string fullPath = Path.GetFullPath(path); + string root = NormalizeRoot( + Path.GetPathRoot(fullPath) + ?? throw new IOException($"The path '{path}' has no root.")); + var components = new Queue(SplitComponents(fullPath, root)); + var visitedStates = new HashSet(StringComparer.Ordinal) + { + 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 = NormalizeExistingEntrySpelling(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 = NormalizeRoot(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 = NormalizeRoot( + 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 void ValidateChildName(string name, string paramName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name, paramName); + if (name is "." or ".." + || Path.IsPathRooted(name) + || name.AsSpan().IndexOfAny( + Path.DirectorySeparatorChar, + Path.AltDirectorySeparatorChar) >= 0 + || OperatingSystem.IsWindows() && name.Contains(Path.VolumeSeparatorChar)) + { + throw new ArgumentException( + "A child name must contain exactly one path component.", + paramName); + } + } + + private static string NormalizeRoot(string root) + { + return OperatingSystem.IsWindows() ? root.ToUpperInvariant() : root; + } + + 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 bool CouldHaveEquivalentFilesystemSpelling(string left, string right) + { + return string.Equals(left, right, StringComparison.OrdinalIgnoreCase) + || string.Equals( + left.Normalize(NormalizationForm.FormC), + right.Normalize(NormalizationForm.FormC), + StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizeExistingEntrySpelling( + string parent, + string component, + string candidate) + { + if (!Path.Exists(candidate)) + { + return candidate; + } + + try + { + return SelectCanonicalExistingEntry( + component, + candidate, + Directory.EnumerateFileSystemEntries(parent)); + } + catch (Exception ex) + when (ex is IOException + or UnauthorizedAccessException + or NotSupportedException) + { + throw new IOException( + $"Could not normalize the on-disk spelling of '{candidate}'.", + ex); + } + } + + internal static string SelectCanonicalExistingEntry( + string component, + string candidate, + IEnumerable entries) + { + ArgumentNullException.ThrowIfNull(entries); + + string normalizedComponent = component.Normalize(NormalizationForm.FormC); + string? equivalentMatch = null; + bool equivalentMatchAmbiguous = false; + + foreach (string entry in entries) + { + string entryName = Path.GetFileName(entry); + if (string.Equals(entryName, component, StringComparison.Ordinal)) + { + return entry; + } + + if (string.Equals(entryName, component, StringComparison.OrdinalIgnoreCase)) + { + TrackUniqueMatch( + entry, + ref equivalentMatch, + ref equivalentMatchAmbiguous); + continue; + } + + string normalizedEntryName = entryName.Normalize(NormalizationForm.FormC); + if (string.Equals( + normalizedEntryName, + normalizedComponent, + StringComparison.Ordinal)) + { + TrackUniqueMatch( + entry, + ref equivalentMatch, + ref equivalentMatchAmbiguous); + } + else if (string.Equals( + normalizedEntryName, + normalizedComponent, + StringComparison.OrdinalIgnoreCase)) + { + TrackUniqueMatch( + entry, + ref equivalentMatch, + ref equivalentMatchAmbiguous); + } + } + + return equivalentMatchAmbiguous ? candidate : equivalentMatch ?? candidate; + } + + private static void TrackUniqueMatch( + string entry, + ref string? match, + ref bool ambiguous) + { + if (match is null) + { + match = entry; + } + else if (!string.Equals(match, entry, StringComparison.Ordinal)) + { + ambiguous = true; + } + } + +} diff --git a/src/Beutl.Editor/VersionControl/VersionControlSerializationGraph.cs b/src/Beutl.Editor/VersionControl/VersionControlSerializationGraph.cs new file mode 100644 index 0000000000..4c99347df7 --- /dev/null +++ b/src/Beutl.Editor/VersionControl/VersionControlSerializationGraph.cs @@ -0,0 +1,2081 @@ +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; + +/// +/// Collects IFileSource references and font references from the project hierarchy. +/// +internal sealed class VersionControlSerializationGraph +{ + 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 VersionControlSerializationGraph() + { + } + + /// + /// The list of collected file sources. + /// + public IEnumerable<(Guid Object, string PropertyName, Uri OriginalUri)> FileSources => _fileSources; + + /// + /// The list of collected font families. + /// + public IEnumerable FontFamilies => _fontFamilies; + + internal IReadOnlySet UnaddressableFileSources => _unaddressableFileSources; + + internal IReadOnlySet RelocationOwners => _relocationOwners; + + /// + /// Collects all resource references within the hierarchy. + /// + /// The root hierarchy to start collecting from. + /// The path of the project directory. + /// The collected resource information. + public static VersionControlSerializationGraph Collect(IHierarchical root, string projectDirectory) + { + return Collect(root, projectDirectory, stagedStorageObjects: null); + } + + internal static VersionControlSerializationGraph Collect( + IHierarchical root, + string projectDirectory, + IReadOnlySet? stagedStorageObjects) + { + ArgumentNullException.ThrowIfNull(root); + ArgumentNullException.ThrowIfNull(projectDirectory); + + return Collect(DiscoverSerializationGraph(root), projectDirectory, stagedStorageObjects); + } + + internal static VersionControlSerializationGraph Collect( + SerializationGraph graph, + string projectDirectory, + IReadOnlySet? stagedStorageObjects) + { + VersionControlSerializationGraph collector = new(); + foreach (CoreObject obj in graph.Objects) + { + collector.CollectFromObject(obj, projectDirectory, stagedStorageObjects); + } + + collector._fontFamilies.UnionWith(graph.FontFamilies); + collector._unaddressableFileSources.UnionWith( + graph.UnaddressableFileSources.Where(uri => ShouldRelocateFile(uri, projectDirectory))); + + return collector; + } + + 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 + && stagedStorageObjects?.Contains(obj) != true + && ShouldRelocateFile(obj.Uri, projectDirectory)) + { + AddFileSource(obj, "Uri", obj.Uri); + } + + var props = PropertyRegistry.GetRegistered(obj.GetType()); + foreach (var prop in props) + { + if (prop.PropertyType.IsValueType) continue; + object? value = obj.GetValue(prop); + switch (value) + { + case IFileSource fileSource: + if (fileSource.Uri != null && ShouldRelocateFile(fileSource.Uri, projectDirectory)) + { + AddFileSource(obj, prop.Name, fileSource.Uri); + } + + break; + case FontFamily fontFamily: + _fontFamilies.Add(fontFamily); + break; + } + } + } + + private void CollectFromEngineObject(EngineObject obj, string projectDirectory) + { + foreach (IProperty property in obj.Properties) + { + switch (property.CurrentValue) + { + // Collect IFileSource + case IFileSource fileSource when fileSource.Uri != null: + if (ShouldRelocateFile(fileSource.Uri, projectDirectory)) + { + AddFileSource(obj, property.Name, fileSource.Uri); + } + + break; + // Collect FontFamily + case FontFamily fontFamily: + _fontFamilies.Add(fontFamily); + break; + } + } + } + + private void AddFileSource(CoreObject owner, string propertyName, Uri uri) + { + _fileSources.Add((owner.Id, propertyName, uri)); + _relocationOwners.Add(owner); + } + + /// + /// Determines whether the URI must be copied into the package's resources directory. + /// + 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 = Path.GetFullPath(uri.LocalPath); + string fullProjectPath = Path.GetFullPath(projectDirectory); + 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); + } + + private static bool ContainsReservedPath(string relativePath) + { + // This value comes from Path.GetRelativePath. A backslash is an ordinary filename + // character on Unix, while both slash forms are separators on Windows. + string[] segments = relativePath.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + 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.Language/SettingsStrings.ja.resx b/src/Beutl.Language/SettingsStrings.ja.resx index c95287a9b6..cad0061af2 100644 --- a/src/Beutl.Language/SettingsStrings.ja.resx +++ b/src/Beutl.Language/SettingsStrings.ja.resx @@ -627,4 +627,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 95e2620f5e..a33848ac81 100644 --- a/src/Beutl.Language/SettingsStrings.resx +++ b/src/Beutl.Language/SettingsStrings.resx @@ -631,4 +631,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..6149062598 100644 --- a/src/Beutl.Language/Strings.ja.resx +++ b/src/Beutl.Language/Strings.ja.resx @@ -993,6 +993,9 @@ 移動のフォールバックも失敗しました。タイムラインの状態と表示が一致しない可能性があります。 + + クリップを追加する前にプロジェクトを保存してください。 + Beutlについて @@ -1570,4 +1573,338 @@ GetProperty<T>("path") または GetProperty<T>(guid, "propertyName" 素材 + + バージョン管理 + + + バージョン管理を有効化… + + + バージョン管理を有効化しています… + + + プロジェクトを保存して最初のバージョンを記録しています。プロジェクトが大きい場合は時間がかかることがあります。 + + + このプロジェクトを Git で管理すると、保存時にバージョンが自動的に記録されます。 + + + Git をダウンロード + + + Git で履歴を記録 + + + バージョンをコミット… + + + プッシュ + + + プル + + + 復元 + + + 新しいブランチに復元… + + + 新しいブランチ… + + + 切り替え + + + バージョン履歴 + + + 変更されたファイル + + + コミットメッセージ + + + 手動 + + + 保存 + + + 終了時 + + + 安全スナップショット + + + 復元 + + + 復旧 + + + 初期化 + + + 記録する新しい変更はありません。 + + + コミット + + + バージョンを記録しました。 + + + プロジェクト履歴を記録するには Git が必要です。 + + + Git 2.36 以降が必要です。インストール済みのバージョンは {0} です。 + + + Git for Windows をインストールしてから Beutl を再起動してください。 + + + Xcode コマンドラインツール、または Homebrew で Git をインストールしてから Beutl を再起動してください。 + + + ディストリビューションのパッケージマネージャーで Git をインストールしてから Beutl を再起動してください。 + + + バージョン管理エラー + + + プロジェクトファイルは保存されましたが、履歴の自動スナップショットを記録できませんでした。Git リポジトリを確認してから、もう一度保存してください。 + + + 復元には失敗しましたが、元のプロジェクトは復旧されました。安全のためブランチ '{0}' を保持しています。他の Git ワークツリーで使用されていないことを確認してから、外部の Git ツールで手動削除してください。 + + + このリポジトリには未解決の競合があります。続行する前に外部の 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 リポジトリロックを削除しました。バージョン管理操作を再試行してください。 + + + 現在のファイルシステムでは、この Git ロックを安全に削除できません。外部の Git ツールを閉じ、Git プロセスが実行中でないことを確認してから、{0} を手動で削除して再試行してください。 + + + バージョン管理がプロジェクトファイルを更新しています。処理の完了後に再試行してください。 + diff --git a/src/Beutl.Language/Strings.resx b/src/Beutl.Language/Strings.resx index af082be71d..a7212abbbe 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 @@ -1576,4 +1579,338 @@ Example: GetProperty<double>("{GUID}.Opacity") Materials + + 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.36 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 + + + The project files were saved, but the automatic history snapshot could not be recorded. Check the Git repository, then save again. + + + The restore failed, but the original project was recovered. The branch '{0}' was retained for safety. After confirming that no other Git worktree uses it, delete it manually with an external Git tool. + + + 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. + + + Beutl cannot safely remove this Git lock on the current filesystem. Close external Git tools, verify that no Git process is running, then remove {0} manually and retry. + + + Project files are being updated by version control. Wait for the operation to finish, then try again. + diff --git a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs index 6668d59fc9..6babd41326 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs @@ -517,14 +517,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 +575,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 +585,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 +691,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 +717,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 +733,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 +747,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/Pages/SettingsPages/EditorSettingsPage.axaml b/src/Beutl/Pages/SettingsPages/EditorSettingsPage.axaml index 88c00e13c9..3072c7a7a8 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..46c4f345a7 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; @@ -71,19 +75,443 @@ public sealed class EditorService { private readonly CoreList _tabItems; private readonly ExtensionProvider _extensionProvider; + private readonly Action _serializeProject; + private readonly ReactivePropertySlim + _projectVersionControlService = new(); + private readonly object _workspaceOperationSync = new(); + private readonly object _editorSuspensionSync = new(); + private readonly Dictionary + _editorSuspensions + = new(ReferenceEqualityComparer.Instance); + 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); + } + } + + // Legacy output contexts report their lifetime through Started/Finished events. Keep that + // existing host contract local to the app while version-control mutations consult the same + // counter; the separate output-lifecycle change owns any public extension API redesign. + internal void NotifyOutputStarted() + { + lock (_workspaceOperationSync) + { + _activeOutputOperations++; + } + } + + internal void NotifyOutputFinished() + { + EndWorkspaceOperation(WorkspaceOperationKind.Output); + } + + internal IDisposable SuspendEditor(IEditorContext context) + { + ArgumentNullException.ThrowIfNull(context); + + lock (_editorSuspensionSync) + { + bool isTrackedByTab = TabItems.Any( + item => ReferenceEquals(item.Context.Value, context)); + if (_editorSuspensions.TryGetValue(context, out var state)) + { + _editorSuspensions[context] = ( + state.Count + 1, + state.WasEnabled, + state.WasTrackedByTab || isTrackedByTab); + } + else + { + bool wasEnabled = context.IsEnabled.Value; + _editorSuspensions.Add(context, (1, wasEnabled, isTrackedByTab)); + try + { + context.IsEnabled.Value = false; + } + catch (Exception disableFailure) + { + _editorSuspensions.Remove(context); + try + { + context.IsEnabled.Value = wasEnabled; + } + catch (Exception restoreFailure) + { + throw new AggregateException(disableFailure, restoreFailure); + } + + throw; + } + } + } + + return new EditorContextSuspensionLease(this, context); + } + + private void EndEditorSuspension(IEditorContext context) + { + lock (_editorSuspensionSync) + { + if (!_editorSuspensions.TryGetValue(context, out var state)) + { + return; + } + + if (state.Count > 1) + { + _editorSuspensions[context] = ( + state.Count - 1, + state.WasEnabled, + state.WasTrackedByTab); + } + else + { + _editorSuspensions.Remove(context); + if (!state.WasTrackedByTab + || TabItems.Any(item => ReferenceEquals(item.Context.Value, context))) + { + context.IsEnabled.Value = state.WasEnabled; + } + } + } + } + + 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 share a per-context reference count with output execution, so they can complete + /// in any order without re-enabling an editor still owned by another operation. + /// + internal IDisposable SuspendEditors() + { + EditorTabItem[] tabItems = TabItems.ToArray(); + var suspensions = new List(tabItems.Length); + try + { + foreach (EditorTabItem item in tabItems) + { + if (item.Context.Value is { } context) + { + suspensions.Add(SuspendEditor(context)); + } + } + + return new EditorSuspension(suspensions.ToArray()); + } + catch (Exception acquisitionFailure) + { + var failures = new List { acquisitionFailure }; + foreach (IDisposable suspension in suspensions) + { + try + { + suspension.Dispose(); + } + catch (Exception cleanupFailure) + { + failures.Add(cleanupFailure); + } + } + + throw new AggregateException(failures); + } + } + + 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 +557,83 @@ public async ValueTask CloseTabItem(EditorTabItem item) TabItems.Remove(item); await item.DisposeAsync(); } + + private sealed class EditorSuspension(IDisposable[] suspensions) : IDisposable + { + private int _disposed; + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + List? failures = null; + foreach (IDisposable suspension in suspensions) + { + try + { + suspension.Dispose(); + } + catch (Exception ex) + { + (failures ??= []).Add(ex); + } + } + + if (failures is not null) + { + throw new AggregateException(failures); + } + } + } + + 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 sealed class EditorContextSuspensionLease( + EditorService owner, + IEditorContext context) : IDisposable + { + private int _disposed; + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 0) + { + owner.EndEditorSuspension(context); + } + } + } + + private enum WorkspaceOperationKind + { + Output, + ProjectFileWrite, + WorktreeMutation, + } } diff --git a/src/Beutl/Services/OutputService.cs b/src/Beutl/Services/OutputService.cs index e225a75c15..184e314220 100644 --- a/src/Beutl/Services/OutputService.cs +++ b/src/Beutl/Services/OutputService.cs @@ -13,6 +13,7 @@ public sealed class OutputProfileItem : IDisposable { private readonly ILogger _logger = Log.CreateLogger(); private readonly EditorService _editorService; + private int _isRunning; public OutputProfileItem(IOutputContext context, IEditorContext editorContext, EditorService editorService) { @@ -32,6 +33,11 @@ public OutputProfileItem(IOutputContext context, IEditorContext editorContext, E private void OnStarted(object? sender, EventArgs e) { + if (Interlocked.Exchange(ref _isRunning, 1) == 0) + { + _editorService.NotifyOutputStarted(); + } + _logger.LogDebug("Output started for file: {File}", Context.Object.Uri); if (_editorService.TryGetTabItem(Context.Object, out EditorTabItem? tabItem)) @@ -47,6 +53,7 @@ private void OnStarted(object? sender, EventArgs e) private void OnFinished(object? sender, EventArgs e) { + FinishOutputTracking(); _logger.LogDebug("Output finished for file: {File}", Context.Object.Uri); if (_editorService.TryGetTabItem(Context.Object, out EditorTabItem? tabItem)) @@ -62,12 +69,21 @@ private void OnFinished(object? sender, EventArgs e) public void Dispose() { + FinishOutputTracking(); _logger.LogInformation("Disposing OutputProfileItem for file: {File}", Context.Object.Uri); Context.Started -= OnStarted; Context.Finished -= OnFinished; Context.Dispose(); } + private void FinishOutputTracking() + { + if (Interlocked.Exchange(ref _isRunning, 0) == 1) + { + _editorService.NotifyOutputFinished(); + } + } + public static JsonNode ToJson(OutputProfileItem item) { var ctxJson = new JsonObject(); 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/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..2990c82648 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,332 @@ 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 void CloseProject() + { + Task close = CloseProjectAsync(); + if (Avalonia.Threading.Dispatcher.UIThread.CheckAccess()) + { + while (!close.IsCompleted) + { + Avalonia.Threading.Dispatcher.UIThread.RunJobs(); + Thread.Sleep(1); + } + } + + close.GetAwaiter().GetResult(); + } + + internal async Task CloseProjectAsync(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 +448,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 +462,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 +526,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 +561,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 +579,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) + { + _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 { - ViewConfig viewConfig = GlobalConfiguration.Instance.ViewConfig; - viewConfig.UpdateRecentProject(file); - viewConfig.UpdateRecentFile(file); - viewConfig.LastOpenedProjectFile = file; + 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/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..dbb6f0852e --- /dev/null +++ b/src/Beutl/Services/VersionControlCoordinator.cs @@ -0,0 +1,6908 @@ +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 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 + && PathsEqual(activationRoot, projectRoot) + ? 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) + || !PathsEqual(currentRoot, projectRoot) + || _state.ProjectRoot is not { } stateRoot + || !PathsEqual(stateRoot, projectRoot)) + { + 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; + } + + try + { + try + { + if (!await InitializeWithEditorSuspensionAsync( + service, + options, + expectedProject, + operationCancellation)) + { + return false; + } + } + catch (GitIdentityRequiredException) + { + // InitializeWithEditorSuspensionAsync has released the editor before this prompt. + // The user can keep editing while entering an identity; the retry re-saves those + // edits after acquiring a fresh suspension. + GitIdentity? identity = await requestIdentityAsync(operationCancellation); + if (identity is null) + { + return false; + } + + operationCancellation.ThrowIfCancellationRequested(); + if (!await InitializeWithEditorSuspensionAsync( + service, + options with { Identity = identity }, + expectedProject, + operationCancellation)) + { + return false; + } + } + } + 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 + || !PathsEqual(stateRoot, projectRoot)) + { + 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; + } + + private async Task InitializeWithEditorSuspensionAsync( + IProjectVersionControlBackend service, + InitOptions options, + Project expectedProject, + CancellationToken cancellationToken) + { + IDisposable? editorSuspension = null; + try + { + editorSuspension = await SuspendEditorsAsync(cancellationToken); + + // The initial revision has to record what the user sees, so in-memory edits reach disk + // first. Keep the outer suspension through InitializeAsync: its Git hooks can await + // arbitrary work, and edits made after this save would otherwise miss the commit. + if (!await TrySaveOpenProjectAsync(expectedProject, cancellationToken)) + { + PublishNotification(() => + NotificationService.ShowWarning( + Strings.VersionControl, + MessageStrings.OperationFailed)); + return false; + } + + await service.InitializeAsync(options, cancellationToken); + return true; + } + finally + { + await ReleaseEditorSuspensionAsync(editorSuspension); + } + } + + 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 + || !PathsEqual(currentRoot, projectRoot)) + { + 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 + || !PathsEqual(currentRoot, projectRoot) + || !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, + LfsPrefetchScope.RepositoryWide, + 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 VersionControlPathComparison.AreSameCanonicalPath(left, right); + } + + private static bool NullablePathsEqual(string? left, string? right) + { + return left is null || right is null + ? left is null && right is null + : PathsEqual(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, StringComparison.Ordinal)) + { + 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, StringComparison.Ordinal)) + { + 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 (VersionControlPathComparison.AreSameCanonicalPath( + 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( + VersionControlPathComparison.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; + CheckedOutBranchTip? createdBranchTip = null; + 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, + LfsPrefetchScope.ProjectPathspec, + 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); + createdBranchTip = new CheckedOutBranchTip( + $"refs/heads/{branchName}", + originalTip.Commit); + branchTip = await service.GetCheckedOutBranchTipAsync( + CancellationToken.None); + expectedResultTip = branchTip; + if (!BranchTipsEqual(branchTip, createdBranchTip)) + { + throw new InvalidOperationException( + "The restore branch changed immediately after it was created."); + } + } + 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); + createdBranchTip = expectedResultTip; + 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 null && createdBranchTip is not null) + { + PublishRetainedRestoreBranchWarning(createdBranchTip); + } + + 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 void PublishRetainedRestoreBranchWarning(CheckedOutBranchTip createdBranchTip) + { + string branchName = GetLocalBranchName(createdBranchTip.RefName); + _logger.LogWarning( + "Retained failed restore branch {BranchRef} at {Commit} for manual cleanup.", + createdBranchTip.RefName, + createdBranchTip.Commit); + PublishNotification(() => + NotificationService.ShowWarning( + Strings.VersionControl, + string.Format( + CultureInfo.CurrentCulture, + Strings.VersionControl_RestoreBranchRetainedFormat, + branchName))); + } + + 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 (reopenedPath is null || !PathsEqual(reopenedPath, projectFile)) + { + 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 VersionControlPathComparison.IsSameOrDescendant(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 (VersionControlPathComparison.AreSameCanonicalPath(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) + || !VersionControlPathComparison.AreSameCanonicalPath( + 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; + } + + using IDisposable? snapshotMutation = TryBeginWorktreeMutation(completedWrite); + if (snapshotMutation is null) + { + _logger.LogInformation( + "Skipped the {SnapshotKind} project snapshot because the workspace is reserved.", + kind); + return; + } + + Project? savedProject = _projectService.CurrentProject.Value; + if (savedProject is null) + { + return; + } + + string savedProjectRoot = GetProjectRoot(savedProject); + IProjectVersionControlBackend? service = await WaitForSaveSnapshotBackendAsync( + savedProject, + savedProjectRoot, + cancellationToken) + .ConfigureAwait(false); + if (service is null) + { + 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); + if (kind == SnapshotKind.Save) + { + PublishNotification(() => + NotificationService.ShowWarning( + Strings.VersionControl, + Strings.VersionControl_SaveSnapshotFailed)); + } + } + } + + private async Task WaitForSaveSnapshotBackendAsync( + Project savedProject, + string savedProjectRoot, + CancellationToken cancellationToken) + { + ActivationContext? waitedActivation = null; + while (true) + { + ActivationContext? activation; + lock (_stateGate) + { + if (_disposed + || !ReferenceEquals(_projectService.CurrentProject.Value, savedProject) + || _state.ProjectRoot is not { } currentRoot + || !PathsEqual(currentRoot, savedProjectRoot)) + { + return null; + } + + if (ReferenceEquals(_state.OwnedService, _state.VisibleService) + && _state.OwnedService is { Repository: not null } ready) + { + return ready; + } + + activation = _activation; + if (activation is null + || ReferenceEquals(activation, waitedActivation) + || !PathsEqual(activation.ProjectRoot, savedProjectRoot)) + { + return null; + } + } + + waitedActivation = activation; + await activation.Completion.WaitAsync(cancellationToken).ConfigureAwait(false); + } + } + + 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) + && VersionControlPathComparison.AreSameCanonicalPath( + 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 + && VersionControlPathComparison.AreSameCanonicalPath( + 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) + && VersionControlPathComparison.AreSameCanonicalPath( + 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(); + 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 + || NullablePathsEqual(executablePath, _observedGitExecutablePath)) + { + 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 + && PathsEqual(stateRoot, projectRoot) + && _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 + && !PathsEqual(stateRoot, request.ProjectRoot)) + { + _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 + && PathsEqual(trackedRoot, request.ProjectRoot) + && _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 + && NullablePathsEqual( + _observedGitExecutablePath, + request.ExecutablePath) + && _state.OwnedService?.Repository is null; + if (retry + && !_disposed + && request.Revision == _nextConfigurationActivationRevision + && _pendingConfigurationActivation is null + && ReferenceEquals(_projectService.CurrentProject.Value, request.Project) + && (_state.ProjectRoot is null + || PathsEqual(_state.ProjectRoot, request.ProjectRoot)) + && 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 + && PathsEqual(projectRoot, activation.ProjectRoot) + && !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 + && PathsEqual(projectRoot, activation.ProjectRoot) + && !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) + && NullablePathsEqual(_state.ProjectRoot, next.ProjectRoot) + && _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) + || !ReferenceEquals(recovery.RecoverableLock, lockInfo)) + { + return; + } + + if (!await ConfirmRemoveStaleLockAsync(lockInfo, _lifetimeCancellation.Token) + || _disposed + || !ReferenceEquals(CurrentService, sender) + || !ReferenceEquals(recovery.RecoverableLock, lockInfo)) + { + return; + } + + bool removed = await recovery.RemoveRecoverableLockAsync( + lockInfo, + _lifetimeCancellation.Token); + if (removed) + { + _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)); + } + else + { + await _dispatcher.InvokeAsync(() => + NotificationService.ShowWarning( + Strings.VersionControl, + string.Format( + CultureInfo.CurrentCulture, + Strings.VersionControl_StaleLockManualRemovalRequiredFormat, + lockInfo.LockPath))); + } + } + 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; +} diff --git a/src/Beutl/ViewModels/Dialogs/CreateNewProjectViewModel.cs b/src/Beutl/ViewModels/Dialogs/CreateNewProjectViewModel.cs index 6a62ff9c22..04efe84d47 100644 --- a/src/Beutl/ViewModels/Dialogs/CreateNewProjectViewModel.cs +++ b/src/Beutl/ViewModels/Dialogs/CreateNewProjectViewModel.cs @@ -1,21 +1,38 @@ 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) + : this(projectService, versionControlInitializer: null, requestIdentityAsync: null) { - _projectService = projectService; + } + + public CreateNewProjectViewModel( + ProjectService projectService, + IProjectVersionControlInitializer? versionControlInitializer, + Func>? requestIdentityAsync) + { + _projectService = projectService ?? throw new ArgumentNullException(nameof(projectService)); + _versionControlInitializer = versionControlInitializer; + _requestIdentityAsync = requestIdentityAsync; Location.Value = GetDefaultLocation(); Name.Value = GenProjectName(Location.Value); + _ = DetectGitAsync(); Name.SetValidateNotifyError(n => { @@ -86,12 +103,33 @@ 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 + && _versionControlInitializer is not null + && _requestIdentityAsync is not null) + { + try + { + await _versionControlInitializer.InitializeCurrentProjectAsync( + project, + _requestIdentityAsync, + CancellationToken.None); + } + catch (Exception ex) + { + await ex.Handle(); + } + } }); } @@ -109,6 +147,40 @@ await _projectService.CreateProject( public AsyncReactiveCommand Create { get; } + public ReactivePropertySlim TrackHistory { get; } = new(); + + public ReactivePropertySlim IsGitAvailable { get; } = new(); + + private async Task DetectGitAsync() + { + if (_versionControlInitializer is null) + { + TrackHistory.Value = false; + IsGitAvailable.Value = false; + return; + } + + 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 0744eb9218..d7a8599ff8 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; using Beutl.Graphics.Rendering.Cache; using Beutl.Helpers; @@ -32,6 +33,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 = []; @@ -394,18 +396,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."); } }); } @@ -595,8 +607,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(); @@ -911,6 +935,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; @@ -1114,7 +1147,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/MainViewModel.cs b/src/Beutl/ViewModels/MainViewModel.cs index a50612b466..81cce6cea2 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; @@ -23,6 +24,7 @@ public sealed class MainViewModel : BasePageViewModel, IContextCommandHandler 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(); @@ -35,11 +37,12 @@ public MainViewModel() _extensionProvider = new ExtensionProvider(); _projectService = new ProjectService(); _editorService = new EditorService(_extensionProvider); + _versionControlCoordinator = new VersionControlCoordinator(_projectService, _editorService); _agentHostEndpoint = new AgentHostEndpoint(_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 +51,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 +105,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 +115,8 @@ public MainViewModel() internal EditorService EditorService => _editorService; + internal VersionControlCoordinator VersionControlCoordinator => _versionControlCoordinator; + internal ExtensionProvider ExtensionProvider => _extensionProvider; internal AgentHostEndpoint AgentHostEndpoint => _agentHostEndpoint; @@ -149,8 +160,10 @@ public void RegisterServices() public override void Dispose() { CommandPalette.Dispose(); + TitleBarBranch.Dispose(); _agentHostEndpoint.RequestStop(); _projectService.CloseProject(); + _versionControlCoordinator.Dispose(); BeutlApplication.Current.Items.Clear(); } diff --git a/src/Beutl/ViewModels/MenuBarViewModel.Files.cs b/src/Beutl/ViewModels/MenuBarViewModel.Files.cs index 6f321623c5..33b8b29f2d 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.CloseProjectAsync(); + 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/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..91257ee915 100644 --- a/src/Beutl/Views/MacWindow.axaml.cs +++ b/src/Beutl/Views/MacWindow.axaml.cs @@ -138,10 +138,10 @@ private void InitExtMenuItems(MainViewModel viewModel) try { var rootMenu = NativeMenu.GetMenu(this)!; - viewMenuItem = (NativeMenuItem)rootMenu.Items[2]; + viewMenuItem = (NativeMenuItem)rootMenu.Items[3]; editorTabMenu = ((NativeMenuItem)viewMenuItem.Menu!.Items[0]).Menu; toolTabMenu = ((NativeMenuItem)viewMenuItem.Menu!.Items[1]).Menu; - toolWindowMenu = ((NativeMenuItem)rootMenu.Items[3]).Menu; + toolWindowMenu = ((NativeMenuItem)rootMenu.Items[4]).Menu; // View > ... > "Apply dock layout" (see MacWindow.axaml). dockLayoutPresetMenu = ((NativeMenuItem)viewMenuItem.Menu!.Items[^2]).Menu; } 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,120 @@ 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) + { + Project? expectedProject = viewModel.ProjectService.CurrentProject.Value; + IProjectVersionControlService? expectedService = + viewModel.VersionControlCoordinator.CurrentService; + if (expectedProject is null || expectedService is null) + { + return; + } + + var flyout = new VersionControlPickerFlyout(); + string? message = await flyout.ShowTextInputAsync( + GetVersionControlFlyoutAnchor(), + Strings.VersionControl_Commit, + Strings.VersionControl_CommitMessage, + initialText: null); + if (string.IsNullOrWhiteSpace(message)) + { + return; + } + + if (!IsCurrentCommitTarget( + expectedProject, + expectedService, + viewModel.ProjectService.CurrentProject.Value, + viewModel.VersionControlCoordinator.CurrentService)) + { + 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(); + } + } + + internal static bool IsCurrentCommitTarget( + Project expectedProject, + object expectedService, + Project? currentProject, + object? currentService) + { + return ReferenceEquals(expectedProject, currentProject) + && ReferenceEquals(expectedService, currentService); + } + + 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) diff --git a/src/Beutl/Views/MainView.axaml.cs b/src/Beutl/Views/MainView.axaml.cs index 28e185a722..18d8dc6fbe 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); } 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}"> - +