diff --git a/Tools/windows/Tests/Packaging.Tests.ps1 b/Tools/windows/Tests/Packaging.Tests.ps1 index c9e259b5..c04a5f27 100644 --- a/Tools/windows/Tests/Packaging.Tests.ps1 +++ b/Tools/windows/Tests/Packaging.Tests.ps1 @@ -72,7 +72,7 @@ try { & $zig0152 build ` "-Dwinghostty-dir=$wingRoot" ` "-Dwinghostty-lib=$(Join-Path $wingRoot 'zig-out\lib\winghostty-win32-host.lib')" ` - "-Dversion=1.2.3" -Doptimize=ReleaseSafe + "-Dversion=1.2.3" -Dworktrees-deferred=true -Doptimize=ReleaseSafe } finally { Pop-Location } if ($LASTEXITCODE -ne 0) { throw "could not build versioned GraphCode Windows artifact" } $fixtureBin = Join-Path $fixture "nested space\unicode-日本\bin" @@ -161,6 +161,7 @@ try { $metadata.sourceProvenance.tagMatchesSource -ne $true -or $metadata.sourceProvenance.tagMismatchAllowed -ne $false -or $metadata.packageKind -ne "release-candidate" -or + $metadata.previewFeatures.worktreesDeferred -ne $true -or $metadata.sourceProvenance.kind -ne "release-tag") { throw "package metadata did not preserve explicit source provenance: $($metadata | ConvertTo-Json -Compress)" } @@ -192,6 +193,7 @@ try { ConvertFrom-Json if ($localMetadata.version -ne $localVersion -or $localMetadata.packageKind -ne "local-development" -or + $localMetadata.previewFeatures.worktreesDeferred -ne $false -or $localMetadata.signing -notmatch "^UNSIGNED LOCAL DEVELOPMENT PACKAGE" -or $localMetadata.sourceProvenance.kind -ne "local" -or $localMetadata.sourceProvenance.sourceCommit -ne $localCommit -or diff --git a/Tools/windows/Tests/WorktreePreviewGuard.Tests.ps1 b/Tools/windows/Tests/WorktreePreviewGuard.Tests.ps1 new file mode 100644 index 00000000..38d32a90 --- /dev/null +++ b/Tools/windows/Tests/WorktreePreviewGuard.Tests.ps1 @@ -0,0 +1,116 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = "Stop" +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path +$appPath = Join-Path $repoRoot "graphcode-windows\src\App.zig" +$mainPath = Join-Path $repoRoot "graphcode-windows\src\main.zig" +$buildPath = Join-Path $repoRoot "graphcode-windows\build.zig" +$packagePath = Join-Path $repoRoot "Tools\windows\package.ps1" +$app = Get-Content -LiteralPath $appPath -Raw +$main = Get-Content -LiteralPath $mainPath -Raw +$build = Get-Content -LiteralPath $buildPath -Raw +$package = Get-Content -LiteralPath $packagePath -Raw +$checks = 0 + +function Require([bool] $condition, [string] $message) { + if (-not $condition) { throw $message } + $script:checks++ +} + +function Get-FunctionBody([string] $source, [string] $name) { + $match = [regex]::Match( + $source, + "(?m)^\s*(?:pub\s+)?fn\s+$([regex]::Escape($name))\s*\(" + ) + if (-not $match.Success) { throw "production function '$name' was not found" } + $next = [regex]::Match( + $source.Substring($match.Index + $match.Length), + "(?m)^\s*(?:pub\s+)?fn\s+[A-Za-z0-9_]+\s*\(" + ) + $end = if ($next.Success) { + $match.Index + $match.Length + $next.Index + } else { + $source.Length + } + return $source.Substring($match.Index, $end - $match.Index) +} + +$message = "Worktrees are deferred for this preview" +Require ($app.Contains($message)) ` + "RED: production Worktrees routes do not expose the required preview deferral message" + +$guardedFunctions = @( + "inspectWorktrees", + "inspectWorktreesImpl", + "presentWorktreeSweep", + "reclaimWorktrees", + "reclaimWorktreeOffer", + "keepWorktreeOffer", + "editWorktreePolicy", + "saveCurrentWorktreePolicy", + "toggleAllowReclaim", + "toggleConfirmReclaim", + "revealSelectedWorktree", + "selectWorktreeRow", + "toggleWorktreeRow", + "moveWorktreeSelection", + "applyUiaWorktreeSelection" +) +foreach ($name in $guardedFunctions) { + $body = Get-FunctionBody $app $name + Require ($body.Contains("guardWorktreesPreview")) ` + "RED: production Worktrees route '$name' bypasses the centralized preview guard" +} + +$routeContracts = @( + "if (action == .inspect_worktrees)", + ".inspect_project_worktrees => if (selected) self.inspectWorktrees()", + ".inspect_project_worktrees => if (graph.project.isLocalFilesystem()) self.inspectWorktrees()", + ".inspect_worktrees => self.inspectWorktrees()", + ".reclaim_worktrees => self.reclaimWorktrees()", + ".edit_worktree_policy => self.editWorktreePolicy()", + ".save_worktree_policy => self.saveCurrentWorktreePolicy()", + ".header_worktree => return self.invokeHeader(.inspect_worktrees)", + ".inspect_worktrees => self.inspectWorktrees(),", + "6 => app.inspectWorktrees()", + "7 => app.reclaimWorktrees()", + "12 => app.toggleAllowReclaim()", + "13 => app.toggleConfirmReclaim()", + ".reclaim => self.reclaimWorktreeOffer(offer.path)" +) +foreach ($route in $routeContracts) { + Require ($app.Contains($route)) ` + "production Worktrees route no longer funnels through the guarded action: '$route'" +} + +$inspection = Get-FunctionBody $app "inspectWorktreesImpl" +$guardIndex = $inspection.IndexOf("guardWorktreesPreview") +$inspectIndex = $inspection.IndexOf("WorktreeStatus.inspect") +Require ($guardIndex -ge 0 -and $inspectIndex -gt $guardIndex) ` + "RED: WorktreeStatus.inspect is reachable before the preview guard" +Require ($app.Contains("worktree_inspection_attempt_count")) ` + "RED: guarded-route inspection call count is not observable" +Require ($build -match 'b\.option\(\s*bool,\s*"worktrees-deferred"') ` + "RED: the Windows build has no explicit Worktrees preview option" +Require ($build.Contains('addOption(bool, "worktrees_deferred"')) ` + "RED: the Worktrees preview option is not compiled into the product" +Require ($package.Contains('"-Dworktrees-deferred=$worktreesDeferred"')) ` + "RED: release packaging does not pass the Worktrees preview option" +Require ($package.Contains("--worktrees-preview-state")) ` + "RED: packaging does not verify the built product's Worktrees preview state" +Require ($main.Contains('"--worktrees-preview-state"')) ` + "RED: the packaged product cannot report its compiled Worktrees preview state" + +foreach ($unchanged in @( + 'New Quick Chat\tCtrl+Q', + "Open Global Overview", + 'Jump to Loop...\tCtrl+J', + 'Settings...\tCtrl+Shift+,' + )) { + $mainWindow = Get-Content -LiteralPath (Join-Path $repoRoot "graphcode-windows\src\MainWindow.zig") -Raw + Require ($mainWindow.Contains($unchanged)) ` + "non-Worktrees menu contract changed: '$unchanged'" +} + +Write-Host "Worktree preview guard contracts: $checks passed" diff --git a/Tools/windows/package.ps1 b/Tools/windows/package.ps1 index 24d675c0..81fb430c 100644 --- a/Tools/windows/package.ps1 +++ b/Tools/windows/package.ps1 @@ -42,6 +42,7 @@ $required = @("graphcoded.exe", "graphcode.exe", "zmx.exe") $packageManifest = Get-Content (Join-Path $shellRoot "build.zig.zon") -Raw $localSourceCommit = $null $localSourceTreeDirty = $false +$worktreesDeferred = if ($Local) { "false" } else { "true" } if ($Local) { if ($Command -ne "Build") { throw "GraphCode packaging: -Local is only valid with -Command Build" @@ -76,6 +77,28 @@ function Resolve-Input([string] $path) { if (-not (Test-Path -LiteralPath $path -PathType Container)) { Fail "input directory does not exist: $path" } return (Resolve-Path -LiteralPath $path).Path } +function Invoke-ShellQuery([string] $root, [string] $argument) { + $stdout = Join-Path ([IO.Path]::GetTempPath()) "graphcode-shell-$([guid]::NewGuid()).out" + $stderr = Join-Path ([IO.Path]::GetTempPath()) "graphcode-shell-$([guid]::NewGuid()).err" + try { + $process = Start-Process -FilePath (Join-Path $root "bin\graphcode-windows.exe") ` + -ArgumentList $argument -Wait -PassThru ` + -RedirectStandardOutput $stdout -RedirectStandardError $stderr + $errorText = if (Test-Path -LiteralPath $stderr) { + $rawError = Get-Content -LiteralPath $stderr -Raw + if ($null -eq $rawError) { "" } else { $rawError.Trim() } + } else { + "" + } + Require ($process.ExitCode -eq 0) ` + "graphcode-windows.exe $argument failed with exit code $($process.ExitCode): $errorText" + $rawOutput = Get-Content -LiteralPath $stdout -Raw + Require ($null -ne $rawOutput) "graphcode-windows.exe $argument produced no output" + return $rawOutput.Trim() + } finally { + Remove-Item -LiteralPath $stdout, $stderr -Force -ErrorAction SilentlyContinue + } +} function Get-Manifest([string] $root) { @(Get-ChildItem -LiteralPath $root -File -Recurse -Force | Where-Object { @@ -122,6 +145,9 @@ function Write-Metadata([string] $root, [string] $version) { product = "GraphCode Windows" version = $version packageKind = if ($Local) { "local-development" } else { "release-candidate" } + previewFeatures = [ordered]@{ + worktreesDeferred = ($worktreesDeferred -eq "true") + } platform = "windows-x86_64" executables = [ordered]@{ shell = "bin/graphcode-windows.exe"; daemon = "bin/graphcoded.exe"; cli = "bin/graphcode.exe"; zmx = "bin/zmx.exe" } hostAssets = @(Get-ChildItem -LiteralPath (Join-Path $root "bin") -File -ErrorAction SilentlyContinue | @@ -261,6 +287,7 @@ function Build-Package { "-Dwinghostty-dir=$WinghosttyRoot" ` "-Dwinghostty-lib=$(Join-Path $WinghosttyRoot 'zig-out\lib\winghostty-win32-host.lib')" ` "-Dversion=$Version" ` + "-Dworktrees-deferred=$worktreesDeferred" ` -Doptimize=ReleaseSafe Require ($LASTEXITCODE -eq 0) "GraphCode Windows release build failed" } finally { Pop-Location } @@ -271,8 +298,12 @@ function Build-Package { foreach ($name in $required + "graphcode-windows.exe") { Require (Test-Path (Join-Path $root "bin\$name")) "$name was not found; pass -InputDirectory with release outputs" } - $reportedVersion = (& (Join-Path $root "bin\graphcode-windows.exe") --version 2>$null | Select-Object -First 1).Trim() + $reportedVersion = Invoke-ShellQuery $root "--version" Require ($reportedVersion -eq $Version) "graphcode-windows.exe reports $reportedVersion, expected $Version" + $reportedWorktreeState = Invoke-ShellQuery $root "--worktrees-preview-state" + $expectedWorktreeState = if ($worktreesDeferred -eq "true") { "deferred" } else { "available" } + Require ($reportedWorktreeState -eq $expectedWorktreeState) ` + "graphcode-windows.exe reports Worktrees $reportedWorktreeState, expected $expectedWorktreeState" Require (@(Get-ChildItem (Join-Path $root "bin") -Filter *.dll).Count -gt 0) "Swift runtime DLLs were not found" Copy-Item (Join-Path $repoRoot "LICENSE") (Join-Path $root "LICENSE") -Force Require ($WinghosttyRoot -and $ZmxRoot) "trusted provider roots are required for license attribution" @@ -345,6 +376,9 @@ switch ($Command) { $root = Open-Package $Package Verify-PackageContents $root | Out-Null $metadata = Get-Content -LiteralPath (Join-Path $root "metadata.json") -Raw | ConvertFrom-Json + $expectedWorktreesDeferred = [string] $metadata.packageKind -eq "release-candidate" + Require ([bool] $metadata.previewFeatures.worktreesDeferred -eq $expectedWorktreesDeferred) ` + "package metadata contradicts its Worktrees preview policy" if ([string] $metadata.packageKind -eq "local-development") { $provenance = $metadata.sourceProvenance $commit = [string] $provenance.sourceCommit diff --git a/graphcode-windows/build.zig b/graphcode-windows/build.zig index e8d9d06f..21c511ba 100644 --- a/graphcode-windows/build.zig +++ b/graphcode-windows/build.zig @@ -10,6 +10,11 @@ pub fn build(b: *std.Build) !void { }); const optimize = b.standardOptimizeOption(.{}); const package_version = b.option([]const u8, "version", "Packaged GraphCode release version") orelse "dev"; + const worktrees_deferred = b.option( + bool, + "worktrees-deferred", + "Defer all Worktrees actions with the Windows preview message", + ) orelse false; const winghostty_dir = b.option( []const u8, @@ -61,6 +66,7 @@ pub fn build(b: *std.Build) !void { module.addIncludePath(.{ .cwd_relative = winghostty_include }); const build_options = b.addOptions(); build_options.addOption([]const u8, "version", package_version); + build_options.addOption(bool, "worktrees_deferred", worktrees_deferred); module.addOptions("build_options", build_options); const exe = b.addExecutable(.{ @@ -78,7 +84,7 @@ pub fn build(b: *std.Build) !void { }); exe.addCSourceFile(.{ .file = b.path("src/AccessibilityProvider.cpp"), - .flags = &.{ "-Wno-unused-command-line-argument" }, + .flags = &.{"-Wno-unused-command-line-argument"}, }); exe.addObjectFile(.{ .cwd_relative = winghostty_lib }); exe.addObjectFile(vt_library); diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 7e50f08a..5bcf6021 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -1,5 +1,12 @@ const std = @import("std"); -const build_options = @import("build_options"); +const builtin = @import("builtin"); +const build_options = if (builtin.is_test) + struct { + pub const version = "dev"; + pub const worktrees_deferred = false; + } +else + @import("build_options"); const DaemonClient = @import("DaemonClient.zig").DaemonClient; const GraphCanvas = @import("GraphCanvas.zig"); const GdiplusAA = @import("GdiplusAA.zig"); @@ -52,6 +59,7 @@ const c = Win32.c; const title = std.unicode.utf8ToUtf16LeStringLiteral("GraphCode Windows"); const workspace_restart_message = "Workspace identity changed or could not be verified. Restart GraphCode before managing workspaces."; +const worktrees_deferred_message = "Worktrees are deferred for this preview"; const tray_test_hook_environment = "GRAPHCODE_TRAY_TEST_HOOK"; const daemon_supervisor_test_hook_environment = "GRAPHCODE_DAEMON_SUPERVISOR_TEST_HOOK"; const daemon_supervisor_test_property = @@ -124,6 +132,11 @@ fn workspaceInstanceKey(allocator: std.mem.Allocator, path: []const u8) ![:0]u16 return std.unicode.utf8ToUtf16LeAllocZ(allocator, name); } +fn compiledWorktreesDeferred() bool { + if (builtin.is_test) return false; + return build_options.worktrees_deferred; +} + pub fn restoreCurrentWorkspace(allocator: std.mem.Allocator) !void { const path = try WorkspaceLifecycle.currentPath(allocator); defer allocator.free(path); @@ -1014,6 +1027,8 @@ pub const App = struct { edge_drag_source_id: []u8 = &.{}, selection_initialized: bool = false, worktree_inspection: ?WorktreeStatus.Inspection = null, + worktrees_deferred: bool = false, + worktree_inspection_attempt_count: usize = 0, selected_worktree_path: []u8 = &.{}, reclaim_confirmation_armed: bool = false, worktree_dialog: ?WorktreeDialog.Dialog = null, @@ -1127,6 +1142,7 @@ pub const App = struct { .client = client, .daemon = .{ .allocator = allocator }, .model = GraphModel.Model.init(allocator), + .worktrees_deferred = compiledWorktreesDeferred(), .declared_entry_ids = std.array_list.Managed([]u8).init(allocator), .kept_worktree_paths = std.array_list.Managed([]u8).init(allocator), .tray_test_hook_enabled = envFlag(tray_test_hook_environment), @@ -3397,7 +3413,11 @@ pub const App = struct { const sketch = std.mem.eql(u8, node.loop_type, "sketch"); var promotion_error: ?anyerror = null; const promotion: ?SketchPromotion.Context = if (!sketch) null else SketchPromotion.Context.capture( - self.allocator, &self.model, project_path, composite_id orelse "", node, + self.allocator, + &self.model, + project_path, + composite_id orelse "", + node, ) catch |err| blk: { promotion_error = err; break :blk null; @@ -4028,7 +4048,29 @@ pub const App = struct { ); } + fn clearDeferredWorktreeState(self: *App) void { + if (self.worktree_dialog) |*dialog| dialog.deinit(); + self.worktree_dialog = null; + if (self.worktree_inspection) |*inspection| { + WorktreeStatus.deinitInspection(self.allocator, inspection); + } + self.worktree_inspection = null; + if (self.selected_worktree_path.len != 0) self.allocator.free(self.selected_worktree_path); + self.selected_worktree_path = &.{}; + self.reclaim_confirmation_armed = false; + self.model.invalidateWorktreeNotices(.worktrees_changed); + } + + fn guardWorktreesPreview(self: *App) bool { + if (!self.worktrees_deferred) return false; + self.clearDeferredWorktreeState(); + self.setStatus(worktrees_deferred_message); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + return true; + } + fn inspectWorktrees(self: *App) void { + if (self.guardWorktreesPreview()) return; if (envFlag("GRAPHCODE_UIA_GATE") and envFlag("GRAPHCODE_UIA_SHOW_DIALOGS") and self.worktree_inspection != null) { self.presentWorktreeSweep(); return; @@ -4072,6 +4114,7 @@ pub const App = struct { } fn inspectWorktreesImpl(self: *App, show_sweep: bool) void { + if (self.guardWorktreesPreview()) return; const current_graph = self.model.graph orelse { self.setStatus("Worktrees require a local filesystem project"); return; @@ -4099,6 +4142,7 @@ pub const App = struct { }; } } + self.worktree_inspection_attempt_count += 1; var inspection = WorktreeStatus.inspect(self.allocator, path, bindings.items) catch |err| { self.recordWorktreeInspectionFailure(path, err); return; @@ -4140,6 +4184,7 @@ pub const App = struct { } fn presentWorktreeSweep(self: *App) void { + if (self.guardWorktreesPreview()) return; const graph = self.model.graph orelse return; const inspection = self.worktree_inspection orelse return; const project_path = self.allocator.dupe(u8, graph.project.path) catch return; @@ -4259,7 +4304,10 @@ pub const App = struct { defer self.allocator.free(message); self.setIngressError(message); } else |_| {} - self.setStatus("UIA fixture inspection ready"); + self.setStatus(if (self.worktrees_deferred) + worktrees_deferred_message + else + "UIA fixture inspection ready"); return true; } @@ -4278,6 +4326,7 @@ pub const App = struct { self.uia_fixture_model_arena = data.model_arena; self.worktree_inspection = data.inspection; self.worktree_dialog = data.dialog; + if (self.worktrees_deferred) self.clearDeferredWorktreeState(); if (self.uia_fixture_project_path.len != 0) self.allocator.free(self.uia_fixture_project_path); self.uia_fixture_project_path = captured; if (self.selected_worktree_path.len != 0) self.allocator.free(self.selected_worktree_path); @@ -4359,6 +4408,7 @@ pub const App = struct { } fn reclaimWorktrees(self: *App) void { + if (self.guardWorktreesPreview()) return; const current_graph = self.model.graph orelse { self.setStatus("Worktrees require a local filesystem project"); return; @@ -4439,6 +4489,7 @@ pub const App = struct { } pub fn selectWorktreeRow(self: *App, path: []const u8) bool { + if (self.guardWorktreesPreview()) return false; const inspection = self.worktree_inspection orelse return false; if (!envFlag("GRAPHCODE_UIA_GATE")) { if (self.currentProject()) |project| { @@ -4467,6 +4518,7 @@ pub const App = struct { } pub fn toggleWorktreeRow(self: *App, index: usize) bool { + if (self.guardWorktreesPreview()) return false; const dialog = if (self.worktree_dialog) |*value| value else return false; if (index >= dialog.rows.items.len or WorktreeStatus.decision(dialog.rows.items[index].entry) != .reclaimable) return false; @@ -4486,6 +4538,7 @@ pub const App = struct { } fn applyUiaWorktreeSelection(self: *App, payload: usize, operation: usize) bool { + if (self.guardWorktreesPreview()) return false; const dialog = if (self.worktree_dialog) |*value| value else return false; var target: ?usize = null; for (dialog.rows.items, 0..) |row, index| { @@ -4763,6 +4816,7 @@ pub const App = struct { } fn editWorktreePolicy(self: *App) void { + if (self.guardWorktreesPreview()) return; const selected_path = self.currentProject() orelse { self.setStatus("Open a project before changing project settings"); return; @@ -4809,6 +4863,7 @@ pub const App = struct { } fn saveCurrentWorktreePolicy(self: *App) void { + if (self.guardWorktreesPreview()) return; const dialog = self.worktree_dialog orelse { self.setStatus("Inspect worktrees before saving policy"); return; @@ -4820,6 +4875,7 @@ pub const App = struct { } fn toggleAllowReclaim(self: *App) void { + if (self.guardWorktreesPreview()) return; if (self.worktree_dialog) |*dialog| { var policy = dialog.policy; policy.allow_reclaim = !policy.allow_reclaim; @@ -4829,6 +4885,7 @@ pub const App = struct { } fn toggleConfirmReclaim(self: *App) void { + if (self.guardWorktreesPreview()) return; if (self.worktree_dialog) |*dialog| { var policy = dialog.policy; policy.confirm_each_reclaim = !policy.confirm_each_reclaim; @@ -4838,6 +4895,7 @@ pub const App = struct { } fn revealSelectedWorktree(self: *App) void { + if (self.guardWorktreesPreview()) return; const dialog = self.worktree_dialog orelse { self.setStatus("Inspect worktrees before revealing a row"); return; @@ -4875,6 +4933,7 @@ pub const App = struct { } fn keepWorktreeOffer(self: *App, path: []const u8) void { + if (self.guardWorktreesPreview()) return; for (self.kept_worktree_paths.items) |kept| if (std.mem.eql(u8, kept, path)) return; const copy = self.allocator.dupe(u8, path) catch { self.setStatus("Unable to keep the worktree offer"); @@ -4889,6 +4948,7 @@ pub const App = struct { } fn reclaimWorktreeOffer(self: *App, path: []const u8) void { + if (self.guardWorktreesPreview()) return; const graph = self.model.graph orelse return; if (!graph.project.isLocalFilesystem()) return; const inspection = self.worktree_inspection orelse return; @@ -4932,6 +4992,7 @@ pub const App = struct { } fn moveWorktreeSelection(self: *App, delta: i32) void { + if (self.guardWorktreesPreview()) return; const inspection = self.worktree_inspection orelse return; if (inspection.entries.items.len == 0) return; var index: usize = 0; @@ -9240,7 +9301,11 @@ test "workspace running cycle production helper compiles with no launch capabili F.open = &.{F.wide(F.rows[0].path)}; F.expected_restore = F.open[0]; try std.testing.expectEqual(WorkspaceCycleResult.restored, try cycleWorkspaceWith( - RestoreOnly, std.testing.allocator, F.default_path, "c:/fixture/.graphcode", 1, + RestoreOnly, + std.testing.allocator, + F.default_path, + "c:/fixture/.graphcode", + 1, )); try std.testing.expectEqual(@as(usize, 1), F.restores); } @@ -9524,10 +9589,14 @@ test "Show in Graph shared action publishes project UIA after native effects com ); try std.testing.expect(app.model.setSelectedID("loop")); var workspace: TerminalWorkspace.Workspace = .{ - .parent = null, .allocator = allocator, .zmx_path = &.{}, .cwd = &.{}, + .parent = null, + .allocator = allocator, + .zmx_path = &.{}, + .cwd = &.{}, .input_queue = .{ .allocator = allocator }, .layout = try @import("WorkspaceLayout.zig").Layout.init(allocator, "A"), - .layout_path = &.{}, .project_key = &.{}, + .layout_path = &.{}, + .project_key = &.{}, }; defer workspace.layout.deinit(); try workspace.layout.addTab("loop", true); @@ -13023,6 +13092,74 @@ test "header UIA identities hash to distinct payloads" { } } +test "preview Worktrees guard blocks production actions without inspection" { + const allocator = std.testing.allocator; + var app: App = .{ + .allocator = allocator, + .client = undefined, + .daemon = undefined, + .model = GraphModel.Model.init(allocator), + .worktrees_deferred = true, + .sidebar_state = Sidebar.State.init(allocator), + .declared_entry_ids = std.array_list.Managed([]u8).init(allocator), + .kept_worktree_paths = std.array_list.Managed([]u8).init(allocator), + }; + defer { + if (app.status_override.len != 0) allocator.free(app.status_override); + app.model.deinit(); + app.sidebar_state.deinit(); + app.declared_entry_ids.deinit(); + app.kept_worktree_paths.deinit(); + } + _ = try app.model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"project":{"path":"C:\\preview","name":"Preview"},"nodes":[],"edges":[]}}} + ); + const entries = std.array_list.Managed(WorktreeStatus.Entry).init(allocator); + app.worktree_inspection = .{ + .entries = entries, + .default_branch = try allocator.dupe(u8, "main"), + .project_path = try allocator.dupe(u8, "C:\\preview"), + }; + app.worktree_dialog = try WorktreeDialog.Dialog.init( + allocator, + "C:\\preview", + app.worktree_inspection.?.entries.items, + .{ .allow_reclaim = true }, + ); + app.selected_worktree_path = try allocator.dupe(u8, "C:\\preview-worktree"); + app.reclaim_confirmation_armed = true; + + app.inspectWorktrees(); + try std.testing.expectEqualStrings(worktrees_deferred_message, app.status_override); + try std.testing.expectEqual(@as(usize, 0), app.worktree_inspection_attempt_count); + try std.testing.expect(app.worktree_inspection == null); + try std.testing.expect(app.worktree_dialog == null); + try std.testing.expectEqual(@as(usize, 0), app.selected_worktree_path.len); + try std.testing.expect(!app.reclaim_confirmation_armed); + + app.inspectWorktreesImpl(false); + app.presentWorktreeSweep(); + app.reclaimWorktrees(); + app.reclaimWorktreeOffer("C:\\preview-worktree"); + app.keepWorktreeOffer("C:\\preview-worktree"); + app.editWorktreePolicy(); + app.saveCurrentWorktreePolicy(); + app.toggleAllowReclaim(); + app.toggleConfirmReclaim(); + app.revealSelectedWorktree(); + app.moveWorktreeSelection(1); + try std.testing.expect(!app.selectWorktreeRow("C:\\preview-worktree")); + try std.testing.expect(!app.toggleWorktreeRow(0)); + try std.testing.expect(!app.applyUiaWorktreeSelection(0, 0)); + try std.testing.expectEqualStrings(worktrees_deferred_message, app.status_override); + try std.testing.expectEqual(@as(usize, 0), app.worktree_inspection_attempt_count); + + app.surface = .project; + app.openGlobalOverview(); + try std.testing.expectEqual(GraphCanvas.Surface.overview, app.surface); + try std.testing.expectEqualStrings(worktrees_deferred_message, app.status_override); +} + fn runSmokeWorkspaceActions(self: *App) void { const script = self.smoke_workspace_actions; if (script.len == 0) return; diff --git a/graphcode-windows/src/main.zig b/graphcode-windows/src/main.zig index dd3e0dd9..c51aacd1 100644 --- a/graphcode-windows/src/main.zig +++ b/graphcode-windows/src/main.zig @@ -16,6 +16,13 @@ pub fn main() !void { try stdout.interface.print("{s}\n", .{build_options.version}); return; } + if (std.mem.eql(u8, arg, "--worktrees-preview-state")) { + var stdout = std.fs.File.stdout().writer(&.{}); + try stdout.interface.print("{s}\n", .{ + if (build_options.worktrees_deferred) "deferred" else "available", + }); + return; + } } var app = App.init(allocator) catch |err| { if (err == error.InstanceAlreadyRunning) {