diff --git a/CHANGELOG.md b/CHANGELOG.md
index d442968..9461e08 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,20 @@
# Changelog
+## 0.2.0 — 2026-10-01
+
+**Source control** — a new tab that drives Fabric Git integration across every workspace you can see.
+- Scan: Git connection, repo, folder and branch of each workspace, grouped by repo and folder or listed A–Z by workspace; branch workspaces and their source; workspaces sharing a branch.
+- Freshness: stale (no sync for 7 days) and needs attention (30 days, never initialised, conflicts). Thresholds in `settings.json`.
+- Update from Git and Commit to Git, with the change list first, selective commits and an explicit conflict policy. Updates that would delete a lakehouse, warehouse or database are refused.
+- Offload items Git can't track to a nominated local folder, in the Fabric Git layout with a manifest. Reports without a definition are saved as PBIX, others as metadata.
+- Repoint a workspace to another branch: preflight, full local backup, preview of every add / overwrite / delete before anything changes, blocks on data-bearing deletions, automatic restore on failure, a journal that survives crashes, and branch-workspace links put back.
+- Step-by-step progress with live percentages for long service operations.
+
+Repointing is new and changes a workspace's Git connection: try it on a test workspace first. It needs the workspace Admin role.
+
+**Interface**
+- Clearer text: 14px body, nothing below 12px, subpixel rendering, less negative letter spacing.
+
## 0.1.0 — first public release
A cross-platform (Windows, Linux) desktop editor for Microsoft Fabric semantic models, notebooks and lakehouses.
diff --git a/CLAUDE.md b/CLAUDE.md
index 7168559..08392ec 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -7,7 +7,8 @@ A cross-platform desktop editor for Microsoft Fabric. It edits semantic models l
- **TOM SDK** (`Microsoft.AnalysisServices.NetCore.retail.amd64`) stays at `19.84.1` unless there is explicit approval. It connects over HTTP XMLA with `Server.AccessToken`, which works cross-platform. TCP and local PBI Desktop connections are Windows-only and out of scope.
- **Auth:** one MSAL public client (Power BI Desktop client id, system-browser loopback). The Power BI-audience token covers the Power BI REST, Fabric REST and XMLA APIs. OneLake uses a storage-audience token.
- **Licence:** source-available (Daxis Community License 1.0), not open source. Never call it open source, never add copyleft dependencies, and add every new third-party component to `THIRD-PARTY-NOTICES.md`.
-- **Scope:** semantic models, notebooks and lakehouses only. There is no Git integration, no local PBI Desktop and no licensing.
+- **Scope:** semantic models, notebooks, lakehouses and source control. Source control drives Fabric's own Git integration through its REST API (scan, status, commit, update, repoint) and offloads items Git can't track to a local folder. Daxis never pushes from the local machine and never deletes a data-bearing item. No local PBI Desktop and no licensing.
+- **Repoint safety:** every step that changes a workspace's Git connection is written to `RepointJournal` first, and any failure after disconnect restores the original branch. Never add a path that disconnects without a journal, or that updates without the user having seen the preview.
- **Save safety:** model edits stay local until the user has reviewed the before/after list and clicked "Confirm & save to service". Every other write (refresh, notebook save/run) goes through `TabBase.Ask(...)`. Never add a write path that skips both.
- **Token safety:** only `FabricClient` sends tokens, and `IsTrusted(url, scope)` allowlists hosts per audience. Never add another HTTP path with a bearer token. Browser links go through `Web.Open`, which accepts Fabric/Power BI portal links plus the two product pages (GitHub repo, portable-labs.com) only.
- **Config:** `DAXIS_CLIENT_ID` sets your own Entra app (the default is the Power BI Desktop public client). `DAXIS_HOME` sets the data folder, for portable installs and isolated test runs.
@@ -27,12 +28,17 @@ A cross-platform desktop editor for Microsoft Fabric. It edits semantic models l
| `src/Daxis.Core/ReportAnalysis.cs` | Report definitions (PBIR and legacy): pages, visuals, slicers, applied filters, field refs. `Coverage` classifies model fields as used in reports / model only / unused. `Dax` token-level reference finder |
| `src/Daxis.Core/ModelHealth.cs` | Storage (VertiPaq DMVs → size per column/table) and refresh health (schedule, history, durations) |
| `src/Daxis.Core/Treemap.cs` | Squarified treemap layout (pure maths; `TreemapView` renders it) |
+| `src/Daxis.Core/FabricGit.cs` | Fabric Git integration endpoints (partial of `FabricClient`) |
+| `src/Daxis.Core/SourceControl.cs` | Git models and parsing, freshness, branch families, tracked / data-bearing item types, switch and update impact classification, preflight, `RepointJournal` |
+| `src/Daxis.Core/Repoint.cs` | `Repointer`: backup → disconnect → connect → initialise → preview → apply or restore |
+| `src/Daxis.Core/Offload.cs` | Writes item definitions to disk in the Fabric Git layout (atomic per item, path-safe, manifest); offloads and repoint backups |
| `src/Daxis.Core/ModelGraph.cs` | Graphs plus layout for the model diagram (relationships) and pipeline lineage (sources → staging → tables → reports) |
| `src/Daxis.App/GraphCanvas.cs` | Pan/zoom canvas that renders a `Graph`: entrance animation, hover tracing with flowing particles, semantic zoom, drag, find |
| `src/Daxis.App/UsageBar.cs` | Animated share bar (report usage) |
| `src/Daxis.App/MainViewModel.cs` | Shell: sign-in, workspace, item lists, tabs, theme |
| `src/Daxis.App/ModelTab.cs` | Semantic model view model: typed object tree, DAX/M editing, overview, diagram, lineage, report usage, review-then-save |
| `src/Daxis.App/WorkspaceTab.cs` | Workspace overview: item inventory, models and reports, sources → models → reports lineage |
+| `src/Daxis.App/SourceControlTab.cs` | Source control view model: scan, detail, commit / update, offload, repoint, recovery, operation stepper |
| `src/Daxis.App/Tabs.cs` | `TabBase`, `NotebookTab`, `LakehouseTab` |
| `src/Daxis.App/Views/*` | One view per tab type. Views are cached per tab in `MainWindow` |
| `src/Daxis.App/Editors.cs` | AvaloniaEdit setup, theme-aware highlighting and completion popup |
diff --git a/DESIGN.md b/DESIGN.md
index 1cbcc44..2085543 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -31,7 +31,8 @@ colors:
typography:
ui: "Inter (Circular substitute), embedded"
mono: "JetBrains Mono, embedded"
- scale: { h1: 22/500/-0.4, title: 20/500/-0.3, h2: 15/500, body: 13/400, caption: 12/400, label: 11/500/+0.6 uppercase }
+ scale: { h1: 22/500/-0.2, title: 20/500/-0.2, h2: 16/500, body: 14/400, small: 13/400, caption: 12/400 (minimum), label: 11.5/500/+0.6 uppercase, mono: 12.5, editor: 13 }
+ rendering: subpixel antialiasing; negative tracking only at 20px and above
rounded: { control: 6, card: 8, pill: 999 }
spacing: [2, 4, 8, 12, 16, 20, 24, 32]
---
@@ -63,7 +64,7 @@ The tokens live in `src/Daxis.App/Theme/Tokens.axaml` and the component styles i
## Components
| Component | Rule |
|---|---|
-| Button (default) | `bg2` fill, `border-strong` hairline, 6px radius, 13/500, 30px min height |
+| Button (default) | `bg2` fill, `border-strong` hairline, 6px radius, 14/500, 30px min height |
| Button.primary | `brand` fill, `on-brand` text, hover `brand-hover`. Only one per view |
| Button.ghost | Transparent icon button with `fg-muted` icon and `bg3` on hover |
| Button.nav | Sidebar row: 28px, `fg-muted`, `bg3` when active or hovered |
@@ -76,12 +77,16 @@ The tokens live in `src/Daxis.App/Theme/Tokens.axaml` and the component styles i
| Graph canvas | `bg1` with a `border-strong` dot grid. Cards are `bg2` with an 8px radius and hairline; edges are `border-strong` beziers. Tracing dims everything else to 14% and draws the path in `brand-text` with `brand` particles moving in the flow direction. Zoomed out, detail fades and titles grow |
| Floating toolbar | `bg2`, hairline, 8px radius, no shadow; top-left over a canvas, hints bottom-left |
| Usage bar | `bg3` track, `brand` for the primary share over `brand-border` for the secondary; grows from zero |
+| Stepper | One row per step of a long operation: hollow ring (pending), pulsing brand ring (running, with a 3px determinate bar when the service reports a percentage), brand check (done), danger cross (failed). Sits in a card over a scrim; the result shows as a callout and the card closes only when nothing is running |
+| Skeleton | `bg3` bar that breathes (opacity 0.35↔1) only while its row is loading |
+| Callout | `bg2` card with hairline for explanations; `.danger` (DangerSoft + Danger) for blocks, `.warn` (Warning hairline) for warnings that need acknowledgement |
+| Status dot | 8px: brand = fresh, `Warning` = stale, `Danger` = needs attention, `fg-faint` = unknown |
| Icons | Hand-drawn 24×24 stroke geometry at a 1.6px stroke (`Icon.cs`). No emoji, no icon fonts |
## Motion
- Ease out, never bounce. Canvas motion is exponential easing (frame-rate independent); page switches fade in over 280ms.
- Animate to explain: entrances stagger left to right in flow order, and particles show direction. Nothing loops while the view is idle.
-- Request frames only while something is moving.
+- Request frames only while something is moving. The only looping animations (skeleton, running step) exist only while work is in flight.
## Syntax colours
The same roles apply to DAX and Python:
diff --git a/Directory.Build.props b/Directory.Build.props
index e9fce48..f899d6a 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -4,6 +4,8 @@
enableenablelatest
+
+ 0.2.0true
diff --git a/README.md b/README.md
index 150489a..e53d04e 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,13 @@ Windows and Linux. Free for individuals and small businesses.
- **Refresh health** and per-table freshness.
- **Edit** DAX and Power Query with IntelliSense and formatters. **Every change is reviewed** before/after, and nothing is written until you confirm.
+**Source control** (Fabric Git integration, across every workspace you can see)
+- **Scan:** which workspaces are on Git, the repo, folder and branch each uses, branch workspaces and their source, and workspaces sharing one branch.
+- **Freshness:** flags workspaces not synced for a week (stale) or a month, never initialised, or in conflict (needs attention).
+- **Update from Git / Commit to Git** with the item list first, selective commits and an explicit choice for conflicts.
+- **Offload** items Git can't track (dashboards, datamarts, dataflows Gen1…) to a folder you choose, in the Fabric Git layout.
+- **Repoint a workspace to another branch without losing anything:** refuses if anything is uncommitted, backs up every item locally, previews exactly what the branch would add, overwrite or delete, never deletes a lakehouse, warehouse or database, and puts the original branch back if you cancel or anything fails (even after a crash).
+
**Also:** notebooks (edit, save, run), lakehouses (tables and files), Ctrl+K workspace search, light and dark themes.
@@ -82,12 +89,14 @@ Linux needs a desktop session. Minimal images and WSL may also need `sudo apt in
- A work or school account with access to the workspace.
- For semantic models: a workspace on **Fabric, Premium or PPU** capacity with the **XMLA endpoint** enabled (read-write to edit), and Contributor or higher.
- Report usage reads report definitions, which Fabric only allows with **edit rights on the report**.
+- Source control: Git integration enabled in the tenant, your Git account set up in the workspace's Source control panel, **Contributor** to see status, commit and update, **Admin** to repoint a branch. If Git calls are refused for missing permissions, register your own Entra app with the Fabric `Workspace.ReadWrite.All`, `Workspace.GitCommit.All` and `Workspace.GitUpdate.All` delegated scopes and point Daxis at it with `DAXIS_CLIENT_ID`.
## Security
- **No Daxis server, no telemetry.** Daxis only talks to Microsoft sign-in, Fabric, Power BI and OneLake.
- **Tokens stay on your machine** (DPAPI-encrypted on Windows, owner-only on Linux) and are never sent to any other host.
- **Nothing changes in Fabric without your confirmation.**
+- **Git credentials never touch Daxis.** Fabric talks to GitHub or Azure DevOps itself; Daxis never pushes from your machine. Offloads and backups are written only to the folder you pick.
- Sign-in uses the Power BI Desktop public client. To use your own Entra app, set `DAXIS_CLIENT_ID`. `DAXIS_HOME` moves the data folder.
Details and vulnerability reporting: [SECURITY.md](SECURITY.md).
diff --git a/docs/plans/fabric-git-sync.md b/docs/plans/fabric-git-sync.md
new file mode 100644
index 0000000..9d81643
--- /dev/null
+++ b/docs/plans/fabric-git-sync.md
@@ -0,0 +1,123 @@
+# Source Control — Fabric Git integration in Daxis
+
+Status: shipped in v0.2.0 (2026-10-01). Repoint still to be live-tested on safe workspaces. Supersedes the first draft (local snapshot only).
+Origin: `Fabric_to_Git` Python experiment (fab CLI export). Its review is kept at the bottom; its export
+engine becomes the **offload** feature below.
+
+## Required features
+1. **Scan** — which workspaces are Git-connected, to what (provider / repo / directory / branch), including branch families.
+2. **Staleness** — flag workspaces not synced for a while, or with pending incoming/uncommitted changes.
+3. **Update from Git** (Git → workspace) and **Commit to Git** (workspace → Git), with a preview first.
+4. **Offload unsupported items** to a nominated local folder, clearly defined in the UI.
+5. **Processing feedback** — visible, animated progress for every long operation.
+6. **Safe repoint** of a workspace to another branch, then sync — **no data loss is the hard requirement.**
+
+## Platform facts that shape the design (Microsoft Learn, checked 2026-09-30)
+- APIs: `git/connection`, `git/status`, `git/commitToGit`, `git/updateFromGit`, `git/connect`, `git/disconnect`,
+ `git/initializeConnection`, `git/myGitCredentials`, `git/workspaceRelation` (create, preview). Commit/update are LROs.
+- **There is no "switch branch" API.** Portal Switch Branch = sync to the new branch; API equivalent is
+ disconnect → connect → initialize → update.
+- Switching overrides every supported item; **items in the old branch but not the new one are deleted.**
+- Portal refuses to switch with uncommitted changes. The API path has no such guard — **Daxis must enforce it.**
+- **Disconnect removes branch-workspace relationships** (both as branch and as source). Must be re-created via the
+ Create Workspace Relation API after reconnecting.
+- Connect/disconnect are **workspace Admin only**. Switch/checkout is Admin, or Contributor+ when the workspace setting allows.
+- Unsupported items are ignored by Git: never synced, never deleted — but they can block an update with a dependency error.
+- Lakehouse tables/files are never touched by Git ops, **but deleting a data-bearing item (Lakehouse, Warehouse,
+ SQL database, Eventhouse/KQL DB, Mirrored DB) deletes its data.** A later Git sync recreates only the empty shell.
+- Git connection is per user: GitHub needs a configured credentials connection (`connectionId`); ADO can be automatic.
+- Commits from the service normalise CRLF → LF; Enhanced Refresh API causes semantic-model diffs. Don't report these as "your changes".
+- Duplicate item names make commit/update fail.
+
+## UX
+A new tenant-level **Source control** tab (opened from the sidebar, not per item).
+
+| Area | Content |
+|---|---|
+| **Scan grid** | Workspace · provider · repo/dir · branch · state (Not connected / Connected / Initialised) · last sync (relative, coloured) · uncommitted / incoming / conflicts · offload folder. Rows fill in as each workspace answers (skeleton shimmer until then). 403 → "No access" rather than an error. |
+| **Branch families** | Rows grouped by repo + directory; branches under it. Branch-workspace relationships shown as parent → child. Two workspaces on the same branch+dir flagged (both will fight over the same folder). |
+| **Staleness** | Amber > 7 days since sync or incoming changes waiting; red > 30 days or conflicts. Thresholds in Settings. |
+| **Workspace detail** | Changes list (workspace side / Git side / conflict), buttons: **Update from Git**, **Commit to Git**, **Offload unsupported**, **Repoint branch…**. One green button: whichever action the status says is next. |
+| **Offload panel** | Always shows *what* ("7 items Git can't track: 2 dashboards, 3 dataflows Gen1, 2 legacy reports"), *where* (full path, "Change…", "Open folder"), and *when last offloaded*. Folder is nominated per workspace and remembered. Nothing is written until the user has picked a folder. |
+| **Progress** | Every operation shows a stepper (✓ done / animated active / pending / ✗ failed) with the current step's text; LROs show a determinate bar from `percentComplete`. Scan shows `n / total` workspaces. Existing `Busy()` overlay is kept for short calls only. |
+
+## Safe repoint protocol (feature 6)
+A journalled state machine. Each step is persisted to `Daxis/journal/.json` **before** it runs, so a
+crash or closed app resumes or rolls back instead of leaving a disconnected workspace.
+
+1. **Preflight (read-only; any red = blocked, with the reason and the fix):**
+ - Caller is workspace Admin; Git credentials configured for the target provider.
+ - `git/status`: zero uncommitted changes and zero conflicts. Otherwise offer "Commit to current branch first".
+ - No duplicate item names.
+ - Record original connection (provider, org/owner, project, repo, branch, directory, credentials connectionId,
+ current head) and any branch-workspace relationship.
+2. **Backup:** export the full definition of **every** item (supported and unsupported) plus a manifest to
+ `/_backups/-/`. Repoint cannot start if the backup is incomplete.
+3. **Disconnect → connect (target branch) → initialize.** No items change yet.
+4. **Preview:** `git/status` now lists exactly what the update would create / overwrite / delete. Show it.
+ - **Hard block** if any data-bearing item would be deleted. The user must remove it from the plan in Git or
+ move it first; Daxis will not offer an override.
+ - Unsupported items that depend on items being deleted → blocked (would fail mid-update anyway).
+5. **User confirms** (`Ask`, listing deletions first) → `updateFromGit` with explicit conflict policy.
+ **User declines** → rollback: disconnect → reconnect original branch → initialize → verify status is clean at the recorded head.
+6. **Verify:** status clean; item counts match the preview; re-create the branch-workspace relationship; clear journal.
+
+## Architecture
+- `FabricClient`: Git endpoints + `IProgress` on `LongRunningAsync` (percentComplete). Token path unchanged.
+- `Daxis.Core/SourceControl.cs`: pure logic — scan grouping into families, staleness, preflight rules,
+ deletion classification (data-bearing set), repoint journal state machine.
+- `Daxis.Core/Offload.cs`: the experiment's export engine done properly (see review) — raw-byte parts,
+ `/./` layout, manifest, stale-folder cleanup by logicalId. Fallbacks for items with no
+ definition API: PBIX export for reports, `model.json` for Dataflow Gen1, metadata JSON (e.g. dashboard tiles).
+- `Daxis.App/SourceControlTab.cs` + `Views/SourceControlView.axaml`, `Stepper` control.
+- No local `git` process and no LibGit2Sharp: Fabric does all Git work server-side. (Optional local commit of the
+ offload folder can come later via the `git` CLI.)
+- Every write through `TabBase.Ask`. No push to any remote from Daxis itself; commits happen in Fabric only.
+
+## Phases
+| # | Scope | Risk | Est. |
+|---|---|---|---|
+| 0 | **Spike on a sandbox tenant:** (a) does the Power BI Desktop client token carry Git scopes (`Workspace.GitCommit.All`, `Workspace.GitUpdate.All`)? If not, `DAXIS_CLIENT_ID` + own app registration becomes a documented requirement. (b) confirm `initializeConnection` changes no items. (c) confirm status-after-connect is a faithful preview of the update. (d) find a way to *read* branch-workspace relationships. (e) `percentComplete` on Git LROs. | Unknowns | 0.5 d |
+| 1 | Scan grid, families, staleness, per-workspace status. Read-only, ships alone. | None (read) | 1 d |
+| 2 | Commit / Update with preview, selective commit, explicit conflict policy, stepper + progress. | Medium | 1 d |
+| 3 | Offload unsupported items to the nominated folder (+ PBIX / metadata fallbacks). | Low (local writes) | 1 d |
+| 4 | Safe repoint: journal, backup, preview, blocks, rollback, relation restore. Test matrix on sandbox: clean switch, switch with deletions, data-item deletion blocked, decline → rollback, kill app mid-flow → resume, GitHub + ADO. | **High** | 2 d |
+
+Phases 1–3 → v0.2.0. Phase 4 ships only after its test matrix passes end-to-end on the sandbox.
+
+## Scope change to record
+CLAUDE.md says "There is no Git integration". Replace with: *"Source control drives Fabric's own Git integration via
+its REST API (scan, status, commit, update, repoint) and offloads unsupported items to a local folder. Daxis never
+pushes from the local machine and never deletes a data-bearing item."* Update README the same PR.
+
+## Review of the original experiment (kept for the offload engine)
+| Problem in `fabric_to_git.py` | Fix |
+|---|---|
+| Python + `fab` CLI + PATH fixes + hardcoded user path | `FabricClient` already does definitions, LRO polling and 429 retry |
+| Double-nested output `X.Report/X.Report/` | Write parts straight into `./` |
+| Additive only; deleted/renamed items leave stale folders | Mirror by `.platform` logicalId; rename = move |
+| `GetDefinitionAsync` decodes all payloads as UTF-8 → corrupts images/custom visuals | Raw-byte variant for offload |
+| All failures logged as "no exportable definition" | Classify unsupported / forbidden (labels) / failed |
+| Hardcoded exportable-type list | Ask the API; small skip list for noise types |
+| Items addressed by display-name path | Address by id; sanitise folder names only |
+| Sequential | Bounded parallelism (4) |
+| `sync_log.json` timestamp dirties every run | Log outside the folder |
+| `fabric_to_git_all.py` referenced but missing | Superseded by the Scan grid |
+
+Governance: nothing from the experiment's exported content enters the Daxis repo; tests use synthetic fixtures.
+
+## Morning test checklist (live, safe workspaces only)
+Run `.\run.ps1`, sign in, open **Source control** in the sidebar.
+1. **Scan** — rows fill in; connected workspaces grouped by repo + folder; branch-workspace text; shared-branch warning; stale/attention dots. If every status says "Status unavailable" with a scope/permission message, the Power BI Desktop client lacks Git scopes → set `DAXIS_CLIENT_ID` (see README).
+2. **Changes** — make an edit in a test workspace, rescan, commit it selectively; then change something in the repo and Update from Git. Try a conflict with each policy.
+3. **Offload** — choose a folder, offload; check `//_offload.json`, a dashboard's metadata, a report's PBIX/definition, images byte-identical.
+4. **Repoint** (safe workspace, Admin):
+ - uncommitted change → Check blocks.
+ - clean → Back up and preview → confirm the backup folder, then **Cancel** → workspace back on the original branch, no item changed, links intact.
+ - preview again → **Switch** → items match the target branch; verify.
+ - target branch missing a lakehouse → preview shows the block, Switch disabled; Cancel restores.
+ - edit an item after the preview, then Switch → refused ("changed after the preview").
+ - kill Daxis during preview (Task Manager), restart → red recovery banner → Restore original branch.
+5. Watch in the portal: the branch link (Related branches) survives Cancel and Switch.
+
+Things the spike must confirm (documented behaviour, not yet observed): `initializeConnection` changes no items; the status read right after connecting reflects what the first update does; items only in the workspace show as `workspaceChange: Added` there.
diff --git a/src/Daxis.App/App.axaml.cs b/src/Daxis.App/App.axaml.cs
index c7b9ca9..d9a5493 100644
--- a/src/Daxis.App/App.axaml.cs
+++ b/src/Daxis.App/App.axaml.cs
@@ -25,6 +25,12 @@ public sealed class Settings
{
public string? Workspace { get; set; }
public bool Light { get; set; }
+ /// Workspace id → local folder nominated for offloads and pre-repoint backups.
+ public Dictionary OffloadFolders { get; set; } = [];
+ public int StaleDays { get; set; } = 7;
+ public int CriticalDays { get; set; } = 30;
+ /// Source control list: one A–Z list of workspaces instead of grouping by repo.
+ public bool SourceControlByWorkspace { get; set; }
static string FilePath => Path.Combine(AppPaths.Data, "settings.json");
diff --git a/src/Daxis.App/Conv.cs b/src/Daxis.App/Conv.cs
index e402921..b6bcc94 100644
--- a/src/Daxis.App/Conv.cs
+++ b/src/Daxis.App/Conv.cs
@@ -17,6 +17,28 @@ public static class Conv
return app.TryGetResource(key, app.ActualThemeVariant, out var b) ? b as Avalonia.Media.IBrush : null;
});
+ /// Git freshness → dot colour: fresh brand, stale warning, critical danger, unknown faint.
+ public static readonly IValueConverter FreshBrush = new FuncValueConverter(f =>
+ {
+ var app = Avalonia.Application.Current!;
+ var key = f switch
+ {
+ Daxis.Core.Freshness.Fresh => "Brand",
+ Daxis.Core.Freshness.Stale => "Warning",
+ Daxis.Core.Freshness.Critical => "Danger",
+ _ => "FgFaint",
+ };
+ return app.TryGetResource(key, app.ActualThemeVariant, out var b) ? b as Avalonia.Media.IBrush : null;
+ });
+
+ public static readonly IValueConverter FreshText = new FuncValueConverter(f => f switch
+ {
+ Daxis.Core.Freshness.Fresh => "Fresh",
+ Daxis.Core.Freshness.Stale => "Stale",
+ Daxis.Core.Freshness.Critical => "Needs attention",
+ _ => "",
+ });
+
public static readonly IValueConverter Bytes = new FuncValueConverter(Format);
internal static string Format(long b) => b switch
diff --git a/src/Daxis.App/Editors.cs b/src/Daxis.App/Editors.cs
index 56f1ef3..e436df6 100644
--- a/src/Daxis.App/Editors.cs
+++ b/src/Daxis.App/Editors.cs
@@ -160,7 +160,7 @@ sealed class CompletionItem(Completion c) : ICompletionData
CompletionKind.Function => "fx", CompletionKind.Measure => "Σ",
CompletionKind.Column => "col", CompletionKind.Table => "tbl", _ => "var",
},
- Width = 24, FontSize = 10, Opacity = 0.55, VerticalAlignment = VerticalAlignment.Center,
+ Width = 24, FontSize = 11, Opacity = 0.55, VerticalAlignment = VerticalAlignment.Center,
},
new TextBlock { Text = c.Label, VerticalAlignment = VerticalAlignment.Center },
},
diff --git a/src/Daxis.App/Icon.cs b/src/Daxis.App/Icon.cs
index df1167d..1e96594 100644
--- a/src/Daxis.App/Icon.cs
+++ b/src/Daxis.App/Icon.cs
@@ -59,5 +59,10 @@ public static class Icons
public static readonly Geometry Report = P("M4,20 H20 M7,20 V12 M12,20 V5 M17,20 V9");
public static readonly Geometry Diagram = P("M4,4 H10 V9 H4 Z M14,15 H20 V20 H14 Z M14,4 H20 V9 H14 Z M7,9 V17.5 H14 M17,9 V15");
public static readonly Geometry Workspace = P("M4,4 H10 V10 H4 Z M14,4 H20 V10 H14 Z M4,14 H10 V20 H4 Z M14,14 H20 V20 H14 Z");
+ public static readonly Geometry Branch = P("M6,3 V15 M6,15 A3,3 0 1 1 5.99,15 Z M18,3 A3,3 0 1 1 17.99,3 Z M18,9 C18,13.5 11,14.5 6,15");
+ public static readonly Geometry Download = P("M12,4 V15 M7,10 L12,15 L17,10 M5,19 H19");
+ public static readonly Geometry Upload = P("M12,15 V4 M7,9 L12,4 L17,9 M5,19 H19");
+ public static readonly Geometry Shield = P("M12,3 L19,6 V11 C19,15.5 16,19 12,21 C8,19 5,15.5 5,11 V6 Z M9,12 L11,14 L15,10");
+ public static readonly Geometry Warn = P("M12,4 L21,19 H3 Z M12,10 V14 M12,16.5 V17");
public static readonly Geometry SignOut = P("M10,5 H5 V19 H10 M15,8 L19,12 L15,16 M19,12 H9");
}
diff --git a/src/Daxis.App/MainViewModel.cs b/src/Daxis.App/MainViewModel.cs
index be3e9bc..4392869 100644
--- a/src/Daxis.App/MainViewModel.cs
+++ b/src/Daxis.App/MainViewModel.cs
@@ -15,6 +15,7 @@ public sealed partial class ItemRow(FabricItem item, Action open) : Obs
MainViewModel.SemanticModel => Icons.Model,
MainViewModel.Notebook => Icons.Notebook,
MainViewModel.WorkspaceOverview => Icons.Workspace,
+ SourceControlTab.ItemType => Icons.Branch,
_ => Icons.Lakehouse,
};
[ObservableProperty] private bool _isActive;
@@ -49,6 +50,7 @@ public sealed partial class MainViewModel : ObservableObject
[ObservableProperty] private bool _isDark;
[ObservableProperty] private bool _hasItems;
[ObservableProperty] private ItemRow? _workspaceRow;
+ [ObservableProperty] private ItemRow? _sourceControlRow;
public bool ShowEmptyState => IsSignedIn && Tabs.Count == 0;
partial void OnIsSignedInChanged(bool value) => OnPropertyChanged(nameof(ShowEmptyState));
@@ -99,6 +101,7 @@ private async Task SignOut()
Workspaces.Clear();
SetRows([]);
WorkspaceRow = null;
+ SourceControlRow = null;
SetStatus("Signed out");
}
@@ -110,6 +113,8 @@ async Task OnSignedInAsync()
var list = await _fabric.WorkspacesAsync();
Workspaces.Clear();
foreach (var w in list) Workspaces.Add(w);
+ SourceControlRow = new ItemRow(new FabricItem("daxis:source-control", "Source control", SourceControlTab.ItemType,
+ "Git status of every workspace: sync, commit, offload and branch repointing", ""), Open);
SelectedWorkspace = list.FirstOrDefault(w => w.Id == _settings.Workspace) ?? list.FirstOrDefault();
}
@@ -166,6 +171,12 @@ async void Open(ItemRow row)
SelectedTab = open;
return;
}
+ if (row.Item.Type == SourceControlTab.ItemType)
+ {
+ if ((SelectedWorkspace ?? Workspaces.FirstOrDefault()) is not { } any) return;
+ await Add(new SourceControlTab(row.Item, any, _fabric, _settings, () => Workspaces.ToList()));
+ return;
+ }
// The item's own workspace, never "whatever is selected now": a model open must target exactly this item.
if (Workspaces.FirstOrDefault(w => w.Id == row.Item.WorkspaceId) is not { } ws) return;
TabBase tab = row.Item.Type switch
@@ -176,6 +187,11 @@ async void Open(ItemRow row)
id => { if (_rows.FirstOrDefault(r => r.Item.Id == id) is { } r) Open(r); }),
_ => new LakehouseTab(row.Item, ws, _fabric),
};
+ await Add(tab);
+ }
+
+ async Task Add(TabBase tab)
+ {
tab.CloseRequested += OnCloseRequested;
Tabs.Add(tab);
SelectedTab = tab;
@@ -212,6 +228,7 @@ void MarkActive()
{
foreach (var r in _rows) r.IsActive = r.Item.Id == SelectedTab?.Item.Id;
if (WorkspaceRow is { } w) w.IsActive = w.Item.Id == SelectedTab?.Item.Id;
+ if (SourceControlRow is { } g) g.IsActive = g.Item.Id == SelectedTab?.Item.Id;
}
[RelayCommand]
diff --git a/src/Daxis.App/MainWindow.axaml b/src/Daxis.App/MainWindow.axaml
index a9dcdc2..6da76be 100644
--- a/src/Daxis.App/MainWindow.axaml
+++ b/src/Daxis.App/MainWindow.axaml
@@ -66,7 +66,7 @@
-
+
@@ -205,12 +207,12 @@
Fill="{DynamicResource Brand}" IsVisible="{Binding IsSignedIn}" />
-
-
diff --git a/src/Daxis.App/MainWindow.axaml.cs b/src/Daxis.App/MainWindow.axaml.cs
index 830e5ea..8b07c35 100644
--- a/src/Daxis.App/MainWindow.axaml.cs
+++ b/src/Daxis.App/MainWindow.axaml.cs
@@ -12,6 +12,8 @@ public sealed partial class MainWindow : Window
public MainWindow()
{
InitializeComponent();
+ // Subpixel (ClearType-style) glyphs: Inter at UI sizes reads thin and cramped with greyscale antialiasing.
+ Avalonia.Media.RenderOptions.SetTextRenderingMode(this, Avalonia.Media.TextRenderingMode.SubpixelAntialias);
Opened += async (_, _) =>
{
var vm = (MainViewModel)DataContext!;
@@ -102,6 +104,7 @@ void ShowTab(TabBase? tab)
ModelTab => new Views.ModelView(),
NotebookTab => new Views.NotebookView(),
WorkspaceTab => new Views.WorkspaceView(),
+ SourceControlTab => new Views.SourceControlView(),
_ => new Views.LakehouseView(),
};
view.DataContext = tab;
diff --git a/src/Daxis.App/SourceControlTab.cs b/src/Daxis.App/SourceControlTab.cs
new file mode 100644
index 0000000..154901c
--- /dev/null
+++ b/src/Daxis.App/SourceControlTab.cs
@@ -0,0 +1,819 @@
+using System.Collections.ObjectModel;
+using Avalonia.Media;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Daxis.Core;
+
+namespace Daxis.App;
+
+/// One workspace in the scan: its Git connection, status and freshness. Fills in as the service answers.
+public sealed partial class GitRow(Workspace ws) : ObservableObject
+{
+ public Workspace Workspace { get; } = ws;
+ public string Name => Workspace.DisplayName;
+
+ [ObservableProperty] private bool _isLoading = true;
+ [ObservableProperty] private bool _isSelected;
+ [ObservableProperty] private GitConnection? _connection;
+ [ObservableProperty] private GitStatus? _status;
+ [ObservableProperty] private string? _statusError;
+ [ObservableProperty] private string? _error;
+ [ObservableProperty] private Freshness _freshness;
+ [ObservableProperty] private string _syncText = "";
+ [ObservableProperty] private string? _relationText;
+ [ObservableProperty] private bool _sharesBranch;
+
+ public bool IsConnected => Connection is { State: not GitState.NotConnected, Provider: not null };
+ public bool NoAccess => Error is not null;
+ public string Branch => Connection?.Provider?.Branch ?? "";
+ public string Repo => Connection?.Provider is { } p ? $"{p.Repo} · {p.Folder}" : "";
+ public string StateText => Error is not null ? "No access"
+ : Connection is null ? ""
+ : Connection.State switch
+ {
+ GitState.NotConnected => "Not connected",
+ GitState.Connected => "Connected, never synced",
+ _ => Connection.Selective ? "Synced (selective)" : "Synced",
+ };
+ public string Counts => Status is { } s
+ ? (s.Clean ? "In sync" : string.Join(" · ", new[]
+ {
+ s.Uncommitted > 0 ? $"{s.Uncommitted} to commit" : null,
+ s.Incoming > 0 ? $"{s.Incoming} incoming" : null,
+ s.Conflicts > 0 ? SourceControlTab.Plural(s.Conflicts, "conflict") : null,
+ }.OfType()))
+ : StatusError is not null ? "Status unavailable" : IsConnected && IsLoading ? "…" : "";
+ public bool NeedsAttention => Freshness is Freshness.Stale or Freshness.Critical || SharesBranch || Status is { Clean: false } || StatusError is not null;
+
+ public void Refresh(DateTimeOffset now, int staleDays, int criticalDays)
+ {
+ Freshness = Git.FreshnessOf(Connection, Status, now, staleDays, criticalDays);
+ SyncText = IsConnected ? Git.Ago(Connection!.LastSync, now) : "";
+ OnPropertyChanged(nameof(IsConnected));
+ OnPropertyChanged(nameof(NoAccess));
+ OnPropertyChanged(nameof(Branch));
+ OnPropertyChanged(nameof(Repo));
+ OnPropertyChanged(nameof(StateText));
+ OnPropertyChanged(nameof(Counts));
+ OnPropertyChanged(nameof(NeedsAttention));
+ }
+}
+
+/// A heading in the scan list (repo + folder, or "Not connected") and its workspaces.
+public sealed record GitGroup(string Label, string Detail, IReadOnlyList Rows);
+
+public sealed partial class StepRow(string key, string title) : ObservableObject
+{
+ public string Key { get; } = key;
+ [ObservableProperty] private string _title = title;
+ [ObservableProperty] private StepState _state;
+ [ObservableProperty] private string? _detail;
+ [ObservableProperty] private int? _percent;
+
+ public bool IsActive => State == StepState.Active;
+ public bool IsDone => State == StepState.Done;
+ public bool IsFailed => State == StepState.Failed;
+ public bool IsPending => State is StepState.Pending or StepState.Skipped;
+ public bool HasPercent => Percent is not null && State == StepState.Active;
+
+ partial void OnStateChanged(StepState value)
+ {
+ OnPropertyChanged(nameof(IsActive));
+ OnPropertyChanged(nameof(IsDone));
+ OnPropertyChanged(nameof(IsFailed));
+ OnPropertyChanged(nameof(IsPending));
+ OnPropertyChanged(nameof(HasPercent));
+ }
+
+ partial void OnPercentChanged(int? value) => OnPropertyChanged(nameof(HasPercent));
+}
+
+public sealed partial class ChangeRow(GitChange change) : ObservableObject
+{
+ public GitChange Change { get; } = change;
+ public string Name => Change.Name;
+ public string Type => Change.Type;
+ public string Workspace => Change.WorkspaceChange ?? "";
+ public string Remote => Change.RemoteChange ?? "";
+ public bool IsConflict => Change.IsConflict;
+ public bool CanCommit => Change.WorkspaceChange is not null && !Change.IsConflict;
+ [ObservableProperty] private bool _selected = change.WorkspaceChange is not null && !change.IsConflict;
+}
+
+public sealed record PlanRow(PlannedChange Change)
+{
+ public string Name => Change.Name;
+ public string Type => Change.Type;
+ public string Action => Change.Impact switch { Impact.Delete => "Deleted", Impact.Overwrite => "Overwritten", _ => "Added" };
+ public string? Note => Change.Block ?? Change.Warning;
+ public bool IsBlocked => Change.Block is not null;
+}
+
+public sealed record CheckRow(Check Check)
+{
+ public string Title => Check.Title;
+ public string Detail => Check.Detail;
+ public bool Ok => Check.Ok;
+}
+
+public sealed record Recovery(RepointJournal Journal)
+{
+ public string Text => $"'{Journal.WorkspaceName}': the switch from {Journal.Original.Branch} to {Journal.Target} was interrupted " + Journal.Step switch
+ {
+ RepointStep.Updating => "while the workspace was being updated. Restore brings back the original branch and its content.",
+ RepointStep.Previewed => "while waiting for you to confirm. No items were changed; the workspace is still connected to the new branch.",
+ RepointStep.RollingBack => "while it was being restored. Run the restore again to finish.",
+ _ => $"before any item was changed. Its Git connection may not be on {Journal.Original.Branch}.",
+ };
+}
+
+///
+/// Tenant-wide source control: which workspaces are on Git and where, how fresh they are, commit / update, offload of
+/// items Git can't track, and journalled branch repointing.
+///
+public sealed partial class SourceControlTab : TabBase
+{
+ public const string ItemType = "SourceControl";
+
+ readonly FabricClient _fabric;
+ readonly Settings _settings;
+ readonly Func> _workspaces;
+ List _rows = [];
+ List _items = [];
+ GitCredentials? _credentials;
+ RepointPreview? _preview;
+ CancellationTokenSource? _scan;
+
+ public SourceControlTab(FabricItem item, Workspace ws, FabricClient fabric, Settings settings, Func> workspaces)
+ : base(item, ws)
+ {
+ _fabric = fabric;
+ _settings = settings;
+ _workspaces = workspaces;
+ _grouping = settings.SourceControlByWorkspace ? 1 : 0;
+ }
+
+ public override Geometry Icon => Icons.Branch;
+ public override string WebUrl => Selected is { } r
+ ? $"https://app.fabric.microsoft.com/groups/{r.Workspace.Id}/list"
+ : "https://app.fabric.microsoft.com/home";
+
+ // ── Scan ─────────────────────────────────────────────────────────────────
+ public ObservableCollection Groups { get; } = [];
+ public ObservableCollection Recoveries { get; } = [];
+ [ObservableProperty] private bool _isScanning;
+ [ObservableProperty] private string _scanText = "";
+ [ObservableProperty] private double _scanPercent;
+ [ObservableProperty] private int _connectedCount;
+ [ObservableProperty] private int _notConnectedCount;
+ [ObservableProperty] private int _staleCount;
+ [ObservableProperty] private int _criticalCount;
+ [ObservableProperty] private int _sharedBranchCount;
+ [ObservableProperty] private int _noAccessCount;
+ [ObservableProperty] private int _filter; // 0 all, 1 needs attention, 2 connected, 3 not connected
+ [ObservableProperty] private string _search = "";
+ [ObservableProperty] private int _grouping; // 0 by repo, 1 by workspace
+ public string Thresholds => $"Stale after {_settings.StaleDays} days without a sync, critical after {_settings.CriticalDays}.";
+
+ // ── Selected workspace ───────────────────────────────────────────────────
+ [ObservableProperty] private GitRow? _selected;
+ [ObservableProperty] private int _detailPage; // 0 changes, 1 offload, 2 repoint
+ [ObservableProperty] private bool _isLoadingDetail;
+ public ObservableCollection Changes { get; } = [];
+ [ObservableProperty] private string _commitMessage = "";
+ [ObservableProperty] private int _conflictPolicy; // 0 stop, 1 keep workspace, 2 take Git
+ [ObservableProperty] private string? _detailError;
+ public bool HasSelection => Selected is not null;
+ public bool SelectedConnected => Selected?.IsConnected == true;
+ /// Fabric refuses an update with unresolved conflicts, so a conflict policy must be picked first.
+ public bool CanUpdate => Selected?.Status is { } s && (s.Incoming > 0 || s.Conflicts > 0) && (s.Conflicts == 0 || ConflictPolicy > 0);
+ public bool CanCommit => Changes.Any(c => c.Selected && c.CanCommit) && !string.IsNullOrWhiteSpace(CommitMessage);
+ /// One green button: update when something is incoming, otherwise commit.
+ public bool UpdateIsPrimary => CanUpdate;
+ public bool HasConflicts => Selected?.Status is { Conflicts: > 0 };
+ public string ChangesSummary => Selected?.Status is { } s
+ ? s.Clean ? "The workspace and the branch match." : $"{s.Uncommitted} uncommitted · {s.Incoming} incoming · {Plural(s.Conflicts, "conflict")}"
+ : Selected?.StatusError ?? "";
+
+ // ── Offload ──────────────────────────────────────────────────────────────
+ public ObservableCollection OffloadItems { get; } = [];
+ [ObservableProperty] private bool _offloadAll;
+ [ObservableProperty] private string? _offloadFolder;
+ [ObservableProperty] private string _lastOffload = "";
+ public string OffloadTarget => Selected is { } r && OffloadFolder is { } f ? Offload.WorkspaceRoot(f, r.Workspace) : "";
+ public string OffloadSummary => OffloadItems.Count == 0
+ ? OffloadAll ? "This workspace has no items." : "Git can track every item in this workspace. Tick the box to save a full local copy anyway."
+ : OffloadAll ? $"All {OffloadItems.Count} items are saved to this computer."
+ : $"{OffloadItems.Count} item(s) Git can't track: {string.Join(", ", OffloadItems.GroupBy(i => i.Type).Select(g => $"{g.Count()} {g.Key}"))}. " +
+ "They are never committed or updated by Git, so this is their only copy outside Fabric.";
+
+ // ── Repoint ──────────────────────────────────────────────────────────────
+ public ObservableCollection KnownBranches { get; } = [];
+ public ObservableCollection Checks { get; } = [];
+ public ObservableCollection Plan { get; } = [];
+ public ObservableCollection PlanBlocks { get; } = [];
+ public ObservableCollection PlanWarnings { get; } = [];
+ [ObservableProperty] private string _targetBranch = "";
+ [ObservableProperty] private bool _checksPassed;
+ [ObservableProperty] private bool _hasPreview;
+ [ObservableProperty] private bool _acknowledged;
+ [ObservableProperty] private string _previewSummary = "";
+ [ObservableProperty] private string _previewTitle = "";
+ public bool CanSwitch => HasPreview && PlanBlocks.Count == 0 && (PlanWarnings.Count == 0 || Acknowledged) && !IsOperating;
+
+ // ── Operation (stepper) ──────────────────────────────────────────────────
+ public ObservableCollection Steps { get; } = [];
+ [ObservableProperty] private bool _isOperating;
+ [ObservableProperty] private bool _showOperation;
+ [ObservableProperty] private string _operationTitle = "";
+ [ObservableProperty] private string? _operationResult;
+ [ObservableProperty] private bool _operationFailed;
+
+ public override Task LoadAsync()
+ {
+ LoadRecoveries();
+ return ScanAsync();
+ }
+
+ // ── Scan ─────────────────────────────────────────────────────────────────
+
+ [RelayCommand]
+ private Task Rescan() => ScanAsync();
+
+ async Task ScanAsync()
+ {
+ _scan?.Cancel();
+ var cts = _scan = new CancellationTokenSource();
+ var workspaces = _workspaces().Where(w => w.Type != "Personal").ToList();
+ _rows = workspaces.Select(w => new GitRow(w)).ToList();
+ var keep = Selected?.Workspace.Id ?? Workspace.Id;
+ Selected = null;
+ Regroup();
+ IsScanning = true;
+ var done = 0;
+ ScanText = $"Scanning 0 / {_rows.Count} workspaces…";
+ using var gate = new SemaphoreSlim(6); // ponytail: fixed fan-out; 429s are retried by the client
+ try
+ {
+ await Task.WhenAll(_rows.Select(async row =>
+ {
+ await gate.WaitAsync(cts.Token);
+ try { await ScanOneAsync(row, cts.Token); }
+ finally
+ {
+ gate.Release();
+ var n = Interlocked.Increment(ref done);
+ ScanText = $"Scanning {n} / {_rows.Count} workspaces…";
+ ScanPercent = 100.0 * n / Math.Max(1, _rows.Count);
+ if (n % 8 == 0) Regroup();
+ }
+ }));
+ }
+ catch (OperationCanceledException) { return; }
+ finally { if (_scan == cts) IsScanning = false; }
+ Regroup();
+ ScanText = $"Scanned {_rows.Count} workspaces at {DateTime.Now:HH:mm}";
+ if (keep is not null && _rows.FirstOrDefault(r => r.Workspace.Id == keep) is { } again) Select(again);
+ }
+
+ async Task ScanOneAsync(GitRow row, CancellationToken ct)
+ {
+ try
+ {
+ row.Connection = await _fabric.GitConnectionAsync(row.Workspace.Id, ct);
+ if (row.IsConnected)
+ {
+ var relations = _fabric.WorkspaceRelationsAsync(row.Workspace.Id, ct);
+ if (row.Connection.State == GitState.Initialized)
+ try { row.Status = await _fabric.GitStatusAsync(row.Workspace.Id, ct); }
+ catch (FabricException e) { row.StatusError = Friendly(e); }
+ try { row.RelationText = RelationText(await relations); }
+ catch (FabricException) { } // preview API; not every tenant has it
+ }
+ }
+ catch (FabricException e) { row.Error = Friendly(e); }
+ finally
+ {
+ row.IsLoading = false;
+ row.Refresh(DateTimeOffset.UtcNow, _settings.StaleDays, _settings.CriticalDays);
+ }
+ }
+
+ string? RelationText(List relations)
+ {
+ if (relations.Count == 0) return null;
+ var names = _workspaces().ToDictionary(w => w.Id, w => w.DisplayName, StringComparer.OrdinalIgnoreCase);
+ string N(string id) => names.TryGetValue(id, out var n) ? n : "a workspace you can't see";
+ var baseOf = relations.FirstOrDefault(r => r.Type == "Base");
+ var branches = relations.Count(r => r.Type == "Branch");
+ return baseOf is not null ? $"Branched from {N(baseOf.RelatedWorkspaceId)}"
+ : branches > 0 ? $"{branches} branch workspace{(branches > 1 ? "s" : "")}" : null;
+ }
+
+ partial void OnFilterChanged(int value) => Regroup();
+ partial void OnGroupingChanged(int value)
+ {
+ _settings.SourceControlByWorkspace = value == 1;
+ _settings.Save();
+ Regroup();
+ }
+ partial void OnSearchChanged(string value) => Regroup();
+
+ void Regroup()
+ {
+ var families = Git.Families(_rows.Where(r => r.IsConnected).Select(r => (r.Workspace.Id, r.Connection!.Provider!)));
+ var byId = _rows.ToDictionary(r => r.Workspace.Id);
+ foreach (var f in families)
+ foreach (var m in f.Members) byId[m.WorkspaceId].SharesBranch = m.SharesBranch;
+
+ bool Show(GitRow r) => r.Name.Contains(Search, StringComparison.OrdinalIgnoreCase) && Filter switch
+ {
+ 1 => r.NeedsAttention || r.IsLoading,
+ 2 => r.IsConnected,
+ 3 => !r.IsConnected && !r.IsLoading,
+ _ => true,
+ };
+
+ var groups = Grouping == 1
+ // By workspace: every workspace A–Z, connected or not; repo and branch stay on each row.
+ ? [new GitGroup("All workspaces A–Z", "", _rows.Where(r => !r.IsLoading && Show(r)).OrderBy(r => r.Name, StringComparer.OrdinalIgnoreCase).ToList())]
+ : families
+ .Select(f => new GitGroup(f.Label, Plural(f.Members.Count, "workspace"),
+ f.Members.Select(m => byId[m.WorkspaceId]).Where(Show).ToList()))
+ .ToList();
+ groups = groups.Where(g => g.Rows.Count > 0).ToList();
+ var pending = _rows.Where(r => r.IsLoading && Show(r)).ToList();
+ if (pending.Count > 0) groups.Insert(0, new GitGroup("Checking…", "", pending));
+ var rest = _rows.Where(r => !r.IsLoading && !r.IsConnected && Show(r)).OrderBy(r => r.NoAccess).ThenBy(r => r.Name).ToList();
+ if (rest.Count > 0 && Grouping == 0) groups.Add(new GitGroup("Not connected to Git", $"{rest.Count} workspace{(rest.Count > 1 ? "s" : "")}", rest));
+
+ Groups.Clear();
+ foreach (var g in groups) Groups.Add(g);
+
+ var scanned = _rows.Where(r => !r.IsLoading).ToList();
+ ConnectedCount = scanned.Count(r => r.IsConnected);
+ NotConnectedCount = scanned.Count(r => !r.IsConnected && !r.NoAccess);
+ StaleCount = scanned.Count(r => r.Freshness == Freshness.Stale);
+ CriticalCount = scanned.Count(r => r.Freshness == Freshness.Critical);
+ SharedBranchCount = scanned.Count(r => r.SharesBranch);
+ NoAccessCount = scanned.Count(r => r.NoAccess);
+ }
+
+ // ── Selection ────────────────────────────────────────────────────────────
+
+ public void Select(GitRow row)
+ {
+ if (IsOperating) return;
+ Selected = row;
+ }
+
+ partial void OnSelectedChanged(GitRow? oldValue, GitRow? newValue)
+ {
+ if (oldValue is not null) oldValue.IsSelected = false;
+ if (newValue is not null) newValue.IsSelected = true;
+ ResetRepoint();
+ ShowOperation = false;
+ DetailError = null;
+ Changes.Clear();
+ OffloadItems.Clear();
+ _items = [];
+ _credentials = null;
+ OffloadFolder = newValue is not null && _settings.OffloadFolders.TryGetValue(newValue.Workspace.Id, out var f) ? f : null;
+ CommitMessage = newValue is null ? "" : $"Update from {newValue.Name} (Daxis)";
+ if (newValue is not null)
+ {
+ if (!newValue.IsConnected && DetailPage == 0) DetailPage = 1;
+ _ = LoadDetailAsync(newValue);
+ }
+ RaiseDetail();
+ }
+
+ async Task LoadDetailAsync(GitRow row)
+ {
+ IsLoadingDetail = true;
+ try
+ {
+ var items = _fabric.ItemsAsync(row.Workspace.Id);
+ if (row.IsConnected)
+ {
+ try { _credentials = await _fabric.GitCredentialsAsync(row.Workspace.Id); }
+ catch (FabricException) { _credentials = null; }
+ }
+ _items = await items;
+ if (Selected != row) return;
+ FillChanges(row);
+ FillOffload();
+ FillBranches(row);
+ LastOffload = OffloadFolder is { } f && Offload.ReadManifest(Offload.WorkspaceRoot(f, row.Workspace)) is { } m
+ ? $"Last offload {Git.Ago(m.At, DateTimeOffset.UtcNow)}: {m.Saved} saved" + (m.FailedCount > 0 ? $", {m.FailedCount} failed" : "")
+ : "Not offloaded yet.";
+ }
+ catch (Exception e) { if (Selected == row) DetailError = e.Message; }
+ finally { if (Selected == row) IsLoadingDetail = false; }
+ }
+
+ void FillChanges(GitRow row)
+ {
+ Changes.Clear();
+ foreach (var c in row.Status?.Changes ?? []) Changes.Add(Track(new ChangeRow(c)));
+ RaiseDetail();
+ }
+
+ ChangeRow Track(ChangeRow c)
+ {
+ c.PropertyChanged += (_, _) => OnPropertyChanged(nameof(CanCommit));
+ return c;
+ }
+
+ void FillOffload()
+ {
+ OffloadItems.Clear();
+ foreach (var i in _items.Where(i => OffloadAll ? !Git.SystemOwned.Contains(i.Type) : Git.IsOffloadCandidate(i))) OffloadItems.Add(i);
+ OnPropertyChanged(nameof(OffloadSummary));
+ }
+
+ void FillBranches(GitRow row)
+ {
+ KnownBranches.Clear();
+ if (row.Connection?.Provider is not { } p) return;
+ foreach (var b in _rows.Where(r => r.Connection?.Provider?.FamilyKey == p.FamilyKey).Select(r => r.Branch)
+ .Where(b => b != p.Branch).Distinct(StringComparer.OrdinalIgnoreCase).Order())
+ KnownBranches.Add(b);
+ }
+
+ partial void OnOffloadAllChanged(bool value) => FillOffload();
+ partial void OnOffloadFolderChanged(string? value) => OnPropertyChanged(nameof(OffloadTarget));
+ partial void OnConflictPolicyChanged(int value) => RaiseDetail();
+ partial void OnCommitMessageChanged(string value) => OnPropertyChanged(nameof(CanCommit));
+ partial void OnAcknowledgedChanged(bool value) => OnPropertyChanged(nameof(CanSwitch));
+ partial void OnIsOperatingChanged(bool value) => OnPropertyChanged(nameof(CanSwitch));
+ partial void OnTargetBranchChanged(string value)
+ {
+ ChecksPassed = false;
+ Checks.Clear();
+ }
+
+ void RaiseDetail()
+ {
+ OnPropertyChanged(nameof(HasSelection));
+ OnPropertyChanged(nameof(SelectedConnected));
+ OnPropertyChanged(nameof(CanUpdate));
+ OnPropertyChanged(nameof(CanCommit));
+ OnPropertyChanged(nameof(UpdateIsPrimary));
+ OnPropertyChanged(nameof(HasConflicts));
+ OnPropertyChanged(nameof(ChangesSummary));
+ OnPropertyChanged(nameof(WebUrl));
+ }
+
+ /// Re-reads one workspace after an operation so the list and detail show the new state.
+ async Task RefreshRowAsync(GitRow row)
+ {
+ row.IsLoading = true;
+ row.Status = null;
+ row.StatusError = null;
+ await ScanOneAsync(row, CancellationToken.None);
+ Regroup();
+ if (Selected == row)
+ {
+ FillChanges(row);
+ FillBranches(row);
+ }
+ }
+
+ [RelayCommand]
+ private void SelectAllChanges()
+ {
+ var all = Changes.Where(c => c.CanCommit).All(c => c.Selected);
+ foreach (var c in Changes.Where(c => c.CanCommit)) c.Selected = !all;
+ }
+
+ // ── Update from Git ──────────────────────────────────────────────────────
+
+ string? Policy => ConflictPolicy switch { 1 => "PreferWorkspace", 2 => "PreferRemote", _ => null };
+
+ [RelayCommand]
+ private void UpdateFromGit()
+ {
+ if (Selected is not { Status: { } status } row) return;
+ var plan = Git.ClassifyUpdate(status, Policy);
+ var blocks = plan.Where(p => p.Block is not null).Select(p => p.Block!).ToList();
+ if (blocks.Count > 0)
+ {
+ DetailError = "Update stopped before it started. " + string.Join(" ", blocks);
+ return;
+ }
+ var warnings = plan.Where(p => p.Warning is not null).Select(p => p.Warning!).ToList();
+ Ask($"Update '{row.Name}' from {row.Branch}?",
+ $"{plan.Count(p => p.Impact == Impact.Create)} added, {plan.Count(p => p.Impact == Impact.Overwrite)} overwritten, " +
+ $"{plan.Count(p => p.Impact == Impact.Delete)} deleted in the workspace to match the branch. " +
+ (status.Conflicts > 0 ? Policy == "PreferRemote" ? "Conflicting items take the Git version; their workspace edits are lost. " : "Conflicting items keep the workspace version. " : "") +
+ (status.Uncommitted > 0 ? "Uncommitted workspace changes to other items are kept. " : "") +
+ string.Join(" ", warnings),
+ "Update workspace", () => RunUpdateAsync(row));
+ }
+
+ Task RunUpdateAsync(GitRow row) => Operate($"Update '{row.Name}' from Git", row, async p =>
+ {
+ var status = await Step(p, "status", "Read the latest status", async () =>
+ {
+ var s = await _fabric.GitStatusAsync(row.Workspace.Id);
+ return (s, $"{s.Incoming} incoming · {s.Conflicts} conflicts");
+ });
+ var blocks = Git.ClassifyUpdate(status, Policy).Where(c => c.Block is not null).ToList();
+ if (blocks.Count > 0) throw new InvalidOperationException(string.Join(" ", blocks.Select(b => b.Block)));
+ if (status.RemoteCommitHash is not { } remote || (status.Incoming == 0 && status.Conflicts == 0))
+ return "Nothing incoming any more; the workspace already matches the branch.";
+ await Step(p, "update", $"Update from {row.Branch}", async () =>
+ {
+ await _fabric.GitUpdateAsync(row.Workspace.Id, remote, status.WorkspaceHead, Policy,
+ new Progress(pc => p.Report(new("update", $"Update from {row.Branch}", StepState.Active, $"{pc}%", pc))));
+ return (true, "Applied");
+ });
+ return $"'{row.Name}' now matches {row.Branch}.";
+ });
+
+ // ── Commit to Git ────────────────────────────────────────────────────────
+
+ [RelayCommand]
+ private void Commit()
+ {
+ if (Selected is not { } row || !CanCommit) return;
+ var chosen = Changes.Where(c => c.Selected && c.CanCommit).Select(c => c.Change).ToList();
+ var all = chosen.Count == Changes.Count(c => c.CanCommit);
+ Ask($"Commit {chosen.Count} item(s) to {row.Branch}?",
+ $"Writes the workspace version of {string.Join(", ", chosen.Take(6).Select(c => c.Name))}{(chosen.Count > 6 ? $" and {chosen.Count - 6} more" : "")} " +
+ $"to {row.Connection?.Provider?.Repo} on {row.Branch} with the message \"{CommitMessage.Trim()}\". Anyone updating from that branch receives it.",
+ "Commit to Git", () => RunCommitAsync(row, all ? null : chosen, CommitMessage.Trim()));
+ }
+
+ Task RunCommitAsync(GitRow row, List? items, string message) => Operate($"Commit '{row.Name}' to Git", row, async p =>
+ {
+ var status = await Step(p, "status", "Read the latest status", async () =>
+ {
+ var s = await _fabric.GitStatusAsync(row.Workspace.Id);
+ return (s, $"{s.Uncommitted} uncommitted");
+ });
+ if (status.Uncommitted == 0) return "Nothing to commit any more.";
+ await Step(p, "commit", $"Commit to {row.Branch}", async () =>
+ {
+ await _fabric.GitCommitAsync(row.Workspace.Id, status.WorkspaceHead, message, items,
+ new Progress(pc => p.Report(new("commit", $"Commit to {row.Branch}", StepState.Active, $"{pc}%", pc))));
+ return (true, "Committed");
+ });
+ return $"Committed to {row.Branch}.";
+ });
+
+ // ── Offload ──────────────────────────────────────────────────────────────
+
+ public void SetOffloadFolder(string path)
+ {
+ if (Selected is not { } row) return;
+ OffloadFolder = path;
+ _settings.OffloadFolders[row.Workspace.Id] = path;
+ _settings.Save();
+ }
+
+ [RelayCommand]
+ private Task RunOffload()
+ {
+ if (Selected is not { } row || OffloadFolder is not { } root || OffloadItems.Count == 0) return Task.CompletedTask;
+ var items = OffloadItems.ToList();
+ var dir = Offload.WorkspaceRoot(root, row.Workspace);
+ return Operate($"Offload {items.Count} item(s) from '{row.Name}'", null, async p =>
+ {
+ var m = await Step(p, "export", "Export items to this computer", async () =>
+ {
+ var prog = new Progress(o => p.Report(new("export", "Export items to this computer", StepState.Active,
+ $"{o.Done} / {o.Total} · {o.Current}", o.Total == 0 ? null : o.Done * 100 / o.Total)));
+ var r = await Offload.RunAsync(_fabric, row.Workspace, items, dir, OffloadAll ? "Full copy" : "Items Git can't track", prog);
+ return (r, $"{r.Saved} saved, {r.FailedCount} failed");
+ });
+ LastOffload = $"Last offload just now: {m.Saved} saved" + (m.FailedCount > 0 ? $", {m.FailedCount} failed" : "");
+ var failed = m.Items.Where(i => !i.Ok).Select(i => $"{i.Name}: {i.Note}").ToList();
+ return failed.Count == 0
+ ? $"Saved {m.Saved} item(s) to {dir}. Each item has its own folder; _offload.json lists what was saved and how."
+ : $"Saved {m.Saved}, {failed.Count} failed (kept any earlier copy): {string.Join("; ", failed.Take(3))}";
+ });
+ }
+
+ // ── Repoint ──────────────────────────────────────────────────────────────
+
+ void ResetRepoint()
+ {
+ TargetBranch = "";
+ Checks.Clear();
+ ChecksPassed = false;
+ ClearPreview();
+ }
+
+ void ClearPreview()
+ {
+ _preview = null;
+ HasPreview = false;
+ Acknowledged = false;
+ Plan.Clear();
+ PlanBlocks.Clear();
+ PlanWarnings.Clear();
+ PreviewSummary = "";
+ OnPropertyChanged(nameof(CanSwitch));
+ }
+
+ [RelayCommand]
+ private async Task CheckRepoint()
+ {
+ if (Selected is not { Connection: { } conn } row) return;
+ Checks.Clear();
+ ChecksPassed = false;
+ GitStatus? status = null;
+ string? statusError = null;
+ try { status = await _fabric.GitStatusAsync(row.Workspace.Id); }
+ catch (FabricException e) { statusError = Friendly(e); }
+ try { _credentials = await _fabric.GitCredentialsAsync(row.Workspace.Id); }
+ catch (FabricException) { _credentials = null; }
+ try { _items = await _fabric.ItemsAsync(row.Workspace.Id); }
+ catch (FabricException e) { DetailError = e.Message; return; }
+ if (Selected != row) return;
+ var target = TargetBranch.Trim();
+ foreach (var c in Git.Preflight(conn, status, statusError, _credentials, _items, target, OffloadFolder)) Checks.Add(new CheckRow(c));
+ ChecksPassed = Checks.All(c => c.Ok);
+ }
+
+ [RelayCommand]
+ private void PrepareRepoint()
+ {
+ if (Selected is not { Connection: { Provider: { } p } conn } row || !ChecksPassed || _credentials is not { } creds || OffloadFolder is not { } root) return;
+ var target = TargetBranch.Trim();
+ Ask($"Prepare to switch '{row.Name}' to {target}?",
+ $"Daxis first saves every item of '{row.Name}' to {Offload.WorkspaceRoot(root, row.Workspace)}\\_backups. " +
+ $"Then it disconnects the workspace from {p.Branch} and connects it to {target} to read exactly what would change. " +
+ "No workspace item is changed in this step. Until you switch or cancel, Fabric shows the workspace on the new branch. " +
+ "Needs the workspace Admin role.",
+ "Back up and preview", () => Operate($"Prepare switch to {target}", row, async prog =>
+ {
+ var preview = await new Repointer(_fabric).PrepareAsync(row.Workspace, _items, conn, creds, target, root, prog);
+ ShowPreview(preview);
+ return preview.Blocked
+ ? "Blocked: see the reasons under Repoint. Cancel to put the workspace back on its branch."
+ : preview.CommitsIntoEmptyBranch
+ ? $"{target} has nothing in {p.Folder} yet. Switching copies the workspace into it and changes no items."
+ : $"Ready. Review the {preview.Changes.Count} change(s) under Repoint, then switch or cancel.";
+ }, refreshAfter: false));
+ }
+
+ void ShowPreview(RepointPreview preview)
+ {
+ _preview = preview;
+ Plan.Clear();
+ foreach (var c in preview.Changes) Plan.Add(new PlanRow(c));
+ PlanBlocks.Clear();
+ foreach (var b in preview.Blocks) PlanBlocks.Add(b);
+ PlanWarnings.Clear();
+ foreach (var w in preview.Warnings) PlanWarnings.Add(w);
+ var c2 = preview.Changes;
+ PreviewSummary = preview.CommitsIntoEmptyBranch
+ ? $"{preview.Journal.Target} is empty: the workspace content is committed into it. No item changes."
+ : $"{c2.Count(c => c.Impact == Impact.Delete)} deleted · {c2.Count(c => c.Impact == Impact.Overwrite)} overwritten · " +
+ $"{c2.Count(c => c.Impact == Impact.Create)} added. Backup: {preview.Backup.Saved} item(s) in {preview.Journal.BackupPath}";
+ PreviewTitle = $"{preview.Journal.Original.Branch} → {preview.Journal.Target}";
+ HasPreview = true;
+ DetailPage = 2;
+ OnPropertyChanged(nameof(CanSwitch));
+ }
+
+ [RelayCommand]
+ private void ApplyRepoint()
+ {
+ if (_preview is not { } preview || Selected is not { } row || !CanSwitch) return;
+ var c = preview.Changes;
+ Ask($"Switch '{row.Name}' to {preview.Journal.Target}?",
+ preview.CommitsIntoEmptyBranch
+ ? $"Commits the workspace content into {preview.Journal.Target}. No workspace item changes."
+ : $"{c.Count(x => x.Impact == Impact.Delete)} item(s) are deleted, {c.Count(x => x.Impact == Impact.Overwrite)} overwritten and " +
+ $"{c.Count(x => x.Impact == Impact.Create)} added to match {preview.Journal.Target}. Their current versions stay in Git on " +
+ $"{preview.Journal.Original.Branch} and in the local backup at {preview.Journal.BackupPath}.",
+ $"Switch to {preview.Journal.Target}", () => Operate($"Switch '{row.Name}' to {preview.Journal.Target}", row, async p =>
+ {
+ await new Repointer(_fabric).ApplyAsync(preview, p);
+ ClearPreview();
+ TargetBranch = "";
+ return $"'{row.Name}' is now on {preview.Journal.Target}. The backup stays at {preview.Journal.BackupPath}.";
+ }));
+ }
+
+ [RelayCommand]
+ private void CancelRepoint()
+ {
+ if (_preview is not { } preview || Selected is not { } row) return;
+ Ask($"Put '{row.Name}' back on {preview.Journal.Original.Branch}?",
+ $"Disconnects it from {preview.Journal.Target} and reconnects {preview.Journal.Original.Branch}. No workspace item was changed, so none will be.",
+ "Restore original branch", () => Operate($"Restore '{row.Name}' to {preview.Journal.Original.Branch}", row, async p =>
+ {
+ await new Repointer(_fabric).RestoreAsync(preview.Journal, p);
+ ClearPreview();
+ return $"'{row.Name}' is back on {preview.Journal.Original.Branch}.";
+ }));
+ }
+
+ // ── Recovery after a crash or closed app ─────────────────────────────────
+
+ void LoadRecoveries()
+ {
+ Recoveries.Clear();
+ foreach (var j in RepointJournal.All())
+ {
+ if (j.NeedsRecovery) Recoveries.Add(new Recovery(j));
+ else j.Delete(); // stopped before touching the connection, or finished: nothing to recover
+ }
+ }
+
+ [RelayCommand]
+ private void Recover(Recovery r)
+ {
+ var row = _rows.FirstOrDefault(x => x.Workspace.Id == r.Journal.WorkspaceId);
+ Ask($"Restore '{r.Journal.WorkspaceName}' to {r.Journal.Original.Branch}?",
+ $"Reconnects the workspace to {r.Journal.Original.Branch} ({r.Journal.Original.Repo})" +
+ (r.Journal.ContentChanged ? " and updates its content back to that branch, because the switch had started changing items." : ". No items were changed.") +
+ (r.Journal.BackupPath is { } b ? $" The backup is at {b}." : ""),
+ "Restore original branch", () => Operate($"Restore '{r.Journal.WorkspaceName}'", row, async p =>
+ {
+ await new Repointer(_fabric).RestoreAsync(r.Journal, p);
+ Recoveries.Remove(r);
+ if (_preview?.Journal.WorkspaceId == r.Journal.WorkspaceId) ClearPreview();
+ return $"'{r.Journal.WorkspaceName}' is back on {r.Journal.Original.Branch}.";
+ }));
+ }
+
+ // ── Operation runner ─────────────────────────────────────────────────────
+
+ /// Runs one multi-step operation with the stepper visible. Returns the final message; failures stay on screen.
+ async Task Operate(string title, GitRow? row, Func, Task> run, bool refreshAfter = true)
+ {
+ if (IsOperating) return;
+ IsOperating = true;
+ ShowOperation = true;
+ OperationTitle = title;
+ OperationResult = null;
+ OperationFailed = false;
+ DetailError = null;
+ Steps.Clear();
+ var progress = new Progress(u =>
+ {
+ var step = Steps.FirstOrDefault(s => s.Key == u.Key);
+ if (step is null) Steps.Add(step = new StepRow(u.Key, u.Title));
+ step.Title = u.Title;
+ step.State = u.State;
+ step.Percent = u.Percent;
+ if (u.Detail is not null || u.State != StepState.Active) step.Detail = u.Detail;
+ });
+ try { OperationResult = await run(progress); }
+ catch (Exception e)
+ {
+ OperationFailed = true;
+ OperationResult = e is FabricException f ? Friendly(f) : e.Message;
+ LoadRecoveries(); // a failed restore leaves its journal for the banner
+ }
+ finally { IsOperating = false; }
+ if (row is not null && refreshAfter) await RefreshRowAsync(row);
+ }
+
+ static async Task Step(IProgress p, string key, string title, Func> run)
+ {
+ p.Report(new(key, title, StepState.Active));
+ try
+ {
+ var (r, detail) = await run();
+ p.Report(new(key, title, StepState.Done, detail));
+ return r;
+ }
+ catch (Exception e)
+ {
+ p.Report(new(key, title, StepState.Failed, e.Message));
+ throw;
+ }
+ }
+
+ [RelayCommand]
+ private void CloseOperation()
+ {
+ if (!IsOperating) ShowOperation = false;
+ }
+
+ /// Service error codes → what to do about them.
+ internal static string Friendly(FabricException e) => e.Code switch
+ {
+ "InsufficientPrivileges" => "You don't have the role this needs in the workspace (connect, disconnect and switching branch need Admin).",
+ "WorkspaceNotConnectedToGit" => "The workspace isn't connected to Git.",
+ "WorkspaceHasNoCapacityAssigned" => "Git integration needs the workspace on a Fabric or Premium capacity.",
+ "WorkspaceHeadMismatch" => "Someone changed the workspace or branch meanwhile. Refresh and try again.",
+ "WorkspacePreviousOperationInProgress" => "Another Git operation is still running on this workspace. Wait and retry.",
+ "PrincipalTypeNotSupported" => "This operation doesn't support your identity type.",
+ "GitCredentialsNotConfigured" or "GitCredentialsConfigurationNotSupported" => "Set up your Git account in the workspace's Source control panel in Fabric first.",
+ "MissingDependency" or "DependencyDeletionFailed" => $"Blocked by an item dependency: {e.Message}",
+ _ when e.Status == System.Net.HttpStatusCode.Forbidden => $"Access denied. {e.Message}",
+ _ => e.Message,
+ };
+
+ internal static string Plural(int n, string word) => $"{n} {word}{(n == 1 ? "" : "s")}";
+
+ public override void Dispose() => _scan?.Cancel();
+}
diff --git a/src/Daxis.App/Theme/Styles.axaml b/src/Daxis.App/Theme/Styles.axaml
index 8e6c579..02bfb28 100644
--- a/src/Daxis.App/Theme/Styles.axaml
+++ b/src/Daxis.App/Theme/Styles.axaml
@@ -7,7 +7,7 @@
@@ -23,10 +23,10 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -259,7 +336,7 @@
@@ -268,7 +345,7 @@
@@ -292,7 +369,7 @@
@@ -339,19 +416,19 @@
-
+
@@ -134,7 +134,7 @@
-
@@ -144,14 +144,14 @@
-
-
+
-
@@ -185,7 +185,7 @@
-
@@ -226,7 +226,7 @@
-
+
@@ -260,7 +260,7 @@
-
@@ -273,7 +273,7 @@
@@ -283,7 +283,7 @@
-
+
@@ -291,12 +291,12 @@
-
-
-
+
+
+
-
-
+
@@ -305,7 +305,7 @@
-
@@ -317,7 +317,7 @@
-
@@ -383,7 +383,7 @@
-
@@ -411,7 +411,7 @@
Background="{DynamicResource Bg0}" IsVisible="{Binding HasProperties}">
-
+
@@ -423,7 +423,7 @@
Background="{DynamicResource Bg0}" IsVisible="{Binding SourceInfo, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
-
+
@@ -435,7 +435,7 @@
@@ -661,7 +661,7 @@
-
@@ -675,7 +675,7 @@
-
diff --git a/src/Daxis.App/Views/NotebookView.axaml b/src/Daxis.App/Views/NotebookView.axaml
index 7d51bcc..9eadcd1 100644
--- a/src/Daxis.App/Views/NotebookView.axaml
+++ b/src/Daxis.App/Views/NotebookView.axaml
@@ -58,7 +58,7 @@
-
+
diff --git a/src/Daxis.App/Views/SourceControlView.axaml b/src/Daxis.App/Views/SourceControlView.axaml
new file mode 100644
index 0000000..9e5a80f
--- /dev/null
+++ b/src/Daxis.App/Views/SourceControlView.axaml
@@ -0,0 +1,483 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Daxis.App/Views/SourceControlView.axaml.cs b/src/Daxis.App/Views/SourceControlView.axaml.cs
new file mode 100644
index 0000000..6406f72
--- /dev/null
+++ b/src/Daxis.App/Views/SourceControlView.axaml.cs
@@ -0,0 +1,42 @@
+using Avalonia.Controls;
+using Avalonia.Platform.Storage;
+
+namespace Daxis.App.Views;
+
+public sealed partial class SourceControlView : UserControl
+{
+ public SourceControlView()
+ {
+ InitializeComponent();
+
+ // Rows and recovery banners carry their model in Tag.
+ AddHandler(Button.ClickEvent, (_, e) =>
+ {
+ if (DataContext is not SourceControlTab vm) return;
+ if (e.Source is Button { Tag: GitRow row } b && b.Classes.Contains("row")) vm.Select(row);
+ else if (e.Source is Button { Tag: Recovery r } rb && rb.Classes.Contains("recover")) vm.RecoverCommand.Execute(r);
+ });
+
+ DataContextChanged += (_, _) =>
+ {
+ if (DataContext is not SourceControlTab vm) return;
+ OpenWeb.Click += (_, _) => Web.Open(this, vm.WebUrl);
+ ChooseFolder.Click += async (_, _) =>
+ {
+ if (TopLevel.GetTopLevel(this)?.StorageProvider is not { } sp) return;
+ var picked = await sp.OpenFolderPickerAsync(new FolderPickerOpenOptions
+ {
+ Title = "Folder for offloads and repoint backups",
+ AllowMultiple = false,
+ });
+ if (picked.FirstOrDefault()?.TryGetLocalPath() is { } path) vm.SetOffloadFolder(path);
+ };
+ OpenFolder.Click += async (_, _) =>
+ {
+ var dir = Directory.Exists(vm.OffloadTarget) ? vm.OffloadTarget : vm.OffloadFolder;
+ if (dir is not null && Directory.Exists(dir) && TopLevel.GetTopLevel(this) is { } top)
+ await top.Launcher.LaunchDirectoryInfoAsync(new DirectoryInfo(dir));
+ };
+ };
+ }
+}
diff --git a/src/Daxis.App/Views/WorkspaceView.axaml b/src/Daxis.App/Views/WorkspaceView.axaml
index b4c3b97..ef5b380 100644
--- a/src/Daxis.App/Views/WorkspaceView.axaml
+++ b/src/Daxis.App/Views/WorkspaceView.axaml
@@ -27,7 +27,7 @@
-
+
@@ -60,7 +60,7 @@
-
+
@@ -71,7 +71,7 @@
-
@@ -94,7 +94,7 @@
-
@@ -136,7 +136,7 @@
@@ -152,23 +152,23 @@
-
+
-
+
-
+
-
+
-
+
@@ -181,8 +181,8 @@
-
-
+
+
@@ -190,7 +190,7 @@
-
+
@@ -200,7 +200,7 @@
-
@@ -209,12 +209,12 @@
-
-
@@ -297,7 +297,7 @@
@@ -313,7 +313,7 @@
-
@@ -322,14 +322,14 @@
-
+
-
+
@@ -338,13 +338,13 @@
-
+
-
-
-
+
+
@@ -482,7 +482,7 @@
-
diff --git a/src/Daxis.Core/FabricClient.cs b/src/Daxis.Core/FabricClient.cs
index 097d6a8..e3fd5b3 100644
--- a/src/Daxis.Core/FabricClient.cs
+++ b/src/Daxis.Core/FabricClient.cs
@@ -22,13 +22,15 @@ public sealed record ReportRef(string Id, string Name, string WorkspaceId, strin
public sealed record WorkspaceReport(ReportRef Ref, string ModelId, string ModelWorkspaceId);
public sealed record QueryResult(IReadOnlyList Columns, IReadOnlyList Rows);
-public sealed class FabricException(HttpStatusCode status, string message) : Exception(message)
+public sealed class FabricException(HttpStatusCode status, string message, string? code = null) : Exception(message)
{
public HttpStatusCode Status { get; } = status;
+ /// The service's errorCode (e.g. WorkspaceNotConnectedToGit), when it sent one.
+ public string? Code { get; } = code;
}
/// Thin client over the Fabric, Power BI and OneLake REST APIs.
-public sealed class FabricClient(Auth auth) : IDisposable
+public sealed partial class FabricClient(Auth auth) : IDisposable
{
const string Fabric = "https://api.fabric.microsoft.com/v1/";
const string PowerBi = "https://api.powerbi.com/v1.0/myorg/";
@@ -67,6 +69,35 @@ public async Task> GetDefinitionAsync(FabricItem item, stri
.ToList();
}
+ /// The definition as raw bytes per part, for writing to disk (text decoding would corrupt images and custom visuals).
+ public async Task> GetDefinitionFilesAsync(FabricItem item, string? format = null, CancellationToken ct = default)
+ {
+ var url = $"{Fabric}workspaces/{E(item.WorkspaceId)}/items/{E(item.Id)}/getDefinition" + (format is null ? "" : $"?format={E(format)}");
+ var json = await LongRunningAsync(HttpMethod.Post, url, null, wantResult: true, ct);
+ return json!["definition"]!["parts"]!.AsArray()
+ .Select(p => new ItemFile(Str(p!, "path"), Convert.FromBase64String(Str(p!, "payload")))).ToList();
+ }
+
+ /// Downloads a report as a .pbix (the fallback for reports without an item definition).
+ public async Task ExportReportAsync(FabricItem report, CancellationToken ct = default)
+ {
+ using var res = await SendAsync(HttpMethod.Get, $"{PowerBi}groups/{E(report.WorkspaceId)}/reports/{E(report.Id)}/Export", null, Auth.PowerBi, ct);
+ return await res.Content.ReadAsByteArrayAsync(ct);
+ }
+
+ /// Whatever the service can say about an item that has no definition: dashboard tiles, dataflow JSON, else the item record.
+ public async Task ItemMetadataAsync(FabricItem item, CancellationToken ct = default)
+ {
+ var url = item.Type switch
+ {
+ "Dashboard" => $"{PowerBi}groups/{E(item.WorkspaceId)}/dashboards/{E(item.Id)}/tiles",
+ "Dataflow" => $"{PowerBi}groups/{E(item.WorkspaceId)}/dataflows/{E(item.Id)}",
+ _ => $"{Fabric}workspaces/{E(item.WorkspaceId)}/items/{E(item.Id)}",
+ };
+ try { return JsonSerializer.Deserialize((await GetJsonAsync(url, Auth.PowerBi, ct)).ToJsonString()); }
+ catch (FabricException) { return null; }
+ }
+
public Task UpdateDefinitionAsync(FabricItem item, IEnumerable parts, CancellationToken ct = default)
{
var body = new
@@ -218,7 +249,8 @@ async Task> PagedAsync(string url, CancellationToken ct, string a
return all;
}
- async Task LongRunningAsync(HttpMethod method, string url, object? body, bool wantResult, CancellationToken ct)
+ async Task LongRunningAsync(HttpMethod method, string url, object? body, bool wantResult, CancellationToken ct,
+ IProgress? progress = null)
{
using var res = await SendAsync(method, url, body, Auth.PowerBi, ct);
if (res.StatusCode != HttpStatusCode.Accepted)
@@ -230,10 +262,12 @@ async Task> PagedAsync(string url, CancellationToken ct, string a
{
await Task.Delay(delay, ct);
var state = await GetJsonAsync(op, Auth.PowerBi, ct);
+ if (state["percentComplete"]?.GetValueKind() == JsonValueKind.Number) progress?.Report(state["percentComplete"]!.GetValue());
switch (Str(state, "status"))
{
case "Succeeded": return wantResult ? await GetJsonAsync(op + "/result", Auth.PowerBi, ct) : null;
- case "Failed": throw new FabricException(HttpStatusCode.OK, state["error"]?["message"]?.ToString() ?? "Operation failed.");
+ case "Failed": throw new FabricException(HttpStatusCode.OK, state["error"]?["message"]?.ToString() ?? "Operation failed.",
+ state["error"]?["errorCode"]?.ToString());
}
delay = TimeSpan.FromSeconds(Math.Min(delay.TotalSeconds * 1.5, 5));
}
@@ -267,7 +301,7 @@ async Task SendAsync(HttpMethod method, string url, object?
}
var text = await res.Content.ReadAsStringAsync(ct);
res.Dispose();
- throw new FabricException(res.StatusCode, ErrorMessage(res.StatusCode, text));
+ throw new FabricException(res.StatusCode, ErrorMessage(res.StatusCode, text), ErrorCode(text));
}
}
@@ -304,6 +338,12 @@ internal static string ErrorMessage(HttpStatusCode status, string body)
return $"{(int)status} {status}";
}
+ internal static string? ErrorCode(string body)
+ {
+ try { return JsonNode.Parse(body)?["errorCode"]?.GetValue(); }
+ catch (Exception e) when (e is JsonException or InvalidOperationException) { return null; }
+ }
+
static string Str(JsonNode n, string prop) => n[prop]?.GetValue() ?? "";
public void Dispose() => _http.Dispose();
diff --git a/src/Daxis.Core/FabricGit.cs b/src/Daxis.Core/FabricGit.cs
new file mode 100644
index 0000000..af68fe8
--- /dev/null
+++ b/src/Daxis.Core/FabricGit.cs
@@ -0,0 +1,82 @@
+using System.Net;
+using System.Text.Json.Nodes;
+
+namespace Daxis.Core;
+
+// Fabric Git integration REST API (workspaces/{id}/git/*). Fabric talks to the Git provider itself;
+// Daxis never sees Git credentials or sends a token anywhere but the Fabric API.
+public sealed partial class FabricClient
+{
+ string GitUrl(string workspaceId, string path) => $"{Fabric}workspaces/{E(workspaceId)}/git/{path}";
+
+ public async Task GitConnectionAsync(string workspaceId, CancellationToken ct = default) =>
+ Git.ParseConnection(await GetJsonAsync(GitUrl(workspaceId, "connection"), Auth.PowerBi, ct));
+
+ /// Per-item differences between the workspace and its branch. Long-running on large workspaces.
+ public async Task GitStatusAsync(string workspaceId, CancellationToken ct = default) =>
+ Git.ParseStatus(await LongRunningAsync(HttpMethod.Get, GitUrl(workspaceId, "status"), null, wantResult: true, ct)
+ ?? throw new FabricException(HttpStatusCode.OK, "Git status returned nothing."));
+
+ public async Task GitCredentialsAsync(string workspaceId, CancellationToken ct = default) =>
+ Git.ParseCredentials(await GetJsonAsync(GitUrl(workspaceId, "myGitCredentials"), Auth.PowerBi, ct));
+
+ /// Commits workspace changes to the connected branch: everything, or only .
+ public Task GitCommitAsync(string workspaceId, string? workspaceHead, string comment, IReadOnlyList? items = null,
+ IProgress? progress = null, CancellationToken ct = default)
+ {
+ var body = new JsonObject
+ {
+ ["mode"] = items is null ? "All" : "Selective",
+ ["comment"] = comment.Length > 300 ? comment[..300] : comment,
+ };
+ if (workspaceHead is not null) body["workspaceHead"] = workspaceHead;
+ if (items is not null) body["items"] = new JsonArray([.. items.Select(Git.Identifier)]);
+ return LongRunningAsync(HttpMethod.Post, GitUrl(workspaceId, "commitToGit"), body, wantResult: false, ct, progress);
+ }
+
+ ///
+ /// Brings the workspace to . Overwrites and deletes workspace items as the branch dictates;
+ /// callers show the preview and confirm first. is PreferRemote / PreferWorkspace, or null to
+ /// refuse when there are conflicts.
+ ///
+ public Task GitUpdateAsync(string workspaceId, string remoteCommitHash, string? workspaceHead, string? conflictPolicy,
+ IProgress? progress = null, CancellationToken ct = default)
+ {
+ var body = new JsonObject
+ {
+ ["remoteCommitHash"] = remoteCommitHash,
+ ["options"] = new JsonObject { ["allowOverrideItems"] = true },
+ };
+ if (workspaceHead is not null) body["workspaceHead"] = workspaceHead;
+ if (conflictPolicy is not null)
+ body["conflictResolution"] = new JsonObject { ["conflictResolutionType"] = "Workspace", ["conflictResolutionPolicy"] = conflictPolicy };
+ return LongRunningAsync(HttpMethod.Post, GitUrl(workspaceId, "updateFromGit"), body, wantResult: false, ct, progress);
+ }
+
+ public async Task GitConnectAsync(string workspaceId, GitProvider provider, GitCredentials credentials, CancellationToken ct = default)
+ {
+ var body = new JsonObject { ["gitProviderDetails"] = provider.ToJson() };
+ if (credentials.Source != "None") body["myGitCredentials"] = credentials.ToJson();
+ using var _ = await SendAsync(HttpMethod.Post, GitUrl(workspaceId, "connect"), body, Auth.PowerBi, ct);
+ }
+
+ public async Task GitDisconnectAsync(string workspaceId, CancellationToken ct = default)
+ {
+ using var _ = await SendAsync(HttpMethod.Post, GitUrl(workspaceId, "disconnect"), new JsonObject(), Auth.PowerBi, ct);
+ }
+
+ /// Works out which way the first sync must go. Changes no items; the caller then commits or updates.
+ public async Task GitInitializeAsync(string workspaceId, string strategy, CancellationToken ct = default) =>
+ Git.ParseInit(await LongRunningAsync(HttpMethod.Post, GitUrl(workspaceId, "initializeConnection"),
+ new JsonObject { ["initializationStrategy"] = strategy }, wantResult: true, ct) ?? new JsonObject());
+
+ /// Branch-workspace links (preview API). Viewer+ can read them.
+ public async Task> WorkspaceRelationsAsync(string workspaceId, CancellationToken ct = default) =>
+ (await PagedAsync(GitUrl(workspaceId, "workspaceRelations"), ct)).Select(Git.ParseRelation).ToList();
+
+ public async Task CreateWorkspaceRelationAsync(string workspaceId, string relatedWorkspaceId, string relationType, CancellationToken ct = default)
+ {
+ using var _ = await SendAsync(HttpMethod.Post, GitUrl(workspaceId, "workspaceRelations"),
+ new JsonObject { ["relatedWorkspaceId"] = relatedWorkspaceId, ["relationType"] = relationType }, Auth.PowerBi, ct);
+ }
+}
diff --git a/src/Daxis.Core/Offload.cs b/src/Daxis.Core/Offload.cs
new file mode 100644
index 0000000..ef53054
--- /dev/null
+++ b/src/Daxis.Core/Offload.cs
@@ -0,0 +1,195 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Daxis.Core;
+
+/// One file of an exported item, as raw bytes (images and custom visuals are binary).
+public sealed record ItemFile(string Path, byte[] Content);
+
+/// What happened to one item in an offload or backup run.
+public sealed record OffloadEntry(string ItemId, string Type, string Name, string Folder, string Method, string? Note = null)
+{
+ public bool Ok => Method != Offload.Failed;
+ /// The item's content was saved (definition or PBIX), not just a descriptor. Only this counts as a backup.
+ public bool IsFullCopy => Method is Offload.Definition or Offload.Pbix;
+}
+
+public sealed record OffloadManifest(DateTimeOffset At, string WorkspaceId, string WorkspaceName, string Kind, List Items)
+{
+ public int Saved => Items.Count(i => i.Ok);
+ public int FailedCount => Items.Count(i => !i.Ok);
+}
+
+public sealed record OffloadProgress(int Done, int Total, string Current);
+
+///
+/// Writes item definitions to a local folder in the Fabric Git layout (<Name>.<Type>/…), so a copy made
+/// here can later be committed to a repo or re-imported. Used for items Git can't track (offload) and for the
+/// full backup taken before a repoint. Local-only: nothing is written to the service.
+///
+public static class Offload
+{
+ public const string Definition = "Definition", Pbix = "PBIX", Metadata = "Metadata", Failed = "Failed";
+ public const string ManifestFile = "_offload.json";
+ const string BackupFolder = "_backups";
+
+ static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web)
+ {
+ WriteIndented = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ };
+
+ /// Windows is the strictest target: strip its invalid characters and trailing dots/spaces.
+ public static string Sanitise(string name)
+ {
+ var bad = new HashSet(Path.GetInvalidFileNameChars().Concat(['<', '>', ':', '"', '/', '\\', '|', '?', '*']));
+ var s = new string(name.Select(c => bad.Contains(c) || char.IsControl(c) ? '_' : c).ToArray()).Trim().TrimEnd('.');
+ return s.Length == 0 ? "_" : s;
+ }
+
+ /// Item id → folder name. Names that collide case-insensitively get a short id suffix so nothing overwrites anything.
+ public static Dictionary FolderNames(IEnumerable items)
+ {
+ var list = items.ToList();
+ var counts = list.GroupBy(i => Base(i), StringComparer.OrdinalIgnoreCase).ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
+ return list.ToDictionary(i => i.Id, i =>
+ {
+ var b = Base(i);
+ return counts[b] == 1 ? b : $"{Sanitise(i.DisplayName)} ({i.Id[..Math.Min(8, i.Id.Length)]}).{i.Type}";
+ });
+
+ static string Base(FabricItem i) => $"{Sanitise(i.DisplayName)}.{i.Type}";
+ }
+
+ public static string WorkspaceRoot(string root, Workspace ws) => Path.Combine(root, Sanitise(ws.DisplayName));
+
+ public static string BackupRoot(string root, Workspace ws, string branch, DateTimeOffset at) =>
+ Path.Combine(WorkspaceRoot(root, ws), BackupFolder, $"{at.ToLocalTime():yyyyMMdd-HHmmss}-{Sanitise(branch)}");
+
+ ///
+ /// Writes one item's files under , replacing the previous copy only once the new one is
+ /// complete (a failed export never leaves a half-written folder or destroys the last good copy).
+ ///
+ public static void WriteItem(string itemDir, IEnumerable files)
+ {
+ var full = Path.GetFullPath(itemDir);
+ var tmp = full + ".tmp-" + Guid.NewGuid().ToString("N")[..8];
+ Directory.CreateDirectory(tmp);
+ try
+ {
+ foreach (var f in files)
+ {
+ var target = SafeCombine(tmp, f.Path);
+ Directory.CreateDirectory(Path.GetDirectoryName(target)!);
+ File.WriteAllBytes(target, f.Content);
+ }
+ var old = full + ".old-" + Guid.NewGuid().ToString("N")[..8];
+ if (Directory.Exists(full)) Directory.Move(full, old);
+ Directory.Move(tmp, full);
+ if (Directory.Exists(old)) Directory.Delete(old, recursive: true);
+ }
+ catch
+ {
+ if (Directory.Exists(tmp)) Directory.Delete(tmp, recursive: true);
+ throw;
+ }
+ }
+
+ /// Joins a service-supplied relative path onto a folder, refusing anything that would escape it.
+ internal static string SafeCombine(string dir, string relative)
+ {
+ var segments = relative.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries);
+ if (segments.Length == 0 || segments.Any(s => s is "." or ".."))
+ throw new InvalidOperationException($"Refusing unsafe path from the service: {relative}");
+ var path = Path.GetFullPath(Path.Combine([dir, .. segments.Select(Sanitise)]));
+ var root = Path.GetFullPath(dir).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
+ if (!path.StartsWith(root, StringComparison.OrdinalIgnoreCase))
+ throw new InvalidOperationException($"Refusing unsafe path from the service: {relative}");
+ return path;
+ }
+
+ public static void WriteManifest(string dir, OffloadManifest m)
+ {
+ Directory.CreateDirectory(dir);
+ File.WriteAllText(Path.Combine(dir, ManifestFile), JsonSerializer.Serialize(m, Json));
+ }
+
+ public static OffloadManifest? ReadManifest(string dir)
+ {
+ try { return JsonSerializer.Deserialize(File.ReadAllText(Path.Combine(dir, ManifestFile)), Json); }
+ catch (Exception e) when (e is IOException or JsonException or UnauthorizedAccessException) { return null; }
+ }
+
+ ///
+ /// Exports into : the item definition when the API has one, a PBIX
+ /// for reports without one, otherwise a metadata record so the inventory is never silently incomplete.
+ ///
+ public static async Task RunAsync(FabricClient fabric, Workspace ws, IReadOnlyList items, string dir,
+ string kind, IProgress? progress = null, CancellationToken ct = default)
+ {
+ Directory.CreateDirectory(dir);
+ var folders = FolderNames(items);
+ var entries = new OffloadEntry[items.Count];
+ var done = 0;
+ using var gate = new SemaphoreSlim(4); // ponytail: fixed fan-out; the client already backs off on 429
+ await Task.WhenAll(items.Select(async (item, i) =>
+ {
+ await gate.WaitAsync(ct).ConfigureAwait(false);
+ try { entries[i] = await ExportOneAsync(fabric, item, Path.Combine(dir, folders[item.Id]), folders[item.Id], ct).ConfigureAwait(false); }
+ finally
+ {
+ gate.Release();
+ progress?.Report(new OffloadProgress(Interlocked.Increment(ref done), items.Count, item.DisplayName));
+ }
+ })).ConfigureAwait(false);
+ var manifest = new OffloadManifest(DateTimeOffset.UtcNow, ws.Id, ws.DisplayName, kind, [.. entries]);
+ WriteManifest(dir, manifest);
+ return manifest;
+ }
+
+ static async Task ExportOneAsync(FabricClient fabric, FabricItem item, string itemDir, string folder, CancellationToken ct)
+ {
+ string? why = null;
+ try
+ {
+ // Off the UI thread from here: exports write many files.
+ var files = await fabric.GetDefinitionFilesAsync(item, item.Type == "SemanticModel" ? "TMDL" : null, ct).ConfigureAwait(false);
+ WriteItem(itemDir, files);
+ return new(item.Id, item.Type, item.DisplayName, folder, Definition);
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception e) { why = e.Message; }
+
+ try
+ {
+ if (item.Type == "Report")
+ {
+ var pbix = await fabric.ExportReportAsync(item, ct).ConfigureAwait(false);
+ WriteItem(itemDir, [new ItemFile(Sanitise(item.DisplayName) + ".pbix", pbix), Meta(item, why)]);
+ return new(item.Id, item.Type, item.DisplayName, folder, Pbix, "No item definition; exported the PBIX instead.");
+ }
+ var extra = await fabric.ItemMetadataAsync(item, ct).ConfigureAwait(false);
+ WriteItem(itemDir, [Meta(item, why, extra)]);
+ return new(item.Id, item.Type, item.DisplayName, folder, Metadata, "No exportable definition; saved its metadata only.");
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception e)
+ {
+ // Keep whatever copy an earlier run left: a failure must never destroy the last good export.
+ return new(item.Id, item.Type, item.DisplayName, folder, Failed, e.Message);
+ }
+ }
+
+ static ItemFile Meta(FabricItem item, string? definitionError, JsonElement? extra = null) =>
+ new("item.metadata.json", JsonSerializer.SerializeToUtf8Bytes(new
+ {
+ id = item.Id,
+ type = item.Type,
+ displayName = item.DisplayName,
+ description = item.Description,
+ workspaceId = item.WorkspaceId,
+ exportedAt = DateTimeOffset.UtcNow,
+ definitionError,
+ details = extra,
+ }, Json));
+}
diff --git a/src/Daxis.Core/Repoint.cs b/src/Daxis.Core/Repoint.cs
new file mode 100644
index 0000000..a9ce2a2
--- /dev/null
+++ b/src/Daxis.Core/Repoint.cs
@@ -0,0 +1,273 @@
+namespace Daxis.Core;
+
+public enum StepState { Pending, Active, Done, Failed, Skipped }
+
+/// Progress of one named step. is set while a long-running service operation reports it.
+public sealed record StepUpdate(string Key, string Title, StepState State, string? Detail = null, int? Percent = null);
+
+/// What the target branch would do to the workspace, read after connecting to it and before anything is applied.
+public sealed record RepointPreview(RepointJournal Journal, List Changes, OffloadManifest Backup)
+{
+ /// The target folder is empty: switching commits the workspace into the new branch and changes no items.
+ public bool CommitsIntoEmptyBranch => Journal.RequiredAction == "CommitToGit";
+ public List Blocks => [.. Changes.Where(c => c.Block is not null).Select(c => c.Block!),
+ .. Changes.Where(c => c.Impact != Impact.Create && !BackedUp(c)).Select(c => $"{c.Name} ({c.Type}) has no full local backup, so it can't be changed safely.")];
+
+ /// Identity of the confirmed plan, to detect that the workspace or branch moved on before it was applied.
+ internal static string Signature(IEnumerable changes) =>
+ string.Join("\n", changes.Select(c => $"{c.Type}|{c.Name}|{c.Impact}".ToLowerInvariant()).Order());
+ public List Warnings => [.. Changes.Where(c => c.Warning is not null).Select(c => c.Warning!)];
+ public bool Blocked => Blocks.Count > 0;
+
+ bool BackedUp(PlannedChange c) => Backup.Items.Any(i => i.IsFullCopy &&
+ string.Equals(i.Name, c.Name, StringComparison.OrdinalIgnoreCase) && string.Equals(i.Type, c.Type, StringComparison.OrdinalIgnoreCase));
+}
+
+///
+/// Moves a Git-connected workspace to another branch without losing anything:
+///
+/// Prepare: preflight → full local backup → record links → disconnect → connect target → initialise → read status (preview).
+/// Nothing in the workspace has changed yet; any failure restores the original branch.
+/// Apply (after the user confirms the preview): update from the target branch, verify, restore links.
+/// Restore (the user declines, or recovery after a crash): reconnect the original branch; if content already changed, update back to it.
+///
+/// Every service-changing step is written to the journal first.
+///
+public sealed class Repointer(FabricClient fabric, string? journalDir = null)
+{
+ public async Task PrepareAsync(Workspace ws, IReadOnlyList items, GitConnection conn, GitCredentials creds,
+ string target, string backupRoot, IProgress progress, CancellationToken ct = default)
+ {
+ var original = conn.Provider ?? throw new InvalidOperationException("The workspace is not connected to Git.");
+ var j = new RepointJournal
+ {
+ WorkspaceId = ws.Id, WorkspaceName = ws.DisplayName, Original = original, OriginalHead = conn.Head,
+ Credentials = creds, Target = target,
+ };
+
+ // 1. Backup. Read-only against the service.
+ var backupDir = Offload.BackupRoot(backupRoot, ws, original.Branch, DateTimeOffset.Now);
+ j.BackupPath = backupDir;
+ j.Advance(RepointStep.Started, $"Repoint {original.Branch} → {target}", journalDir);
+ var backup = await Step(progress, "backup", "Back up every item to this computer", async () =>
+ {
+ var p = new Progress(o => progress.Report(new("backup", "Back up every item to this computer", StepState.Active,
+ $"{o.Done} / {o.Total} · {o.Current}", o.Total == 0 ? null : o.Done * 100 / o.Total)));
+ var m = await Offload.RunAsync(fabric, ws, [.. items.Where(i => !Git.SystemOwned.Contains(i.Type))], backupDir, "Backup before repoint", p, ct);
+ return (m, $"{m.Saved} saved, {m.FailedCount} failed → {backupDir}");
+ });
+ j.Advance(RepointStep.BackedUp, backupDir, journalDir);
+
+ j.Relations = await Step(progress, "links", "Record branch-workspace links", async () =>
+ {
+ try
+ {
+ var r = await fabric.WorkspaceRelationsAsync(ws.Id, ct);
+ return (r, r.Count == 0 ? "None" : $"{r.Count} link(s)");
+ }
+ catch (FabricException e) { return (new List(), $"Couldn't read ({e.Message}); none will be restored"); }
+ });
+ j.Save(journalDir);
+
+ // Last look before the first write: the checks the user saw may be minutes old.
+ await Step(progress, "recheck", "Re-check the workspace just before switching", async () =>
+ {
+ var live = await fabric.GitConnectionAsync(ws.Id, ct);
+ if (live.Provider is not { } lp || lp.FamilyKey != original.FamilyKey || lp.Branch != original.Branch)
+ throw new InvalidOperationException($"The workspace's Git connection changed since the check (now {live.Provider?.Branch ?? "not connected"}). Nothing was changed.");
+ var s = await fabric.GitStatusAsync(ws.Id, ct);
+ if (s.Uncommitted > 0 || s.Conflicts > 0)
+ throw new InvalidOperationException($"{s.Uncommitted} uncommitted change(s) and {s.Conflicts} conflict(s) appeared since the check. Commit them first; nothing was changed.");
+ j.OriginalHead = live.Head ?? j.OriginalHead;
+ return (true, "Still on " + original.Branch + ", nothing uncommitted");
+ });
+
+ // 2. Point at the target. From here on, any failure puts the original branch back.
+ try
+ {
+ await Step(progress, "disconnect", $"Disconnect from {original.Branch}", async () =>
+ {
+ j.Advance(RepointStep.Disconnected, "disconnecting", journalDir);
+ await fabric.GitDisconnectAsync(ws.Id, ct);
+ return (true, "Workspace items are untouched");
+ });
+ await Step(progress, "connect", $"Connect to {target}", async () =>
+ {
+ j.Advance(RepointStep.Connected, "connecting", journalDir);
+ await fabric.GitConnectAsync(ws.Id, original with { Branch = target }, creds, ct);
+ return (true, $"{original.Repo} · {target} · {original.Folder}");
+ });
+ var init = await Step(progress, "init", "Initialise the connection", async () =>
+ {
+ var r = await fabric.GitInitializeAsync(ws.Id, "PreferRemote", ct);
+ return (r, $"Fabric says next: {r.RequiredAction}");
+ });
+ j.RemoteCommitHash = init.RemoteCommitHash;
+ j.TargetWorkspaceHead = init.WorkspaceHead;
+ j.RequiredAction = init.RequiredAction;
+ j.Advance(RepointStep.Initialized, init.RequiredAction, journalDir);
+
+ var status = await Step(progress, "preview", "Read what the switch would change", async () =>
+ {
+ var s = await fabric.GitStatusAsync(ws.Id, ct);
+ return (s, $"{s.Changes.Count} item(s) differ");
+ });
+ j.RemoteCommitHash ??= status.RemoteCommitHash;
+ j.Advance(RepointStep.Previewed, $"{status.Changes.Count} differences", journalDir);
+ // An empty target folder means Fabric will copy the workspace into Git: the item list is uploads, not deletions.
+ return new RepointPreview(j, init.RequiredAction == "CommitToGit" ? [] : Git.ClassifySwitch(status), backup);
+ }
+ catch (Exception e)
+ {
+ j.Log.Add($"Failed: {e.Message}");
+ try { await RestoreAsync(j, progress, CancellationToken.None); }
+ catch (Exception r)
+ {
+ throw new InvalidOperationException($"Repoint stopped: {e.Message} Restoring {original.Branch} also failed: {r.Message} " +
+ "Nothing in the workspace was changed; use Restore original branch in Source control to finish.", e);
+ }
+ throw new InvalidOperationException($"Repoint stopped: {e.Message} The workspace is back on {original.Branch}; nothing was changed.", e);
+ }
+ }
+
+ public async Task ApplyAsync(RepointPreview preview, IProgress progress, CancellationToken ct = default)
+ {
+ if (preview.Blocked) throw new InvalidOperationException("The preview has blocking issues; nothing was changed.");
+ var j = preview.Journal;
+ // The workspace stayed live while the user read the preview: apply only what they saw.
+ await Step(progress, "recheck", "Confirm nothing changed since the preview", async () =>
+ {
+ var fresh = await fabric.GitStatusAsync(j.WorkspaceId, ct);
+ var now = new RepointPreview(j, j.RequiredAction == "CommitToGit" ? [] : Git.ClassifySwitch(fresh), preview.Backup);
+ if (now.Blocked || RepointPreview.Signature(now.Changes) != RepointPreview.Signature(preview.Changes))
+ throw new InvalidOperationException("The workspace or the branch changed after the preview. Nothing was applied; " +
+ "cancel to restore the original branch, then prepare the switch again.");
+ j.RemoteCommitHash = fresh.RemoteCommitHash ?? j.RemoteCommitHash;
+ return (true, "Same as the preview");
+ });
+ if (j.RequiredAction == "CommitToGit")
+ {
+ await Step(progress, "update", $"Commit the workspace into {j.Target}", async () =>
+ {
+ var p = new Progress(pc => progress.Report(new("update", $"Commit the workspace into {j.Target}", StepState.Active, $"{pc}%", pc)));
+ await fabric.GitCommitAsync(j.WorkspaceId, j.TargetWorkspaceHead, $"Daxis: {j.WorkspaceName} repointed from {j.Original.Branch}", null, p, ct);
+ return (true, "Workspace items copied into the branch; nothing in the workspace changed");
+ });
+ }
+ else if (j.RemoteCommitHash is { } remote && (j.RequiredAction == "UpdateFromGit" || preview.Changes.Count > 0))
+ {
+ j.ContentChanged = true;
+ j.Advance(RepointStep.Updating, $"updating to {remote}", journalDir);
+ await Step(progress, "update", $"Update the workspace from {j.Target}", async () =>
+ {
+ var p = new Progress(pc => progress.Report(new("update", $"Update the workspace from {j.Target}", StepState.Active, $"{pc}%", pc)));
+ await fabric.GitUpdateAsync(j.WorkspaceId, remote, j.TargetWorkspaceHead, "PreferRemote", p, ct);
+ return (true, "Done");
+ });
+ }
+ await Step(progress, "verify", "Verify the workspace matches the branch", async () =>
+ {
+ var s = await fabric.GitStatusAsync(j.WorkspaceId, ct);
+ if (s.Incoming > 0 || s.Conflicts > 0)
+ throw new InvalidOperationException($"{s.Incoming} incoming change(s) and {s.Conflicts} conflict(s) remain. Run Update from Git, or restore from {j.BackupPath}.");
+ return (true, s.Uncommitted == 0 ? "In sync" : $"{s.Uncommitted} workspace-only change(s) remain");
+ });
+ await RelinkAsync(j, progress, ct);
+ j.Advance(RepointStep.Done, $"now on {j.Target}", journalDir);
+ j.Delete(journalDir);
+ }
+
+ /// Puts the workspace back on its original branch. Safe to call from any journal step, including after a crash.
+ public async Task RestoreAsync(RepointJournal j, IProgress progress, CancellationToken ct = default)
+ {
+ var contentChanged = j.ContentChanged;
+ j.Advance(RepointStep.RollingBack, $"back to {j.Original.Branch}", journalDir);
+
+ var current = await fabric.GitConnectionAsync(j.WorkspaceId, ct);
+ var onOriginal = current.Provider is { } p && p.FamilyKey == j.Original.FamilyKey && p.Branch == j.Original.Branch;
+ if (!onOriginal)
+ {
+ if (current.State != GitState.NotConnected)
+ await Step(progress, "r-disconnect", $"Disconnect from {current.Provider?.Branch ?? j.Target}", async () =>
+ {
+ await fabric.GitDisconnectAsync(j.WorkspaceId, ct);
+ return (true, "");
+ });
+ await Step(progress, "r-connect", $"Reconnect to {j.Original.Branch}", async () =>
+ {
+ await fabric.GitConnectAsync(j.WorkspaceId, j.Original, j.Credentials, ct);
+ return (true, j.Original.Repo);
+ });
+ }
+ if (onOriginal && current.State == GitState.Initialized && !contentChanged)
+ {
+ progress.Report(new("r-init", "Initialise the connection", StepState.Skipped, "Already on the original branch"));
+ }
+ else
+ {
+ // PreferWorkspace: initialising never pulls remote content over the workspace.
+ var init = await Step(progress, "r-init", "Initialise the connection", async () =>
+ {
+ var r = await fabric.GitInitializeAsync(j.WorkspaceId, "PreferWorkspace", ct);
+ return (r, $"Fabric says next: {r.RequiredAction}");
+ });
+ if (contentChanged && init.RemoteCommitHash is { } hash)
+ {
+ // The same rule as going forward: never delete a data-bearing item, even to restore.
+ var blocks = await Step(progress, "r-check", $"Check what restoring {j.Original.Branch} would change", async () =>
+ {
+ var b = Git.ClassifySwitch(await fabric.GitStatusAsync(j.WorkspaceId, ct)).Where(c => c.Block is not null).ToList();
+ return (b, b.Count == 0 ? "Safe to restore" : $"{b.Count} item(s) would lose data");
+ });
+ if (blocks.Count > 0)
+ {
+ j.Advance(RepointStep.RollingBack, "reconnected; content restore blocked", journalDir);
+ throw new InvalidOperationException($"Reconnected to {j.Original.Branch}, but restoring its content would delete: " +
+ string.Join(" ", blocks.Select(b => b.Block)) + $" Move that data first, then run Update from Git. The backup is at {j.BackupPath}.");
+ }
+ await Step(progress, "r-update", $"Restore content from {j.Original.Branch}", async () =>
+ {
+ var pr = new Progress(pc => progress.Report(new("r-update", $"Restore content from {j.Original.Branch}", StepState.Active, $"{pc}%", pc)));
+ await fabric.GitUpdateAsync(j.WorkspaceId, hash, init.WorkspaceHead, "PreferRemote", pr, ct);
+ return (true, "Workspace matches the original branch again");
+ });
+ }
+ }
+ await RelinkAsync(j, progress, ct);
+ j.Advance(RepointStep.RolledBack, $"on {j.Original.Branch}", journalDir);
+ j.Delete(journalDir);
+ }
+
+ /// Disconnecting drops branch-workspace links; put back the ones recorded before. Failures are reported, not fatal.
+ async Task RelinkAsync(RepointJournal j, IProgress progress, CancellationToken ct)
+ {
+ if (j.Relations.Count == 0) return;
+ await Step(progress, "relink", "Restore branch-workspace links", async () =>
+ {
+ var existing = await fabric.WorkspaceRelationsAsync(j.WorkspaceId, ct).ContinueWith(t => t.IsCompletedSuccessfully ? t.Result : [], ct);
+ var failed = new List();
+ foreach (var r in j.Relations.Where(r => r.Type is "Base" or "Branch" && !existing.Any(e => e.RelatedWorkspaceId == r.RelatedWorkspaceId)))
+ {
+ try { await fabric.CreateWorkspaceRelationAsync(j.WorkspaceId, r.RelatedWorkspaceId, r.Type, ct); }
+ catch (FabricException e) { failed.Add($"{r.Type} {r.RelatedWorkspaceId}: {e.Message}"); }
+ }
+ return (true, failed.Count == 0 ? "Restored" : "Not restored: " + string.Join("; ", failed));
+ });
+ }
+
+ static async Task Step(IProgress progress, string key, string title, Func> run)
+ {
+ progress.Report(new(key, title, StepState.Active));
+ try
+ {
+ var (result, detail) = await run();
+ progress.Report(new(key, title, StepState.Done, detail));
+ return result;
+ }
+ catch (Exception e)
+ {
+ progress.Report(new(key, title, StepState.Failed, e.Message));
+ throw;
+ }
+ }
+}
diff --git a/src/Daxis.Core/SourceControl.cs b/src/Daxis.Core/SourceControl.cs
new file mode 100644
index 0000000..ed553de
--- /dev/null
+++ b/src/Daxis.Core/SourceControl.cs
@@ -0,0 +1,350 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+using System.Text.RegularExpressions;
+
+namespace Daxis.Core;
+
+public enum GitState { NotConnected, Connected, Initialized }
+
+/// Where a workspace points: provider, repo, folder and branch.
+public sealed record GitProvider(string Type, string? Organization, string? Project, string? Owner, string Repository,
+ string Branch, string Directory, string? CustomDomain = null)
+{
+ public bool IsGitHub => Type == "GitHub";
+ public string Account => IsGitHub ? Owner ?? "" : $"{Organization}/{Project}";
+ public string Repo => $"{Account}/{Repository}";
+ public string Folder => "/" + Directory.Trim('/');
+ /// Same repo + folder: workspaces in one family differ only by branch.
+ public string FamilyKey => $"{Type}|{Account}|{Repository}|{Folder}".ToLowerInvariant();
+
+ public JsonObject ToJson()
+ {
+ var o = new JsonObject { ["gitProviderType"] = Type, ["repositoryName"] = Repository, ["branchName"] = Branch, ["directoryName"] = Directory };
+ if (IsGitHub)
+ {
+ o["ownerName"] = Owner;
+ if (CustomDomain is not null) o["customDomainName"] = CustomDomain;
+ }
+ else
+ {
+ o["organizationName"] = Organization;
+ o["projectName"] = Project;
+ }
+ return o;
+ }
+}
+
+public sealed record GitConnection(GitState State, GitProvider? Provider, string? Head, DateTimeOffset? LastSync, bool Selective = false);
+
+/// One item's difference between workspace and branch. Change values: Added, Modified, Deleted (or null).
+public sealed record GitChange(string? ObjectId, string? LogicalId, string Type, string Name, string? WorkspaceChange, string? RemoteChange, string Conflict)
+{
+ public bool IsConflict => Conflict == "Conflict";
+}
+
+public sealed record GitStatus(string? WorkspaceHead, string? RemoteCommitHash, IReadOnlyList Changes)
+{
+ public int Uncommitted => Changes.Count(c => c.WorkspaceChange is not null && !c.IsConflict);
+ public int Incoming => Changes.Count(c => c.RemoteChange is not null && !c.IsConflict);
+ public int Conflicts => Changes.Count(c => c.IsConflict);
+ public bool Clean => Changes.Count == 0;
+}
+
+/// Source: Automatic (Azure DevOps SSO), ConfiguredConnection (a stored connection) or None.
+public sealed record GitCredentials(string Source, string? ConnectionId = null)
+{
+ public bool IsConfigured => Source is "Automatic" or "ConfiguredConnection";
+ public JsonObject ToJson() => ConnectionId is null
+ ? new JsonObject { ["source"] = Source }
+ : new JsonObject { ["source"] = Source, ["connectionId"] = ConnectionId };
+}
+
+public sealed record InitResult(string RequiredAction, string? WorkspaceHead, string? RemoteCommitHash);
+
+public sealed record WorkspaceRelation(string Id, string WorkspaceId, string RelatedWorkspaceId, string Type);
+
+public enum Freshness { Unknown, Fresh, Stale, Critical }
+
+/// A repo + folder and the workspaces connected to it, one per branch (ideally).
+public sealed record GitFamily(string Key, string Label, IReadOnlyList Members);
+public sealed record FamilyMember(string WorkspaceId, string Branch, bool SharesBranch);
+
+public enum Impact { Create, Overwrite, Delete }
+
+/// What a branch switch would do to one item, and whether that is allowed.
+public sealed record PlannedChange(string Name, string Type, Impact Impact, bool DataBearing, string? Block, string? Warning);
+
+public sealed record Check(string Title, bool Ok, string Detail);
+
+public static class Git
+{
+ // ── Parsing ──────────────────────────────────────────────────────────────
+
+ public static GitConnection ParseConnection(JsonNode n)
+ {
+ var state = S(n, "gitConnectionState") switch
+ {
+ "ConnectedAndInitialized" => GitState.Initialized,
+ "Connected" => GitState.Connected,
+ _ => GitState.NotConnected,
+ };
+ var p = n["gitProviderDetails"];
+ var provider = p is null ? null : new GitProvider(S(p, "gitProviderType") ?? "", S(p, "organizationName"), S(p, "projectName"),
+ S(p, "ownerName"), S(p, "repositoryName") ?? "", S(p, "branchName") ?? "", S(p, "directoryName") ?? "/", S(p, "customDomainName"));
+ var sync = n["gitSyncDetails"];
+ DateTimeOffset? last = DateTimeOffset.TryParse(sync is null ? null : S(sync, "lastSyncTime"), out var t) ? t : null;
+ return new GitConnection(state, provider, sync is null ? null : S(sync, "head"), last, S(n, "gitConnectionType") == "Selective");
+ }
+
+ public static GitStatus ParseStatus(JsonNode n) => new(S(n, "workspaceHead"), S(n, "remoteCommitHash"),
+ (n["changes"]?.AsArray() ?? []).Select(c =>
+ {
+ var m = c!["itemMetadata"];
+ var id = m?["itemIdentifier"];
+ return new GitChange(id is null ? null : S(id, "objectId"), id is null ? null : S(id, "logicalId"),
+ m is null ? "" : S(m, "itemType") ?? "", m is null ? "" : S(m, "displayName") ?? "",
+ S(c, "workspaceChange"), S(c, "remoteChange"), S(c, "conflictType") ?? "None");
+ }).ToList());
+
+ public static GitCredentials ParseCredentials(JsonNode n) => new(S(n, "source") ?? "None", S(n, "connectionId"));
+
+ public static InitResult ParseInit(JsonNode n) => new(S(n, "requiredAction") ?? "None", S(n, "workspaceHead"), S(n, "remoteCommitHash"));
+
+ public static WorkspaceRelation ParseRelation(JsonNode n) =>
+ new(S(n, "id") ?? "", S(n, "workspaceId") ?? "", S(n, "relatedWorkspaceId") ?? "", S(n, "relationType") ?? "");
+
+ internal static JsonObject Identifier(GitChange c)
+ {
+ var o = new JsonObject();
+ if (c.ObjectId is not null) o["objectId"] = c.ObjectId;
+ if (c.LogicalId is not null) o["logicalId"] = c.LogicalId;
+ return o;
+ }
+
+ static string? S(JsonNode n, string prop) => n[prop] is JsonValue v && v.TryGetValue(out var s) ? s : null;
+
+ // ── Scan ─────────────────────────────────────────────────────────────────
+
+ ///
+ /// Conflicts, a connection that was never initialised, or no sync within → Critical;
+ /// no sync within → Stale.
+ ///
+ public static Freshness FreshnessOf(GitConnection? c, GitStatus? s, DateTimeOffset now, int staleDays, int criticalDays)
+ {
+ if (c is null || c.State == GitState.NotConnected) return Freshness.Unknown;
+ if (c.State == GitState.Connected || c.LastSync is null || s?.Conflicts > 0) return Freshness.Critical;
+ var age = now - c.LastSync.Value;
+ return age.TotalDays > criticalDays ? Freshness.Critical : age.TotalDays > staleDays ? Freshness.Stale : Freshness.Fresh;
+ }
+
+ public static string Ago(DateTimeOffset? t, DateTimeOffset now)
+ {
+ if (t is null) return "Never";
+ var d = now - t.Value;
+ return d.TotalMinutes < 1 ? "Just now"
+ : d.TotalHours < 1 ? $"{(int)d.TotalMinutes} min ago"
+ : d.TotalDays < 1 ? $"{(int)d.TotalHours} h ago"
+ : d.TotalDays < 60 ? $"{(int)d.TotalDays} d ago"
+ : $"{(int)(d.TotalDays / 30)} months ago";
+ }
+
+ /// Groups connected workspaces by repo + folder. Two workspaces on the same branch there overwrite each other's commits.
+ public static List Families(IEnumerable<(string WorkspaceId, GitProvider Provider)> connected) =>
+ connected.GroupBy(c => c.Provider.FamilyKey)
+ .Select(g =>
+ {
+ var branches = g.GroupBy(m => m.Provider.Branch, StringComparer.OrdinalIgnoreCase).ToDictionary(b => b.Key, b => b.Count(), StringComparer.OrdinalIgnoreCase);
+ var first = g.First().Provider;
+ return new GitFamily(g.Key, $"{first.Repo} {first.Folder}",
+ g.OrderBy(m => m.Provider.Branch, StringComparer.OrdinalIgnoreCase)
+ .Select(m => new FamilyMember(m.WorkspaceId, m.Provider.Branch, branches[m.Provider.Branch] > 1)).ToList());
+ })
+ .OrderBy(f => f.Label, StringComparer.OrdinalIgnoreCase).ToList();
+
+ // ── Item types ───────────────────────────────────────────────────────────
+
+ ///
+ /// Item types Fabric Git integration tracks (learn.microsoft.com, Sept 2026). Anything else is ignored by Git and is
+ /// a candidate for offload. ponytail: static list; Microsoft adds types every few months — update it then.
+ ///
+ public static readonly HashSet Tracked = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "Environment", "GraphQLApi", "Lakehouse", "Notebook", "SparkJobDefinition", "UserDataFunction",
+ "MLExperiment", "MLModel", "DataAgent", "CopyJob", "Dataflow", "DataPipeline", "MirroredDatabase",
+ "MountedDataFactory", "SnowflakeDatabase", "ApacheAirflowJob", "DbtJob", "OperationsAgent", "Reflex",
+ "Eventhouse", "Eventstream", "KQLDatabase", "KQLQueryset", "KQLDashboard", "Map", "EventSchemaSet",
+ "DigitalTwinBuilder", "AnomalyDetector", "Warehouse", "MirroredAzureDatabricksCatalog", "MetricSet",
+ "OrgApp", "PaginatedReport", "Report", "SemanticModel", "SQLDatabase", "CosmosDBDatabase", "GraphModel",
+ "GraphQuerySet", "DeploymentPlan", "VariableLibrary", "Ontology", "Plan",
+ };
+
+ /// Created and owned by another item (a lakehouse's SQL endpoint); never offloaded on their own.
+ public static readonly HashSet SystemOwned = new(StringComparer.OrdinalIgnoreCase) { "SQLEndpoint", "MirroredWarehouse" };
+
+ ///
+ /// Items whose data lives in the item. Git only carries their definition, so deleting one deletes the data and a later
+ /// sync brings back an empty shell.
+ ///
+ public static readonly HashSet DataBearing = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "Lakehouse", "Warehouse", "SQLDatabase", "Eventhouse", "KQLDatabase", "MirroredDatabase", "CosmosDBDatabase",
+ "SnowflakeDatabase", "MirroredAzureDatabricksCatalog", "MLModel", "MLExperiment",
+ };
+
+ public static bool IsOffloadCandidate(FabricItem i) => !Tracked.Contains(i.Type) && !SystemOwned.Contains(i.Type);
+
+ // ── Repoint safety ───────────────────────────────────────────────────────
+
+ ///
+ /// Reads the status taken right after connecting to the target branch as the effect of the full update that follows.
+ /// Items listed only on the workspace side exist nowhere in the target branch, and the first update after connecting
+ /// overwrites the whole workspace, so they count as deletions. Items not listed are identical on both sides.
+ ///
+ public static List ClassifySwitch(GitStatus status) => status.Changes.Select(c =>
+ {
+ var impact = c.RemoteChange switch
+ {
+ "Added" => Impact.Create,
+ "Deleted" => Impact.Delete,
+ "Modified" => Impact.Overwrite,
+ _ => c.IsConflict ? Impact.Overwrite
+ : c.WorkspaceChange == "Added" ? Impact.Delete
+ : c.WorkspaceChange == "Deleted" ? Impact.Create
+ : Impact.Overwrite,
+ };
+ var data = DataBearing.Contains(c.Type);
+ string? block = data && impact == Impact.Delete
+ ? $"{c.Name} ({c.Type}) is not in the target branch. Switching would delete it and the data stored in it."
+ : null;
+ // Lakehouse tables and files are never touched by Git; other stores apply schema from Git, which can drop objects.
+ string? warn = data && impact == Impact.Overwrite && !c.Type.Equals("Lakehouse", StringComparison.OrdinalIgnoreCase)
+ ? $"{c.Name} ({c.Type}) gets the target branch's schema. Objects missing there may be dropped with their data."
+ : null;
+ return new PlannedChange(c.Name, c.Type, impact, data, block, warn);
+ }).OrderBy(p => p.Impact == Impact.Delete ? 0 : p.Impact == Impact.Overwrite ? 1 : 2).ThenBy(p => p.Name, StringComparer.OrdinalIgnoreCase).ToList();
+
+ ///
+ /// What a normal Update from Git would do: incoming changes, plus conflicts when the Git version wins. Workspace-only
+ /// changes stay as uncommitted. Removing a data-bearing item is blocked here too: do that deliberately in Fabric.
+ ///
+ public static List ClassifyUpdate(GitStatus status, string? conflictPolicy) => status.Changes
+ .Where(c => c.IsConflict ? conflictPolicy == "PreferRemote" : c.RemoteChange is not null)
+ .Select(c =>
+ {
+ var impact = c.RemoteChange switch { "Added" => Impact.Create, "Deleted" => Impact.Delete, _ => Impact.Overwrite };
+ var data = DataBearing.Contains(c.Type);
+ return new PlannedChange(c.Name, c.Type, impact, data,
+ data && impact == Impact.Delete ? $"{c.Name} ({c.Type}) was removed in Git. Updating would delete it and its data; delete it in Fabric yourself if that is intended." : null,
+ data && impact == Impact.Overwrite && !c.Type.Equals("Lakehouse", StringComparison.OrdinalIgnoreCase)
+ ? $"{c.Name} ({c.Type}) takes the schema from Git. Objects missing there may be dropped with their data." : null);
+ })
+ .OrderBy(p => p.Impact == Impact.Delete ? 0 : p.Impact == Impact.Overwrite ? 1 : 2).ThenBy(p => p.Name, StringComparer.OrdinalIgnoreCase).ToList();
+
+ // Conservative subset of git-check-ref-format.
+ static readonly Regex BranchName = new(@"^(?!/)(?!.*//)(?!.*\.\.)(?!.*@\{)(?!.*\.lock$)(?!.*[/.]$)[^\s~^:?*\[\\\x00-\x1f\x7f]{1,250}$");
+
+ public static bool IsValidBranch(string? name) => name is not null && BranchName.IsMatch(name);
+
+ /// Everything that must hold before a repoint touches the workspace. Any failure blocks it.
+ public static List Preflight(GitConnection conn, GitStatus? status, string? statusError, GitCredentials? creds,
+ IReadOnlyList items, string? target, string? backupFolder)
+ {
+ var dupes = items.Where(i => !SystemOwned.Contains(i.Type))
+ .GroupBy(i => (i.Type.ToLowerInvariant(), i.DisplayName.ToLowerInvariant())).Where(g => g.Count() > 1)
+ .Select(g => $"{g.First().DisplayName} ({g.First().Type})").ToList();
+ return
+ [
+ new("Workspace is connected and initialised", conn.State == GitState.Initialized && conn.Provider is not null,
+ conn.State == GitState.Initialized ? $"{conn.Provider?.Repo} · {conn.Provider?.Branch}" : "Connect and sync it in Fabric first."),
+ new("Your Git credentials are set up", creds?.IsConfigured == true && (conn.Provider?.IsGitHub != true || creds.ConnectionId is not null),
+ creds?.IsConfigured == true ? creds.Source : "Set up your Git account in the workspace's Source control panel."),
+ new("Nothing uncommitted", status is not null && status.Uncommitted == 0 && status.Conflicts == 0,
+ status is null ? $"Couldn't read Git status: {statusError}"
+ : status.Uncommitted + status.Conflicts == 0 ? "Every item matches a commit, so Git holds a copy of it."
+ : $"{status.Uncommitted} uncommitted change(s), {status.Conflicts} conflict(s). Commit them first or they are lost."),
+ new("Target branch is valid", IsValidBranch(target) && !string.Equals(target, conn.Provider?.Branch, StringComparison.Ordinal),
+ !IsValidBranch(target) ? "Enter an existing branch name." : target == conn.Provider?.Branch ? "That is the current branch." : target!),
+ new("No duplicate item names", dupes.Count == 0, dupes.Count == 0 ? "Git needs unique names per type." : string.Join(", ", dupes)),
+ new("Backup folder chosen", !string.IsNullOrWhiteSpace(backupFolder),
+ string.IsNullOrWhiteSpace(backupFolder) ? "Choose a local folder; every item is backed up there first." : backupFolder!),
+ ];
+ }
+}
+
+// ── Repoint journal ─────────────────────────────────────────────────────────
+
+public enum RepointStep { Started, BackedUp, Disconnected, Connected, Initialized, Previewed, Updating, Done, RollingBack, RolledBack }
+
+///
+/// Durable record of a repoint in progress. Written before every step that changes the service, so a crash or closed app
+/// can always put the workspace back on its original branch.
+///
+public sealed class RepointJournal
+{
+ public string WorkspaceId { get; set; } = "";
+ public string WorkspaceName { get; set; } = "";
+ public GitProvider Original { get; set; } = null!;
+ public string? OriginalHead { get; set; }
+ public GitCredentials Credentials { get; set; } = new("None");
+ public string Target { get; set; } = "";
+ public string? BackupPath { get; set; }
+ public List Relations { get; set; } = [];
+ public string? RemoteCommitHash { get; set; }
+ public string? TargetWorkspaceHead { get; set; }
+ public string? RequiredAction { get; set; }
+ /// Set before the update starts: from then on a restore must also pull the original content back.
+ public bool ContentChanged { get; set; }
+ public RepointStep Step { get; set; }
+ public DateTimeOffset StartedAt { get; set; } = DateTimeOffset.UtcNow;
+ public List Log { get; set; } = [];
+
+ static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web)
+ {
+ WriteIndented = true,
+ Converters = { new JsonStringEnumConverter() },
+ };
+
+ public static string Folder => Path.Combine(AppPaths.Data, "journal");
+ string FilePath(string dir) => Path.Combine(dir, WorkspaceId + ".json");
+
+ /// The workspace is (or may be) off its original branch: a restore is needed before it can be left alone.
+ [JsonIgnore]
+ public bool NeedsRecovery => Step is RepointStep.Disconnected or RepointStep.Connected or RepointStep.Initialized
+ or RepointStep.Previewed or RepointStep.Updating or RepointStep.RollingBack;
+
+ [JsonIgnore] public bool Finished => Step is RepointStep.Done or RepointStep.RolledBack;
+
+ public void Advance(RepointStep step, string note, string? dir = null)
+ {
+ Step = step;
+ Log.Add($"{DateTimeOffset.Now:HH:mm:ss} {step}: {note}");
+ Save(dir);
+ }
+
+ public void Save(string? dir = null)
+ {
+ dir ??= Folder;
+ Directory.CreateDirectory(dir);
+ var path = FilePath(dir);
+ var tmp = path + ".tmp";
+ File.WriteAllText(tmp, JsonSerializer.Serialize(this, Json));
+ File.Move(tmp, path, overwrite: true); // atomic replace: never a half-written journal
+ }
+
+ public void Delete(string? dir = null) => File.Delete(FilePath(dir ?? Folder));
+
+ public static RepointJournal? Load(string path)
+ {
+ try { return JsonSerializer.Deserialize(File.ReadAllText(path), Json); }
+ catch (Exception e) when (e is IOException or JsonException or UnauthorizedAccessException) { return null; }
+ }
+
+ public static List All(string? dir = null)
+ {
+ dir ??= Folder;
+ return Directory.Exists(dir)
+ ? Directory.GetFiles(dir, "*.json").Select(Load).OfType().ToList()
+ : [];
+ }
+}
diff --git a/tests/Daxis.Tests/SourceControlTests.cs b/tests/Daxis.Tests/SourceControlTests.cs
new file mode 100644
index 0000000..b0e093a
--- /dev/null
+++ b/tests/Daxis.Tests/SourceControlTests.cs
@@ -0,0 +1,337 @@
+using System.Text;
+using System.Text.Json.Nodes;
+using Daxis.Core;
+using Xunit;
+
+namespace Daxis.Tests;
+
+public sealed class GitParsingTests
+{
+ [Fact]
+ public void Connection_AzureDevOps_ParsesProviderAndSync()
+ {
+ var c = Git.ParseConnection(JsonNode.Parse("""
+ { "gitProviderDetails": { "organizationName": "org", "projectName": "proj", "gitProviderType": "AzureDevOps",
+ "repositoryName": "repo", "branchName": "main", "directoryName": "/fabric" },
+ "gitSyncDetails": { "head": "abc123", "lastSyncTime": "2026-09-20T10:00:00Z" },
+ "gitConnectionState": "ConnectedAndInitialized", "gitConnectionType": "Full" }
+ """)!);
+ Assert.Equal(GitState.Initialized, c.State);
+ Assert.Equal("org/proj/repo", c.Provider!.Repo);
+ Assert.Equal("main", c.Provider.Branch);
+ Assert.Equal("abc123", c.Head);
+ Assert.Equal(new DateTimeOffset(2026, 9, 20, 10, 0, 0, TimeSpan.Zero), c.LastSync);
+ }
+
+ [Fact]
+ public void Connection_NotConnected_HasNoProvider()
+ {
+ var c = Git.ParseConnection(JsonNode.Parse("""{ "gitProviderDetails": null, "gitSyncDetails": null, "gitConnectionState": "NotConnected" }""")!);
+ Assert.Equal(GitState.NotConnected, c.State);
+ Assert.Null(c.Provider);
+ Assert.Null(c.LastSync);
+ }
+
+ [Fact]
+ public void Status_CountsBySide()
+ {
+ var s = Git.ParseStatus(JsonNode.Parse("""
+ { "workspaceHead": "w1", "remoteCommitHash": "r1", "changes": [
+ { "itemMetadata": { "itemIdentifier": { "objectId": "o1", "logicalId": "l1" }, "itemType": "Report", "displayName": "A" },
+ "workspaceChange": "Modified", "conflictType": "None" },
+ { "itemMetadata": { "itemIdentifier": { "logicalId": "l2" }, "itemType": "Notebook", "displayName": "B" },
+ "remoteChange": "Added", "conflictType": "None" },
+ { "itemMetadata": { "itemIdentifier": { "objectId": "o3" }, "itemType": "SemanticModel", "displayName": "C" },
+ "workspaceChange": "Modified", "remoteChange": "Modified", "conflictType": "Conflict" } ] }
+ """)!);
+ Assert.Equal((1, 1, 1), (s.Uncommitted, s.Incoming, s.Conflicts));
+ Assert.Equal("l2", s.Changes[1].LogicalId);
+ Assert.Null(s.Changes[1].ObjectId);
+ }
+
+ [Fact]
+ public void Provider_ToJson_UsesTheProvidersOwnFields()
+ {
+ var gh = new GitProvider("GitHub", null, null, "me", "repo", "dev", "/");
+ var json = gh.ToJson();
+ Assert.Equal("me", json["ownerName"]!.GetValue());
+ Assert.False(json.ContainsKey("organizationName"));
+ Assert.False(json.ContainsKey("customDomainName"));
+ }
+}
+
+public sealed class GitScanTests
+{
+ static readonly DateTimeOffset Now = new(2026, 9, 30, 12, 0, 0, TimeSpan.Zero);
+ static readonly GitProvider P = new("AzureDevOps", "org", "proj", null, "repo", "main", "/");
+
+ static GitConnection Synced(double daysAgo) => new(GitState.Initialized, P, "h", Now.AddDays(-daysAgo));
+
+ [Theory]
+ [InlineData(1, Freshness.Fresh)]
+ [InlineData(8, Freshness.Stale)]
+ [InlineData(31, Freshness.Critical)]
+ public void Freshness_ByAge(double days, Freshness expected) => Assert.Equal(expected, Git.FreshnessOf(Synced(days), null, Now, 7, 30));
+
+ [Fact]
+ public void Freshness_ConflictsOrUninitialised_AreCritical()
+ {
+ var conflict = new GitStatus("a", "b", [new GitChange("o", "l", "Report", "R", "Modified", "Modified", "Conflict")]);
+ Assert.Equal(Freshness.Critical, Git.FreshnessOf(Synced(0), conflict, Now, 7, 30));
+ Assert.Equal(Freshness.Critical, Git.FreshnessOf(new GitConnection(GitState.Connected, P, null, null), null, Now, 7, 30));
+ Assert.Equal(Freshness.Unknown, Git.FreshnessOf(new GitConnection(GitState.NotConnected, null, null, null), null, Now, 7, 30));
+ }
+
+ [Fact]
+ public void Families_GroupByRepoAndFolder_FlagSharedBranch()
+ {
+ var fam = Git.Families(
+ [
+ ("ws1", P),
+ ("ws2", P with { Branch = "feature/x" }),
+ ("ws3", P with { Branch = "MAIN" }), // same branch as ws1, different case
+ ("ws4", P with { Directory = "/other" }), // different folder → own family
+ ]);
+ Assert.Equal(2, fam.Count);
+ var main = fam.Single(f => f.Members.Count == 3);
+ Assert.True(main.Members.Single(m => m.WorkspaceId == "ws1").SharesBranch);
+ Assert.True(main.Members.Single(m => m.WorkspaceId == "ws3").SharesBranch);
+ Assert.False(main.Members.Single(m => m.WorkspaceId == "ws2").SharesBranch);
+ }
+
+ [Theory]
+ [InlineData("Just now", 0)]
+ [InlineData("3 h ago", 3 * 60)]
+ [InlineData("2 d ago", 2 * 24 * 60)]
+ public void Ago_Formats(string expected, int minutes) => Assert.Equal(expected, Git.Ago(Now.AddMinutes(-minutes), Now));
+
+ [Fact]
+ public void OffloadCandidates_ExcludeTrackedAndSystemItems()
+ {
+ FabricItem I(string type) => new("id", "n", type, null, "ws");
+ Assert.True(Git.IsOffloadCandidate(I("Dashboard")));
+ Assert.True(Git.IsOffloadCandidate(I("Datamart")));
+ Assert.False(Git.IsOffloadCandidate(I("Report")));
+ Assert.False(Git.IsOffloadCandidate(I("SQLEndpoint")));
+ }
+}
+
+public sealed class RepointSafetyTests
+{
+ static GitChange C(string type, string name, string? ws, string? remote, string conflict = "None") => new("o-" + name, "l-" + name, type, name, ws, remote, conflict);
+
+ [Fact]
+ public void ClassifySwitch_WorkspaceOnlyItems_AreDeletions()
+ {
+ var plan = Git.ClassifySwitch(new GitStatus(null, "r", [C("Report", "OnlyHere", "Added", null)]));
+ Assert.Equal(Impact.Delete, Assert.Single(plan).Impact);
+ }
+
+ [Fact]
+ public void ClassifySwitch_DeletingDataItem_IsBlocked()
+ {
+ var plan = Git.ClassifySwitch(new GitStatus(null, "r",
+ [
+ C("Lakehouse", "Sales LH", "Added", null),
+ C("Warehouse", "DW", null, "Deleted"),
+ C("Notebook", "Nb", null, "Deleted"),
+ ]));
+ Assert.Equal(2, plan.Count(p => p.Block is not null));
+ Assert.Null(plan.Single(p => p.Name == "Nb").Block);
+ }
+
+ [Fact]
+ public void ClassifySwitch_OverwritingWarehouse_Warns_ButLakehouseDoesNot()
+ {
+ var plan = Git.ClassifySwitch(new GitStatus(null, "r",
+ [
+ C("Warehouse", "DW", null, "Modified"),
+ C("Lakehouse", "LH", "Modified", "Modified", "Conflict"),
+ C("Notebook", "New", null, "Added"),
+ ]));
+ Assert.NotNull(plan.Single(p => p.Name == "DW").Warning);
+ Assert.Null(plan.Single(p => p.Name == "LH").Warning);
+ Assert.Equal(Impact.Create, plan.Single(p => p.Name == "New").Impact);
+ Assert.Equal("DW", plan[0].Name); // destructive first, creations last
+ }
+
+ [Fact]
+ public void Preview_BlocksChangesToItemsWithoutBackup()
+ {
+ var journal = new RepointJournal { WorkspaceId = "ws", Original = new("GitHub", null, null, "o", "r", "main", "/") };
+ var backup = new OffloadManifest(DateTimeOffset.UtcNow, "ws", "WS", "Backup",
+ [
+ new("1", "Report", "Kept", "Kept.Report", Offload.Definition),
+ new("2", "Report", "Lost", "Lost.Report", Offload.Failed, "403"),
+ new("3", "Notebook", "Descriptor", "Descriptor.Notebook", Offload.Metadata),
+ ]);
+ var changes = Git.ClassifySwitch(new GitStatus(null, "r",
+ [
+ C("Report", "Kept", null, "Modified"),
+ C("Report", "Lost", null, "Modified"),
+ C("Report", "Brand new", null, "Added"),
+ C("Notebook", "Descriptor", "Added", null),
+ ]));
+ var preview = new RepointPreview(journal, changes, backup);
+ Assert.Contains(preview.Blocks, b => b.StartsWith("Lost"));
+ Assert.Contains(preview.Blocks, b => b.StartsWith("Descriptor")); // metadata only is not a backup
+ Assert.Equal(2, preview.Blocks.Count);
+ }
+
+ [Fact]
+ public void ClassifyUpdate_IgnoresWorkspaceOnlyChanges_AndBlocksDataDeletes()
+ {
+ var status = new GitStatus("w", "r",
+ [
+ C("Report", "Mine", "Modified", null),
+ C("Report", "Theirs", null, "Modified"),
+ C("Warehouse", "DW", null, "Deleted"),
+ C("Notebook", "Both", "Modified", "Modified", "Conflict"),
+ ]);
+ var keepMine = Git.ClassifyUpdate(status, "PreferWorkspace");
+ Assert.DoesNotContain(keepMine, p => p.Name is "Mine" or "Both");
+ Assert.NotNull(keepMine.Single(p => p.Name == "DW").Block);
+ Assert.Contains(Git.ClassifyUpdate(status, "PreferRemote"), p => p.Name == "Both" && p.Impact == Impact.Overwrite);
+ }
+
+ [Fact]
+ public void Signature_IgnoresOrder_ButSeesNewItems()
+ {
+ var a = Git.ClassifySwitch(new GitStatus(null, "r", [C("Report", "A", null, "Modified"), C("Notebook", "B", null, "Added")]));
+ var b = Git.ClassifySwitch(new GitStatus(null, "r", [C("Notebook", "B", null, "Added"), C("Report", "A", null, "Modified")]));
+ var c = Git.ClassifySwitch(new GitStatus(null, "r", [C("Report", "A", null, "Modified"), C("Notebook", "B", null, "Added"), C("Lakehouse", "New", "Added", null)]));
+ Assert.Equal(RepointPreview.Signature(a), RepointPreview.Signature(b));
+ Assert.NotEqual(RepointPreview.Signature(a), RepointPreview.Signature(c));
+ }
+
+ [Theory]
+ [InlineData("feature/new-thing", true)]
+ [InlineData("main", true)]
+ [InlineData("has space", false)]
+ [InlineData("a..b", false)]
+ [InlineData("ends/", false)]
+ [InlineData("x.lock", false)]
+ [InlineData("", false)]
+ public void BranchNames(string name, bool ok) => Assert.Equal(ok, Git.IsValidBranch(name));
+
+ [Fact]
+ public void Preflight_UncommittedWork_Blocks()
+ {
+ var conn = new GitConnection(GitState.Initialized, new("AzureDevOps", "o", "p", null, "r", "main", "/"), "h", DateTimeOffset.UtcNow);
+ var status = new GitStatus("h", "h", [C("Report", "R", "Modified", null)]);
+ var checks = Git.Preflight(conn, status, null, new GitCredentials("Automatic"), [], "dev", "C:/backup");
+ Assert.False(checks.Single(c => c.Title == "Nothing uncommitted").Ok);
+ Assert.All(checks.Where(c => c.Title != "Nothing uncommitted"), c => Assert.True(c.Ok, c.Title));
+ }
+
+ [Fact]
+ public void Preflight_GitHubNeedsAConfiguredConnection_AndSameBranchIsRejected()
+ {
+ var conn = new GitConnection(GitState.Initialized, new("GitHub", null, null, "o", "r", "main", "/"), "h", DateTimeOffset.UtcNow);
+ var checks = Git.Preflight(conn, new GitStatus("h", "h", []), null, new GitCredentials("Automatic"),
+ [new("1", "A", "Report", null, "ws"), new("2", "a", "Report", null, "ws")], "main", null);
+ Assert.False(checks.Single(c => c.Title.StartsWith("Your Git")).Ok);
+ Assert.False(checks.Single(c => c.Title.StartsWith("Target")).Ok);
+ Assert.False(checks.Single(c => c.Title.StartsWith("No duplicate")).Ok);
+ Assert.False(checks.Single(c => c.Title.StartsWith("Backup")).Ok);
+ }
+
+ [Fact]
+ public void Journal_RoundTrips_AndReportsRecovery()
+ {
+ var dir = Path.Combine(Path.GetTempPath(), "daxis-journal-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ var j = new RepointJournal
+ {
+ WorkspaceId = "ws1", WorkspaceName = "Sales", Original = new("GitHub", null, null, "o", "r", "main", "/fabric"),
+ Credentials = new("ConfiguredConnection", "c1"), Target = "dev",
+ Relations = [new("rel", "ws1", "ws0", "Base")],
+ };
+ j.Advance(RepointStep.Disconnected, "test", dir);
+ var back = Assert.Single(RepointJournal.All(dir));
+ Assert.True(back.NeedsRecovery);
+ Assert.Equal("main", back.Original.Branch);
+ Assert.Equal("c1", back.Credentials.ConnectionId);
+ Assert.Equal("ws0", Assert.Single(back.Relations).RelatedWorkspaceId);
+ back.Advance(RepointStep.RolledBack, "done", dir);
+ Assert.False(RepointJournal.All(dir).Single().NeedsRecovery);
+ back.Delete(dir);
+ Assert.Empty(RepointJournal.All(dir));
+ }
+ finally { if (Directory.Exists(dir)) Directory.Delete(dir, true); }
+ }
+}
+
+public sealed class OffloadTests
+{
+ [Fact]
+ public void FolderNames_DisambiguateCaseInsensitiveCollisions()
+ {
+ var names = Offload.FolderNames(
+ [
+ new("11111111-aaaa", "Sales", "Report", null, "ws"),
+ new("22222222-bbbb", "sales", "Report", null, "ws"),
+ new("33333333-cccc", "Sales", "SemanticModel", null, "ws"),
+ new("44444444-dddd", "Ops: Q1 [Dev}?", "Dashboard", null, "ws"),
+ ]);
+ Assert.Equal("Sales (11111111).Report", names["11111111-aaaa"]);
+ Assert.Equal("sales (22222222).Report", names["22222222-bbbb"]);
+ Assert.Equal("Sales.SemanticModel", names["33333333-cccc"]);
+ Assert.Equal("Ops_ Q1 [Dev}_.Dashboard", names["44444444-dddd"]);
+ }
+
+ [Theory]
+ [InlineData("../evil.txt")]
+ [InlineData("a/../../evil.txt")]
+ [InlineData("")]
+ public void SafeCombine_RefusesEscapes(string rel) =>
+ Assert.Throws(() => Offload.SafeCombine(Path.GetTempPath(), rel));
+
+ [Fact]
+ public void WriteItem_KeepsBinaryBytes_AndReplacesWholesale()
+ {
+ var dir = Path.Combine(Path.GetTempPath(), "daxis-offload-" + Guid.NewGuid().ToString("N"), "R.Report");
+ try
+ {
+ var png = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x00, 0xFF, 0xFE };
+ Offload.WriteItem(dir, [new ItemFile("StaticResources/logo.png", png), new ItemFile("old.json", "{}"u8.ToArray())]);
+ Assert.Equal(png, File.ReadAllBytes(Path.Combine(dir, "StaticResources", "logo.png")));
+
+ Offload.WriteItem(dir, [new ItemFile("definition.pbir", Encoding.UTF8.GetBytes("{\"version\":\"4.0\"}"))]);
+ Assert.False(File.Exists(Path.Combine(dir, "old.json"))); // mirror, not accumulate
+ Assert.True(File.Exists(Path.Combine(dir, "definition.pbir")));
+ Assert.Single(Directory.GetDirectories(Path.GetDirectoryName(dir)!)); // no temp folders left behind
+ }
+ finally { Directory.Delete(Path.GetDirectoryName(dir)!, true); }
+ }
+
+ [Fact]
+ public void WriteItem_FailedWrite_KeepsPreviousCopy()
+ {
+ var dir = Path.Combine(Path.GetTempPath(), "daxis-offload-" + Guid.NewGuid().ToString("N"), "R.Report");
+ try
+ {
+ Offload.WriteItem(dir, [new ItemFile("good.json", "{}"u8.ToArray())]);
+ Assert.ThrowsAny(() => Offload.WriteItem(dir, [new ItemFile("x.json", []), new ItemFile("../escape", [])]));
+ Assert.True(File.Exists(Path.Combine(dir, "good.json")));
+ Assert.Single(Directory.GetDirectories(Path.GetDirectoryName(dir)!));
+ }
+ finally { Directory.Delete(Path.GetDirectoryName(dir)!, true); }
+ }
+
+ [Fact]
+ public void Manifest_RoundTrips()
+ {
+ var dir = Path.Combine(Path.GetTempPath(), "daxis-manifest-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ Offload.WriteManifest(dir, new(DateTimeOffset.UtcNow, "ws", "WS", "Offload", [new("1", "Dashboard", "D", "D.Dashboard", Offload.Metadata, "note")]));
+ var m = Offload.ReadManifest(dir)!;
+ Assert.Equal(1, m.Saved);
+ Assert.Equal(Offload.Metadata, m.Items[0].Method);
+ Assert.Null(Offload.ReadManifest(Path.Combine(dir, "missing")));
+ }
+ finally { Directory.Delete(dir, true); }
+ }
+}