From 4a477c33b92f629bf6dfe8ee1bf00411b6232074 Mon Sep 17 00:00:00 2001 From: AFetisa <54423072+AFetisa@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:10:48 +1000 Subject: [PATCH 1/5] feat(core): Fabric Git integration client, offload engine and journalled repoint - FabricClient: Git endpoints (connection, status, commit, update, connect, disconnect, initialize, credentials, workspace relations), raw-byte definitions, PBIX export, LRO percentComplete, service error codes - Offload: Fabric-Git folder layout, atomic per-item writes, path-traversal guard, manifest; PBIX / metadata fallbacks - SourceControl: parsing, freshness, branch families, switch/update impact classification with data-bearing item blocks, preflight, repoint journal - Repointer: backup -> disconnect -> connect -> initialize -> preview -> apply/restore, auto-restore on failure, relation re-link Co-Authored-By: Claude Opus 5.5 --- docs/plans/fabric-git-sync.md | 107 ++++++++ src/Daxis.Core/FabricClient.cs | 50 +++- src/Daxis.Core/FabricGit.cs | 82 ++++++ src/Daxis.Core/Offload.cs | 192 +++++++++++++ src/Daxis.Core/Repoint.cs | 231 ++++++++++++++++ src/Daxis.Core/SourceControl.cs | 350 ++++++++++++++++++++++++ tests/Daxis.Tests/SourceControlTests.cs | 324 ++++++++++++++++++++++ 7 files changed, 1331 insertions(+), 5 deletions(-) create mode 100644 docs/plans/fabric-git-sync.md create mode 100644 src/Daxis.Core/FabricGit.cs create mode 100644 src/Daxis.Core/Offload.cs create mode 100644 src/Daxis.Core/Repoint.cs create mode 100644 src/Daxis.Core/SourceControl.cs create mode 100644 tests/Daxis.Tests/SourceControlTests.cs diff --git a/docs/plans/fabric-git-sync.md b/docs/plans/fabric-git-sync.md new file mode 100644 index 0000000..b8ff1f2 --- /dev/null +++ b/docs/plans/fabric-git-sync.md @@ -0,0 +1,107 @@ +# Source Control — Fabric Git integration in Daxis + +Status: planned, 2026-09-30. 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 (not AGL):** (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: the `Fabric_to_Git` folder holds AGL content. Nothing from it enters the Daxis repo; tests use synthetic fixtures. 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..f4abdff --- /dev/null +++ b/src/Daxis.Core/Offload.cs @@ -0,0 +1,192 @@ +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; +} + +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); + try { entries[i] = await ExportOneAsync(fabric, item, Path.Combine(dir, folders[item.Id]), folders[item.Id], ct); } + finally + { + gate.Release(); + progress?.Report(new OffloadProgress(Interlocked.Increment(ref done), items.Count, item.DisplayName)); + } + })); + 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 + { + var files = await fabric.GetDefinitionFilesAsync(item, item.Type == "SemanticModel" ? "TMDL" : null, ct); + 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); + 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); + 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..b51f581 --- /dev/null +++ b/src/Daxis.Core/Repoint.cs @@ -0,0 +1,231 @@ +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 local backup, so it can't be changed safely.")]; + 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.Ok && + 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); + + // 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; + 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) + 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..378ef3c --- /dev/null +++ b/tests/Daxis.Tests/SourceControlTests.cs @@ -0,0 +1,324 @@ +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"), + ]); + var changes = Git.ClassifySwitch(new GitStatus(null, "r", + [ + C("Report", "Kept", null, "Modified"), + C("Report", "Lost", null, "Modified"), + C("Report", "Brand new", null, "Added"), + ])); + var preview = new RepointPreview(journal, changes, backup); + Assert.Contains(preview.Blocks, b => b.StartsWith("Lost")); + Assert.Single(preview.Blocks); + } + + [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); + } + + [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); } + } +} From 7a57bd33b8a65b03c982b0b3042ff5805900d2d3 Mon Sep 17 00:00:00 2001 From: AFetisa <54423072+AFetisa@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:30:22 +1000 Subject: [PATCH 2/5] =?UTF-8?q?feat(app):=20Source=20control=20tab=20?= =?UTF-8?q?=E2=80=94=20scan,=20freshness,=20commit/update,=20offload,=20sa?= =?UTF-8?q?fe=20repoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tenant-wide scan of Git connections grouped by repo + folder, branch workspace links, shared-branch warnings, stale / needs-attention flags - Update from Git and Commit to Git with change list, selective commit and explicit conflict policy; data-bearing deletions refused - Offload items Git can't track to a nominated folder (manifest, PBIX and metadata fallbacks), off the UI thread - Repoint wizard: preflight, backup, live re-check, preview, apply or restore; recovery banner for interrupted journals - Stepper with live percentages, skeleton rows, pulsing active step - Review fixes: re-verify status before applying a preview; metadata-only exports no longer count as a backup - Docs: README, CHANGELOG 0.2.0, CLAUDE.md scope, DESIGN.md components Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 + CLAUDE.md | 8 +- DESIGN.md | 6 +- README.md | 9 + docs/plans/fabric-git-sync.md | 2 +- src/Daxis.App/App.axaml.cs | 4 + src/Daxis.App/Conv.cs | 22 + src/Daxis.App/Icon.cs | 5 + src/Daxis.App/MainViewModel.cs | 17 + src/Daxis.App/MainWindow.axaml | 2 + src/Daxis.App/MainWindow.axaml.cs | 1 + src/Daxis.App/SourceControlTab.cs | 807 ++++++++++++++++++ src/Daxis.App/Theme/Styles.axaml | 77 ++ src/Daxis.App/Views/SourceControlView.axaml | 479 +++++++++++ .../Views/SourceControlView.axaml.cs | 42 + src/Daxis.Core/Offload.cs | 15 +- src/Daxis.Core/Repoint.cs | 46 +- tests/Daxis.Tests/SourceControlTests.cs | 15 +- 18 files changed, 1555 insertions(+), 12 deletions(-) create mode 100644 src/Daxis.App/SourceControlTab.cs create mode 100644 src/Daxis.App/Views/SourceControlView.axaml create mode 100644 src/Daxis.App/Views/SourceControlView.axaml.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index d442968..e48db14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.2.0 — unreleased + +**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; 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. + ## 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..7ca4843 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -76,12 +76,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/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 index b8ff1f2..32bd22f 100644 --- a/docs/plans/fabric-git-sync.md +++ b/docs/plans/fabric-git-sync.md @@ -1,6 +1,6 @@ # Source Control — Fabric Git integration in Daxis -Status: planned, 2026-09-30. Supersedes the first draft (local snapshot only). +Status: implemented on `feature/source-control` (2026-09-30), pending live testing. 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. diff --git a/src/Daxis.App/App.axaml.cs b/src/Daxis.App/App.axaml.cs index c7b9ca9..69e907f 100644 --- a/src/Daxis.App/App.axaml.cs +++ b/src/Daxis.App/App.axaml.cs @@ -25,6 +25,10 @@ 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; 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/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..b803094 100644 --- a/src/Daxis.App/MainWindow.axaml +++ b/src/Daxis.App/MainWindow.axaml @@ -107,6 +107,8 @@ + diff --git a/src/Daxis.App/MainWindow.axaml.cs b/src/Daxis.App/MainWindow.axaml.cs index 830e5ea..c094154 100644 --- a/src/Daxis.App/MainWindow.axaml.cs +++ b/src/Daxis.App/MainWindow.axaml.cs @@ -102,6 +102,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..8aee7f8 --- /dev/null +++ b/src/Daxis.App/SourceControlTab.cs @@ -0,0 +1,807 @@ +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; + } + + 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 = ""; + 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 => Selected?.Status is { Incoming: > 0 } || Selected?.Status is { Conflicts: > 0 }; + 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; + 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 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 = families + .Select(f => new GitGroup(f.Label, $"{f.Members.Count} workspace{(f.Members.Count > 1 ? "s" : "")}", + f.Members.Select(m => byId[m.WorkspaceId]).Where(Show).ToList())) + .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) 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..29462b1 100644 --- a/src/Daxis.App/Theme/Styles.axaml +++ b/src/Daxis.App/Theme/Styles.axaml @@ -167,6 +167,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -187,16 +191,16 @@ - - + - + @@ -206,7 +210,7 @@ - + @@ -219,7 +223,7 @@ - + @@ -272,11 +276,11 @@ - + - + - + @@ -311,9 +315,9 @@ - - + @@ -330,7 +334,7 @@ - + @@ -357,7 +361,7 @@ - @@ -369,7 +373,7 @@ MinimumPrefixLength="0" Watermark="Existing branch, e.g. feature/new-model" /> @@ -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 @@ - - - - - + + + @@ -482,7 +482,7 @@ - From 36258621259a4963fb98cc351ac7e1c4e8f26a8e Mon Sep 17 00:00:00 2001 From: AFetisa <54423072+AFetisa@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:31:18 +1000 Subject: [PATCH 5/5] =?UTF-8?q?release:=20v0.2.0=20=E2=80=94=20Source=20co?= =?UTF-8?q?ntrol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G8QHefm3VMWUxzdTtEohoa --- CHANGELOG.md | 9 +++++++-- Directory.Build.props | 2 ++ docs/plans/fabric-git-sync.md | 6 +++--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e48db14..9461e08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,20 @@ # Changelog -## 0.2.0 — unreleased +## 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; branch workspaces and their source; workspaces sharing a branch. +- 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/Directory.Build.props b/Directory.Build.props index e9fce48..f899d6a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,6 +4,8 @@ enable enable latest + + 0.2.0 true diff --git a/docs/plans/fabric-git-sync.md b/docs/plans/fabric-git-sync.md index 322b055..9d81643 100644 --- a/docs/plans/fabric-git-sync.md +++ b/docs/plans/fabric-git-sync.md @@ -1,6 +1,6 @@ # Source Control — Fabric Git integration in Daxis -Status: implemented on `feature/source-control` (2026-09-30), pending live testing. Supersedes the first draft (local snapshot only). +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. @@ -77,7 +77,7 @@ crash or closed app resumes or rolls back instead of leaving a disconnected work ## Phases | # | Scope | Risk | Est. | |---|---|---|---| -| 0 | **Spike on a sandbox tenant (not AGL):** (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 | +| 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 | @@ -104,7 +104,7 @@ pushes from the local machine and never deletes a data-bearing item."* Update RE | `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: the `Fabric_to_Git` folder holds AGL content. Nothing from it enters the Daxis repo; tests use synthetic fixtures. +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.