From d79e0e787a16a7d20228bfb92a99bd9777bbe1a7 Mon Sep 17 00:00:00 2001 From: Carsten Schurig Date: Thu, 16 Jul 2026 14:03:39 +0200 Subject: [PATCH 01/16] Add fork combined-test workflow (test-combined.ps1). Generic script reads branch config from team/local via git show, fetches origin branches by default (--local-only to disable), auto-resolves .gitignore merge conflicts on throwaway test/combined. Co-authored-by: Cursor --- .gitignore | 6 +- docs/fork-workflow.md | 50 ++++ scripts/test-combined.example.json | 9 + scripts/test-combined.ps1 | 463 +++++++++++++++++++++++++++++ 4 files changed, 527 insertions(+), 1 deletion(-) create mode 100644 docs/fork-workflow.md create mode 100644 scripts/test-combined.example.json create mode 100644 scripts/test-combined.ps1 diff --git a/.gitignore b/.gitignore index f4e2aba..a724c96 100644 --- a/.gitignore +++ b/.gitignore @@ -57,4 +57,8 @@ ExampleDesigns/Sandbox/* # would re-leak exactly what it removes. Run it before each commit. sanitize_sandbox.ps1 wiring.json -scripts/test-*.ps1 +# Local override for test-combined (team config is on team/local branch) +scripts/test-combined.json +# Fork-local team config (team/test-combined.json is tracked on team/local only) +team/* +!team/test-combined.json diff --git a/docs/fork-workflow.md b/docs/fork-workflow.md new file mode 100644 index 0000000..cd87f4c --- /dev/null +++ b/docs/fork-workflow.md @@ -0,0 +1,50 @@ +# Fork workflow (team/local) + +This fork keeps upstream-ready work separate from local team tooling. + +## Branches + +| Branch | Purpose | +|--------|---------| +| `main` | Tracks upstream; use as the base for upstream pull requests | +| `team/local` | Shared fork config (combined-test branch list, etc.) | + +Daily development can use `team/local` or feature branches. **Do not merge `team/local` into branches you open upstream.** + +## Combined local test + +`scripts/test-combined.ps1` builds a throwaway branch, merges configured feature branches, runs topology tests and FYPA, then returns to your previous branch. Run it from **any** branch — config is read from `team/local` via `git show` when the file is not in your working tree. + +Config lives in `team/test-combined.json` on the `team/local` branch: + +```json +{ + "baseBranch": "main", + "testBranch": "test/combined", + "deleteTestBranchFirst": true, + "extraFeatureBranches": ["feature/example-a", "fix/example-b"] +} +``` + +- `deleteTestBranchFirst`: when `true`, delete `testBranch` before recreating it (clean slate). +- By default, `baseBranch` and `extraFeatureBranches` are **fetched from `origin`** and merged via `origin/`. Use `--local-only` to use local branches only. +- Override any field on the command line, e.g. `-DeleteTestBranchFirst:$false`. + +```powershell +pwsh scripts/test-combined.ps1 +pwsh scripts/test-combined.ps1 --local-only +``` + +Resolution order: `scripts/test-combined.json` (local override) → `team/test-combined.json` → `team/local:team/test-combined.json` → example file. + +For a one-off local config, copy `scripts/test-combined.example.json` to `scripts/test-combined.json` (gitignored). + +## Upstream pull requests + +Create the PR branch from upstream, not from `team/local`: + +```powershell +git fetch upstream +git checkout -b feature/my-fix upstream/main +git cherry-pick # feature commits only +``` diff --git a/scripts/test-combined.example.json b/scripts/test-combined.example.json new file mode 100644 index 0000000..3f21d67 --- /dev/null +++ b/scripts/test-combined.example.json @@ -0,0 +1,9 @@ +{ + "baseBranch": "main", + "testBranch": "test/combined", + "deleteTestBranchFirst": true, + "extraFeatureBranches": [ + "feature/example-a", + "fix/example-b" + ] +} diff --git a/scripts/test-combined.ps1 b/scripts/test-combined.ps1 new file mode 100644 index 0000000..bba1779 --- /dev/null +++ b/scripts/test-combined.ps1 @@ -0,0 +1,463 @@ +# Build a local combined test branch from a base branch + feature branches, run tests/FYPA, then switch back. +# +# Config (first match wins): +# scripts/test-combined.json local override (gitignored) +# team/test-combined.json working tree +# team/local:team/test-combined.json from team/local branch via git show (no checkout) +# scripts/test-combined.example.json fallback +# +# Usage (from repo root, any branch): +# pwsh scripts/test-combined.ps1 +# pwsh scripts/test-combined.ps1 --local-only +# pwsh scripts/test-combined.ps1 -ConfigPath scripts/test-combined.json +# +# By default baseBranch and extraFeatureBranches are fetched from origin and merged +# via origin/. Pass --local-only to use local branches only. +# +# Workflow: +# 1. Remember current branch +# 2. Optionally delete the test branch, then recreate it from the base branch +# 3. Merge every extra feature branch (.gitignore conflicts auto-resolved with --ours) +# 4. Run pytest topology suite, then uv run FYPA.py +# 5. Return to the branch you started on (even if a step exits with an error) + +[CmdletBinding()] +param( + [string] $ConfigPath, + [string] $TeamConfigRef = "team/local", + [string] $Remote = "origin", + [switch] $LocalOnly, + [string] $BaseBranch, + [string] $TestBranch, + [string[]] $ExtraFeatureBranches, + [bool] $DeleteTestBranchFirst +) + +$ErrorActionPreference = "Stop" + +$RepoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +Set-Location $RepoRoot + +function Invoke-GitCore { + param( + [Parameter(Mandatory, ValueFromRemainingArguments)] + [string[]] $GitArgs, + [switch] $Quiet + ) + if ($GitArgs.Count -eq 0) { + throw "Invoke-GitCore: no arguments" + } + + $Output = @(& git.exe @GitArgs 2>&1) + $ExitCode = $LASTEXITCODE + + if (-not $Quiet) { + foreach ($Line in $Output) { + if ($Line -is [System.Management.Automation.ErrorRecord]) { + Write-Warning $Line.ToString() + } + else { + Write-Host $Line + } + } + } + + $Stdout = @( + $Output | + Where-Object { $_ -isnot [System.Management.Automation.ErrorRecord] } | + ForEach-Object { [string] $_ } + ) + + return @{ + ExitCode = $ExitCode + Output = $Stdout + } +} + +function Invoke-Git { + param( + [Parameter(Mandatory, ValueFromRemainingArguments)] + [string[]] $GitArgs + ) + $Result = Invoke-GitCore @GitArgs + if ($Result.ExitCode -ne 0) { + throw "git $($GitArgs -join ' ') failed (exit $($Result.ExitCode))" + } + return $Result.Output +} + +function Invoke-GitSoft { + param( + [Parameter(Mandatory, ValueFromRemainingArguments)] + [string[]] $GitArgs + ) + return (Invoke-GitCore @GitArgs).ExitCode +} + +function Test-GitRef { + param([string] $Ref) + & git show-ref --verify --quiet $Ref + return $LASTEXITCODE -eq 0 +} + +function Get-BranchMergeRef { + param( + [string] $Branch, + [string] $RemoteName, + [bool] $UseLocalOnly + ) + + if ($UseLocalOnly) { + return $Branch + } + return "$RemoteName/$Branch" +} + +function Test-BranchAvailable { + param( + [string] $Branch, + [string] $RemoteName, + [bool] $UseLocalOnly + ) + + if ($UseLocalOnly) { + return Test-GitRef "refs/heads/$Branch" + } + return Test-GitRef "refs/remotes/$RemoteName/$Branch" +} + +function Sync-RemoteBranches { + param( + [string] $RemoteName, + [string[]] $Branches + ) + + $UniqueBranches = @($Branches | Where-Object { $_ } | Select-Object -Unique) + if ($UniqueBranches.Count -eq 0) { + return + } + + Write-Host "==> Fetch $RemoteName $($UniqueBranches -join ', ')" + Invoke-Git @(@('fetch', $RemoteName) + $UniqueBranches) +} + +function Test-MergeInProgress { + $MergeHead = & git.exe rev-parse -q --verify MERGE_HEAD 2>$null + return [bool] $MergeHead +} + +function Get-UnmergedPaths { + $Output = & git.exe diff --name-only --diff-filter=U 2>$null + if ($LASTEXITCODE -ne 0) { + return @() + } + return @($Output | Where-Object { $_ }) +} + +function Resolve-IgnoredMergeConflicts { + param( + [string[]] $IgnoredPaths, + [ValidateSet('ours', 'theirs')] + [string] $Prefer = 'ours' + ) + + foreach ($Path in (Get-UnmergedPaths)) { + if ($Path -in $IgnoredPaths) { + Write-Host "==> Auto-resolve merge conflict in $Path ($Prefer)" + Invoke-Git @('checkout', "--$Prefer", '--', $Path) + Invoke-Git @('add', '--', $Path) + } + } + + return @(Get-UnmergedPaths) +} + +function Merge-FeatureBranch { + param( + [string] $MergeRef, + [string] $ExtraBranch, + [string[]] $IgnoredPaths + ) + + $MergeMessage = "test: merge $ExtraBranch for local testing" + $ExitCode = Invoke-GitSoft @( + 'merge', $MergeRef, '--no-edit', '-m', $MergeMessage + ) + if ($ExitCode -eq 0) { + return + } + + if (-not (Test-MergeInProgress)) { + throw "git merge $MergeRef failed (exit $ExitCode)" + } + + $Remaining = Resolve-IgnoredMergeConflicts -IgnoredPaths $IgnoredPaths -Prefer 'ours' + if ($Remaining.Count -gt 0) { + throw "Merge conflict in: $($Remaining -join ', ')" + } + + Invoke-Git @('commit', '--no-edit') +} + +function Get-CurrentBranch { + return ([string] (Invoke-Git @('branch', '--show-current') | Select-Object -First 1)).Trim() +} + +function Restore-DevBranch { + param([string] $Branch) + if ($Branch) { + Invoke-Git @('checkout', $Branch) + } +} + +function Get-GitConfigJson { + param( + [string[]] $Refs, + [string] $RepoPath = "team/test-combined.json" + ) + + foreach ($Ref in $Refs) { + if (-not $Ref) { continue } + $Spec = "${Ref}:${RepoPath}" + $Json = & git show $Spec 2>$null + if ($LASTEXITCODE -eq 0 -and $Json) { + return @{ Source = $Spec; Json = [string] $Json } + } + } + + return $null +} + +function Resolve-ConfigSource { + param( + [string] $ExplicitPath, + [string] $TeamRef + ) + + if ($ExplicitPath) { + if (Test-Path $ExplicitPath) { + return @{ + Source = (Resolve-Path $ExplicitPath).Path + Json = $null + } + } + if ($ExplicitPath -match ':') { + $Json = & git show $ExplicitPath 2>$null + if ($LASTEXITCODE -eq 0 -and $Json) { + return @{ Source = $ExplicitPath; Json = [string] $Json } + } + } + throw "Config file not found: $ExplicitPath" + } + + $LocalCandidates = @( + (Join-Path $RepoRoot "scripts/test-combined.json"), + (Join-Path $RepoRoot "team/test-combined.json") + ) + + foreach ($Candidate in $LocalCandidates) { + if (Test-Path $Candidate) { + return @{ + Source = (Resolve-Path $Candidate).Path + Json = $null + } + } + } + + $GitRefs = @( + $TeamRef, + "origin/$TeamRef" + ) + $FromGit = Get-GitConfigJson -Refs $GitRefs + if ($FromGit) { + return $FromGit + } + + $Example = Join-Path $RepoRoot "scripts/test-combined.example.json" + if (Test-Path $Example) { + Write-Warning "Using example config ($Example). Copy to scripts/test-combined.json or update team/local." + return @{ + Source = (Resolve-Path $Example).Path + Json = $null + } + } + + throw @" +No test-combined config found. +Fetch team/local (git fetch origin team/local) or create scripts/test-combined.json from scripts/test-combined.example.json. +"@ +} + +function Read-TestCombinedConfig { + param( + [string] $Source, + [string] $Json + ) + + try { + if ($Json) { + $Config = $Json | ConvertFrom-Json + } + else { + $Config = Get-Content -Raw -Path $Source | ConvertFrom-Json + } + } + catch { + throw "Failed to parse config JSON at '$Source': $_" + } + + foreach ($Required in @("baseBranch", "testBranch", "extraFeatureBranches")) { + if (-not ($Config.PSObject.Properties.Name -contains $Required)) { + throw "Config '$Source' is missing required field '$Required'." + } + } + + return $Config +} + +if (-not (Test-Path "FYPA.py")) { + throw "FYPA.py not found in $RepoRoot — run this script from the FYPA repo." +} + +$ConfigSource = Resolve-ConfigSource -ExplicitPath $ConfigPath -TeamRef $TeamConfigRef +Write-Host "==> Config: $($ConfigSource.Source)" +$Config = Read-TestCombinedConfig -Source $ConfigSource.Source -Json $ConfigSource.Json + +$BaseBranch = if ($PSBoundParameters.ContainsKey("BaseBranch")) { $BaseBranch } else { [string] $Config.baseBranch } +$TestBranch = if ($PSBoundParameters.ContainsKey("TestBranch")) { $TestBranch } else { [string] $Config.testBranch } +$ExtraFeatureBranches = if ($PSBoundParameters.ContainsKey("ExtraFeatureBranches")) { + $ExtraFeatureBranches +} +else { + @($Config.extraFeatureBranches | ForEach-Object { [string] $_ }) +} +$DeleteTestBranchFirst = if ($PSBoundParameters.ContainsKey("DeleteTestBranchFirst")) { + $DeleteTestBranchFirst +} +elseif ($Config.PSObject.Properties.Name -contains "deleteTestBranchFirst") { + [bool] $Config.deleteTestBranchFirst +} +else { + $false +} + +if (-not $BaseBranch) { throw "baseBranch is empty." } +if (-not $TestBranch) { throw "testBranch is empty." } + +$UseLocalOnly = [bool] $LocalOnly +if ($UseLocalOnly) { + Write-Host "==> Branch source: local only" +} +else { + Write-Host "==> Branch source: $Remote (fetch + merge remote-tracking refs)" +} + +$ReturnBranch = Get-CurrentBranch +if (-not $ReturnBranch) { + throw "Could not determine the current branch." +} + +if ($UseLocalOnly) { + if (-not (Test-BranchAvailable -Branch $BaseBranch -RemoteName $Remote -UseLocalOnly $true)) { + throw "Base branch '$BaseBranch' not found locally." + } +} +else { + Sync-RemoteBranches -RemoteName $Remote -Branches (@($BaseBranch) + $ExtraFeatureBranches) + if (-not (Test-BranchAvailable -Branch $BaseBranch -RemoteName $Remote -UseLocalOnly $false)) { + throw "Remote branch '$Remote/$BaseBranch' not found after fetch." + } +} + +$BaseRef = Get-BranchMergeRef -Branch $BaseBranch -RemoteName $Remote -UseLocalOnly $UseLocalOnly + +$IgnoredPaths = @('.gitignore', 'FYPA.code-workspace') +$Status = @(Invoke-Git @('status', '--porcelain')) +$BlockingStatus = @($Status | Where-Object { + $path = $_.Substring(3).Trim() + if ($path -match ' -> ') { $path = ($path -split ' -> ', 2)[-1].Trim() } + elseif ($path -match "`t") { $path = ($path -split "`t", 2)[-1].Trim() } + $path -notin $IgnoredPaths +}) +if ($BlockingStatus.Count -gt 0) { + throw @" +Uncommitted changes detected on '$ReturnBranch'. +Commit or stash them before running the test script. +"@ +} + +$Returned = $false +$FypaExit = 0 +try { + if ($DeleteTestBranchFirst -and (Test-GitRef "refs/heads/$TestBranch")) { + Write-Host "==> Delete $TestBranch" + if (Get-CurrentBranch -eq $TestBranch) { + Invoke-Git @('checkout', $ReturnBranch) + } + Invoke-Git @('branch', '-D', $TestBranch) + } + + if (Test-GitRef "refs/heads/$TestBranch") { + Write-Host "==> Recreate $TestBranch from $BaseRef" + Invoke-Git @('branch', '-f', $TestBranch, $BaseRef) + Invoke-Git @('checkout', $TestBranch) + } + else { + Write-Host "==> Create $TestBranch from $BaseRef" + Invoke-Git @('checkout', '-b', $TestBranch, $BaseRef) + } + + foreach ($ExtraBranch in $ExtraFeatureBranches) { + if (-not $ExtraBranch) { continue } + + $MergeRef = Get-BranchMergeRef -Branch $ExtraBranch -RemoteName $Remote -UseLocalOnly $UseLocalOnly + if (Test-BranchAvailable -Branch $ExtraBranch -RemoteName $Remote -UseLocalOnly $UseLocalOnly) { + Write-Host "==> Merge $MergeRef into $TestBranch" + Merge-FeatureBranch -MergeRef $MergeRef -ExtraBranch $ExtraBranch -IgnoredPaths $IgnoredPaths + } + else { + $Label = if ($UseLocalOnly) { "local branch" } else { "remote branch" } + Write-Warning "Extra feature $Label '$ExtraBranch' not found — continuing without it." + } + } + + Write-Host "==> pytest topology tests" + & uv run python -m pytest ` + tests/test_topology_invariants.py ` + tests/test_topology_regressions.py ` + tests/test_topology_layout.py ` + tests/test_topology_geometry.py ` + tests/test_topology_labels.py ` + tests/test_pdn_topology.py -q + if ($LASTEXITCODE -ne 0) { + throw "pytest failed (exit $LASTEXITCODE)" + } + + Write-Host "==> uv run FYPA.py" + & uv run --extra spacemouse FYPA.py + $FypaExit = $LASTEXITCODE +} +catch { + if (Get-CurrentBranch -ne $ReturnBranch) { + & git merge --abort 2>$null | Out-Null + & git rebase --abort 2>$null | Out-Null + } + throw +} +finally { + $Current = Get-CurrentBranch + if ($Current -ne $ReturnBranch) { + Write-Host "==> Return to $ReturnBranch" + Restore-DevBranch -Branch $ReturnBranch + $Returned = $true + } +} + +if (-not $Returned) { + Write-Host "==> Return to $ReturnBranch" + Restore-DevBranch -Branch $ReturnBranch +} + +if ($FypaExit -and $FypaExit -ne 0) { + exit $FypaExit +} From d4284ac8a816f55e205795383d7074939fecefc6 Mon Sep 17 00:00:00 2001 From: Carsten Schurig Date: Fri, 17 Jul 2026 08:40:44 +0200 Subject: [PATCH 02/16] Add -PrjPcb CLI to test-combined.ps1. Pass a .PrjPcb path to launch FYPA gui directly after the combined test branch run. Also gitignore scripts/py314.local.json. Co-authored-by: Cursor --- .gitignore | 1 + scripts/test-combined.ps1 | 20 ++++++++++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index a724c96..dba83e6 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,7 @@ sanitize_sandbox.ps1 wiring.json # Local override for test-combined (team config is on team/local branch) scripts/test-combined.json +scripts/py314.local.json # Fork-local team config (team/test-combined.json is tracked on team/local only) team/* !team/test-combined.json diff --git a/scripts/test-combined.ps1 b/scripts/test-combined.ps1 index bba1779..1fa4af0 100644 --- a/scripts/test-combined.ps1 +++ b/scripts/test-combined.ps1 @@ -10,6 +10,7 @@ # pwsh scripts/test-combined.ps1 # pwsh scripts/test-combined.ps1 --local-only # pwsh scripts/test-combined.ps1 -ConfigPath scripts/test-combined.json +# pwsh scripts/test-combined.ps1 -PrjPcb path\to\YourBoard.PrjPcb # # By default baseBranch and extraFeatureBranches are fetched from origin and merged # via origin/. Pass --local-only to use local branches only. @@ -30,7 +31,8 @@ param( [string] $BaseBranch, [string] $TestBranch, [string[]] $ExtraFeatureBranches, - [bool] $DeleteTestBranchFirst + [bool] $DeleteTestBranchFirst, + [string] $PrjPcb ) $ErrorActionPreference = "Stop" @@ -341,6 +343,14 @@ else { $false } +$PrjPcbPath = $null +if ($PrjPcb) { + if (-not (Test-Path -LiteralPath $PrjPcb)) { + throw "PrjPcb not found: $PrjPcb" + } + $PrjPcbPath = (Resolve-Path -LiteralPath $PrjPcb).Path +} + if (-not $BaseBranch) { throw "baseBranch is empty." } if (-not $TestBranch) { throw "testBranch is empty." } @@ -434,7 +444,13 @@ try { } Write-Host "==> uv run FYPA.py" - & uv run --extra spacemouse FYPA.py + if ($PrjPcbPath) { + Write-Host " Project: $PrjPcbPath" + & uv run --extra spacemouse FYPA.py gui $PrjPcbPath + } + else { + & uv run --extra spacemouse FYPA.py + } $FypaExit = $LASTEXITCODE } catch { From 0e67677f1d02e226b1a6ab1d28ef8d3e2557de1c Mon Sep 17 00:00:00 2001 From: Carsten Schurig Date: Thu, 23 Jul 2026 11:54:45 +0200 Subject: [PATCH 03/16] Speed up combined-test launches with soft-fetch reuse and -SkipTests. Reuse test/combined when input SHAs match a git-note stamp, fall back to local refs when fetch fails, skip pytest via -SkipTests, and quiet fetch stderr so stamp matching works reliably from Altium. --- docs/fork-workflow.md | 10 +- scripts/test-combined.ps1 | 257 +++++++++++++++++++++++++++++++------- 2 files changed, 222 insertions(+), 45 deletions(-) diff --git a/docs/fork-workflow.md b/docs/fork-workflow.md index cd87f4c..2d45c47 100644 --- a/docs/fork-workflow.md +++ b/docs/fork-workflow.md @@ -13,7 +13,7 @@ Daily development can use `team/local` or feature branches. **Do not merge `team ## Combined local test -`scripts/test-combined.ps1` builds a throwaway branch, merges configured feature branches, runs topology tests and FYPA, then returns to your previous branch. Run it from **any** branch — config is read from `team/local` via `git show` when the file is not in your working tree. +`scripts/test-combined.ps1` builds a throwaway branch, merges configured feature branches, optionally runs topology tests and FYPA, then returns to your previous branch. Run it from **any** branch — config is read from `team/local` via `git show` when the file is not in your working tree. Config lives in `team/test-combined.json` on the `team/local` branch: @@ -26,13 +26,17 @@ Config lives in `team/test-combined.json` on the `team/local` branch: } ``` -- `deleteTestBranchFirst`: when `true`, delete `testBranch` before recreating it (clean slate). -- By default, `baseBranch` and `extraFeatureBranches` are **fetched from `origin`** and merged via `origin/`. Use `--local-only` to use local branches only. +- `deleteTestBranchFirst`: when `true`, delete `testBranch` before recreating it (clean slate). Only applies when the branch is rebuilt, not when it is reused. +- By default, `baseBranch` and `extraFeatureBranches` are **soft-fetched from `origin`** and merged via `origin/`. If fetch fails (offline), local refs are used instead. Use `--local-only` to skip fetch entirely. +- When the existing `testBranch` tip has a matching stamp (input SHAs + config identity stored as a git note), the branch is **reused** instead of rebuilt. Pass `-Rebuild` to force a clean recreate. +- Topology pytest runs by default. Pass `-SkipTests` to skip them (the Altium launcher always passes `-SkipTests`). - Override any field on the command line, e.g. `-DeleteTestBranchFirst:$false`. ```powershell pwsh scripts/test-combined.ps1 pwsh scripts/test-combined.ps1 --local-only +pwsh scripts/test-combined.ps1 -Rebuild +pwsh scripts/test-combined.ps1 -SkipTests ``` Resolution order: `scripts/test-combined.json` (local override) → `team/test-combined.json` → `team/local:team/test-combined.json` → example file. diff --git a/scripts/test-combined.ps1 b/scripts/test-combined.ps1 index 1fa4af0..446cb0a 100644 --- a/scripts/test-combined.ps1 +++ b/scripts/test-combined.ps1 @@ -9,17 +9,23 @@ # Usage (from repo root, any branch): # pwsh scripts/test-combined.ps1 # pwsh scripts/test-combined.ps1 --local-only +# pwsh scripts/test-combined.ps1 -Rebuild +# pwsh scripts/test-combined.ps1 -SkipTests # pwsh scripts/test-combined.ps1 -ConfigPath scripts/test-combined.json # pwsh scripts/test-combined.ps1 -PrjPcb path\to\YourBoard.PrjPcb # -# By default baseBranch and extraFeatureBranches are fetched from origin and merged -# via origin/. Pass --local-only to use local branches only. +# By default baseBranch and extraFeatureBranches are soft-fetched from origin and +# merged via origin/. If fetch fails (offline), local refs are used. +# When input SHAs match the stamp on an existing test branch, that branch is +# reused instead of rebuilt. Pass -Rebuild to force a clean recreate. +# Pass --local-only to skip fetch and use local branches only. # # Workflow: # 1. Remember current branch -# 2. Optionally delete the test branch, then recreate it from the base branch -# 3. Merge every extra feature branch (.gitignore conflicts auto-resolved with --ours) -# 4. Run pytest topology suite, then uv run FYPA.py +# 2. Soft-fetch inputs (or local-only); reuse test branch if stamp matches +# 3. Otherwise optionally delete, recreate from base, merge feature branches +# (.gitignore conflicts auto-resolved with --ours); write stamp note +# 4. Optionally run pytest topology suite (-SkipTests to skip), then uv run FYPA.py # 5. Return to the branch you started on (even if a step exits with an error) [CmdletBinding()] @@ -28,6 +34,8 @@ param( [string] $TeamConfigRef = "team/local", [string] $Remote = "origin", [switch] $LocalOnly, + [switch] $Rebuild, + [switch] $SkipTests, [string] $BaseBranch, [string] $TestBranch, [string[]] $ExtraFeatureBranches, @@ -140,7 +148,78 @@ function Sync-RemoteBranches { } Write-Host "==> Fetch $RemoteName $($UniqueBranches -join ', ')" - Invoke-Git @(@('fetch', $RemoteName) + $UniqueBranches) + # git writes progress to stderr; don't surface it as PowerShell warnings. + $Result = Invoke-GitCore -Quiet @(@('fetch', $RemoteName) + $UniqueBranches) + if ($Result.ExitCode -ne 0) { + $Detail = ($Result.Output -join "`n").Trim() + if ($Detail) { + throw "git fetch $RemoteName failed (exit $($Result.ExitCode)): $Detail" + } + throw "git fetch $RemoteName failed (exit $($Result.ExitCode))" + } +} + +function Get-RefSha { + param([string] $Ref) + $Sha = ([string] (& git.exe rev-parse --verify "$Ref^{commit}" 2>$null)).Trim() + if ($LASTEXITCODE -ne 0 -or -not $Sha) { + return $null + } + return $Sha +} + +function Get-InputStamp { + param( + [string] $ConfigIdentity, + [string] $BaseName, + [string] $BaseSha, + [string[]] $ExtraPairs + ) + + # Single-line stamp: PowerShell [string] casts of multi-line git output join + # with spaces and would break equality checks if we used newlines. + $Parts = [System.Collections.Generic.List[string]]::new() + $Parts.Add("config=$ConfigIdentity") + $Parts.Add("base=$BaseName=$BaseSha") + foreach ($Pair in $ExtraPairs) { + if ($Pair) { $Parts.Add("extra=$Pair") } + } + return ($Parts -join '|') +} + +function Get-TestCombinedStamp { + param([string] $Commit) + if (-not $Commit) { return $null } + $Lines = @(& git.exe notes --ref=test-combined show $Commit 2>$null) + if ($LASTEXITCODE -ne 0) { + return $null + } + # Join exactly as written; Trim only outer whitespace. + return (($Lines -join "`n").Trim()) +} + +function Set-TestCombinedStamp { + param( + [string] $Commit, + [string] $Stamp + ) + $ExitCode = Invoke-GitSoft @( + 'notes', '--ref=test-combined', 'add', '-f', '-m', $Stamp, $Commit + ) + if ($ExitCode -ne 0) { + Write-Warning "Could not write test-combined stamp note on $Commit" + } +} + +function ConvertTo-NormalizedStamp { + param([string] $Stamp) + if (-not $Stamp) { return $null } + # Accept legacy multiline notes (joined with `n) and new single-line (`|`) form. + $Normalized = $Stamp.Trim() -replace "`r`n", "`n" -replace "`r", "`n" + if ($Normalized.Contains("`n")) { + $Normalized = (($Normalized -split "`n") | ForEach-Object { $_.Trim() } | Where-Object { $_ }) -join '|' + } + return $Normalized } function Test-MergeInProgress { @@ -359,7 +438,7 @@ if ($UseLocalOnly) { Write-Host "==> Branch source: local only" } else { - Write-Host "==> Branch source: $Remote (fetch + merge remote-tracking refs)" + Write-Host "==> Branch source: $Remote (soft-fetch + merge remote-tracking refs)" } $ReturnBranch = Get-CurrentBranch @@ -373,13 +452,87 @@ if ($UseLocalOnly) { } } else { - Sync-RemoteBranches -RemoteName $Remote -Branches (@($BaseBranch) + $ExtraFeatureBranches) - if (-not (Test-BranchAvailable -Branch $BaseBranch -RemoteName $Remote -UseLocalOnly $false)) { - throw "Remote branch '$Remote/$BaseBranch' not found after fetch." + try { + Sync-RemoteBranches -RemoteName $Remote -Branches (@($BaseBranch) + $ExtraFeatureBranches) + if (-not (Test-BranchAvailable -Branch $BaseBranch -RemoteName $Remote -UseLocalOnly $false)) { + throw "Remote branch '$Remote/$BaseBranch' not found after fetch." + } + } + catch { + Write-Warning "Fetch from $Remote failed or remote base missing; falling back to local refs." + Write-Warning "$_" + $UseLocalOnly = $true + if (-not (Test-BranchAvailable -Branch $BaseBranch -RemoteName $Remote -UseLocalOnly $true)) { + throw "Base branch '$BaseBranch' not found locally after soft-fetch fallback." + } + Write-Host "==> Branch source: local only (fallback)" } } $BaseRef = Get-BranchMergeRef -Branch $BaseBranch -RemoteName $Remote -UseLocalOnly $UseLocalOnly +$BaseSha = Get-RefSha -Ref $BaseRef +if (-not $BaseSha) { + throw "Could not resolve SHA for base ref '$BaseRef'." +} + +$ExtraStampPairs = [System.Collections.Generic.List[string]]::new() +$ResolvedExtras = [System.Collections.Generic.List[hashtable]]::new() +foreach ($ExtraBranch in $ExtraFeatureBranches) { + if (-not $ExtraBranch) { continue } + $MergeRef = Get-BranchMergeRef -Branch $ExtraBranch -RemoteName $Remote -UseLocalOnly $UseLocalOnly + if (Test-BranchAvailable -Branch $ExtraBranch -RemoteName $Remote -UseLocalOnly $UseLocalOnly) { + $ExtraSha = Get-RefSha -Ref $MergeRef + if (-not $ExtraSha) { + Write-Warning "Could not resolve SHA for '$MergeRef' — continuing without it." + continue + } + $ExtraStampPairs.Add("$ExtraBranch=$ExtraSha") + $ResolvedExtras.Add(@{ + Branch = $ExtraBranch + MergeRef = $MergeRef + }) + } + else { + $Label = if ($UseLocalOnly) { "local branch" } else { "remote branch" } + Write-Warning "Extra feature $Label '$ExtraBranch' not found — continuing without it." + } +} + +$ConfigIdentity = @( + "base=$BaseBranch", + "test=$TestBranch", + "deleteFirst=$DeleteTestBranchFirst", + "extras=$($ExtraFeatureBranches -join ',')" +) -join ';' + +$DesiredStamp = ConvertTo-NormalizedStamp (Get-InputStamp ` + -ConfigIdentity $ConfigIdentity ` + -BaseName $BaseBranch ` + -BaseSha $BaseSha ` + -ExtraPairs @($ExtraStampPairs)) + +$TestBranchExists = Test-GitRef "refs/heads/$TestBranch" +$ExistingTip = if ($TestBranchExists) { Get-RefSha -Ref $TestBranch } else { $null } +$ExistingStampRaw = $null +if ($ExistingTip) { + $ExistingStampRaw = Get-TestCombinedStamp -Commit $ExistingTip +} +$ExistingStamp = ConvertTo-NormalizedStamp $ExistingStampRaw +$CanReuse = ( + -not $Rebuild -and + $TestBranchExists -and + $ExistingStamp -and + ($ExistingStamp -eq $DesiredStamp) +) + +if (-not $CanReuse -and $TestBranchExists -and -not $Rebuild) { + if (-not $ExistingStamp) { + Write-Host "==> No reuse stamp on $TestBranch — will rebuild" + } + else { + Write-Host "==> Stamp mismatch on $TestBranch — will rebuild" + } +} $IgnoredPaths = @('.gitignore', 'FYPA.code-workspace') $Status = @(Invoke-Git @('status', '--porcelain')) @@ -399,48 +552,68 @@ Commit or stash them before running the test script. $Returned = $false $FypaExit = 0 try { - if ($DeleteTestBranchFirst -and (Test-GitRef "refs/heads/$TestBranch")) { - Write-Host "==> Delete $TestBranch" - if (Get-CurrentBranch -eq $TestBranch) { - Invoke-Git @('checkout', $ReturnBranch) + if ($CanReuse) { + Write-Host "==> Reuse $TestBranch (inputs unchanged)" + if ((Get-CurrentBranch) -ne $TestBranch) { + Invoke-Git @('checkout', $TestBranch) } - Invoke-Git @('branch', '-D', $TestBranch) - } - - if (Test-GitRef "refs/heads/$TestBranch") { - Write-Host "==> Recreate $TestBranch from $BaseRef" - Invoke-Git @('branch', '-f', $TestBranch, $BaseRef) - Invoke-Git @('checkout', $TestBranch) } else { - Write-Host "==> Create $TestBranch from $BaseRef" - Invoke-Git @('checkout', '-b', $TestBranch, $BaseRef) - } + if ($Rebuild) { + Write-Host "==> Rebuild requested — recreating $TestBranch" + } + elseif (-not $TestBranchExists) { + Write-Host "==> $TestBranch missing — creating from $BaseRef" + } + else { + Write-Host "==> Inputs changed — recreating $TestBranch from $BaseRef" + } - foreach ($ExtraBranch in $ExtraFeatureBranches) { - if (-not $ExtraBranch) { continue } + if ($DeleteTestBranchFirst -and (Test-GitRef "refs/heads/$TestBranch")) { + Write-Host "==> Delete $TestBranch" + if ((Get-CurrentBranch) -eq $TestBranch) { + Invoke-Git @('checkout', $ReturnBranch) + } + Invoke-Git @('branch', '-D', $TestBranch) + } - $MergeRef = Get-BranchMergeRef -Branch $ExtraBranch -RemoteName $Remote -UseLocalOnly $UseLocalOnly - if (Test-BranchAvailable -Branch $ExtraBranch -RemoteName $Remote -UseLocalOnly $UseLocalOnly) { - Write-Host "==> Merge $MergeRef into $TestBranch" - Merge-FeatureBranch -MergeRef $MergeRef -ExtraBranch $ExtraBranch -IgnoredPaths $IgnoredPaths + if (Test-GitRef "refs/heads/$TestBranch") { + Write-Host "==> Recreate $TestBranch from $BaseRef" + Invoke-Git @('branch', '-f', $TestBranch, $BaseRef) + Invoke-Git @('checkout', $TestBranch) } else { - $Label = if ($UseLocalOnly) { "local branch" } else { "remote branch" } - Write-Warning "Extra feature $Label '$ExtraBranch' not found — continuing without it." + Write-Host "==> Create $TestBranch from $BaseRef" + Invoke-Git @('checkout', '-b', $TestBranch, $BaseRef) + } + + foreach ($Extra in $ResolvedExtras) { + Write-Host "==> Merge $($Extra.MergeRef) into $TestBranch" + Merge-FeatureBranch -MergeRef $Extra.MergeRef -ExtraBranch $Extra.Branch -IgnoredPaths $IgnoredPaths + } + + $NewTip = Get-RefSha -Ref 'HEAD' + if ($NewTip) { + Set-TestCombinedStamp -Commit $NewTip -Stamp $DesiredStamp + Write-Host "==> Stamp written for $TestBranch" } } - Write-Host "==> pytest topology tests" - & uv run python -m pytest ` - tests/test_topology_invariants.py ` - tests/test_topology_regressions.py ` - tests/test_topology_layout.py ` - tests/test_topology_geometry.py ` - tests/test_topology_labels.py ` - tests/test_pdn_topology.py -q - if ($LASTEXITCODE -ne 0) { - throw "pytest failed (exit $LASTEXITCODE)" + if ($SkipTests) { + Write-Host "==> Skip pytest (-SkipTests)" + } + else { + Write-Host "==> pytest topology tests" + & uv run python -m pytest ` + tests/test_topology_invariants.py ` + tests/test_topology_regressions.py ` + tests/test_topology_layout.py ` + tests/test_topology_geometry.py ` + tests/test_topology_labels.py ` + tests/test_pdn_topology.py -q + if ($LASTEXITCODE -ne 0) { + throw "pytest failed (exit $LASTEXITCODE)" + } } Write-Host "==> uv run FYPA.py" From 66697a35c1b32e0e94d879b74005bd4a89a71025 Mon Sep 17 00:00:00 2001 From: Carsten Schurig Date: Fri, 24 Jul 2026 08:38:06 +0200 Subject: [PATCH 04/16] always merge locally changed branches --- docs/fork-workflow.md | 2 +- scripts/test-combined.ps1 | 188 +++++++++++++++++++++++++++----------- 2 files changed, 135 insertions(+), 55 deletions(-) diff --git a/docs/fork-workflow.md b/docs/fork-workflow.md index 2d45c47..85a08e0 100644 --- a/docs/fork-workflow.md +++ b/docs/fork-workflow.md @@ -27,7 +27,7 @@ Config lives in `team/test-combined.json` on the `team/local` branch: ``` - `deleteTestBranchFirst`: when `true`, delete `testBranch` before recreating it (clean slate). Only applies when the branch is rebuilt, not when it is reused. -- By default, `baseBranch` and `extraFeatureBranches` are **soft-fetched from `origin`** and merged via `origin/`. If fetch fails (offline), local refs are used instead. Use `--local-only` to skip fetch entirely. +- By default, `baseBranch` and `extraFeatureBranches` are **soft-fetched from `origin`**. Each input tip is resolved so **local work is never dropped**: if the local branch is ahead of or diverged from `origin/`, the local tip is used; if local is behind, `origin/` is used; branches that exist only locally or only on the remote are accepted either way. If fetch fails (offline), the same resolution runs against existing refs. Use `--local-only` to skip fetch and use local branches only. - When the existing `testBranch` tip has a matching stamp (input SHAs + config identity stored as a git note), the branch is **reused** instead of rebuilt. Pass `-Rebuild` to force a clean recreate. - Topology pytest runs by default. Pass `-SkipTests` to skip them (the Altium launcher always passes `-SkipTests`). - Override any field on the command line, e.g. `-DeleteTestBranchFirst:$false`. diff --git a/scripts/test-combined.ps1 b/scripts/test-combined.ps1 index 446cb0a..bad0aa9 100644 --- a/scripts/test-combined.ps1 +++ b/scripts/test-combined.ps1 @@ -14,15 +14,19 @@ # pwsh scripts/test-combined.ps1 -ConfigPath scripts/test-combined.json # pwsh scripts/test-combined.ps1 -PrjPcb path\to\YourBoard.PrjPcb # -# By default baseBranch and extraFeatureBranches are soft-fetched from origin and -# merged via origin/. If fetch fails (offline), local refs are used. -# When input SHAs match the stamp on an existing test branch, that branch is -# reused instead of rebuilt. Pass -Rebuild to force a clean recreate. +# By default baseBranch and extraFeatureBranches are soft-fetched from origin. +# Each input is resolved to the tip that includes local work: if the local branch +# is ahead of (or diverged from) origin/, the local tip is merged; if +# local is behind, origin/ is used; local-only or remote-only branches +# are accepted either way. If fetch fails (offline), existing refs are resolved +# the same way. When input SHAs match the stamp on an existing test branch, that +# branch is reused instead of rebuilt. Pass -Rebuild to force a clean recreate. # Pass --local-only to skip fetch and use local branches only. # # Workflow: # 1. Remember current branch -# 2. Soft-fetch inputs (or local-only); reuse test branch if stamp matches +# 2. Soft-fetch inputs (or local-only); resolve tips (prefer local when ahead); +# reuse test branch if stamp matches # 3. Otherwise optionally delete, recreate from base, merge feature branches # (.gitignore conflicts auto-resolved with --ours); write stamp note # 4. Optionally run pytest topology suite (-SkipTests to skip), then uv run FYPA.py @@ -110,30 +114,120 @@ function Test-GitRef { return $LASTEXITCODE -eq 0 } -function Get-BranchMergeRef { +function Test-GitAncestor { param( - [string] $Branch, - [string] $RemoteName, - [bool] $UseLocalOnly + [string] $Ancestor, + [string] $Descendant ) - - if ($UseLocalOnly) { - return $Branch - } - return "$RemoteName/$Branch" + & git.exe merge-base --is-ancestor $Ancestor $Descendant 2>$null | Out-Null + return $LASTEXITCODE -eq 0 } -function Test-BranchAvailable { +function Resolve-BranchMergeTarget { param( [string] $Branch, [string] $RemoteName, [bool] $UseLocalOnly ) + $LocalRef = $Branch + $RemoteRef = "$RemoteName/$Branch" + $HasLocal = Test-GitRef "refs/heads/$Branch" + $HasRemote = Test-GitRef "refs/remotes/$RemoteName/$Branch" + if ($UseLocalOnly) { - return Test-GitRef "refs/heads/$Branch" + if (-not $HasLocal) { return $null } + $Sha = Get-RefSha -Ref $LocalRef + if (-not $Sha) { return $null } + return @{ + Branch = $Branch + MergeRef = $LocalRef + Sha = $Sha + Source = 'local' + } + } + + if ($HasLocal -and $HasRemote) { + $LocalSha = Get-RefSha -Ref $LocalRef + $RemoteSha = Get-RefSha -Ref $RemoteRef + if (-not $LocalSha -and -not $RemoteSha) { return $null } + if (-not $LocalSha) { + return @{ + Branch = $Branch + MergeRef = $RemoteRef + Sha = $RemoteSha + Source = 'remote' + } + } + if (-not $RemoteSha) { + return @{ + Branch = $Branch + MergeRef = $LocalRef + Sha = $LocalSha + Source = 'local' + } + } + if ($LocalSha -eq $RemoteSha) { + return @{ + Branch = $Branch + MergeRef = $LocalRef + Sha = $LocalSha + Source = 'local' + } + } + # Local contains remote → unpushed local commits; keep them. + if (Test-GitAncestor -Ancestor $RemoteSha -Descendant $LocalSha) { + Write-Host "==> ${Branch}: using local (ahead of $RemoteRef)" + return @{ + Branch = $Branch + MergeRef = $LocalRef + Sha = $LocalSha + Source = 'local' + } + } + # Remote contains local → local checkout is stale; take remote. + if (Test-GitAncestor -Ancestor $LocalSha -Descendant $RemoteSha) { + return @{ + Branch = $Branch + MergeRef = $RemoteRef + Sha = $RemoteSha + Source = 'remote' + } + } + # Diverged: never drop local work. + Write-Warning "${Branch}: local and $RemoteRef have diverged — using local tip" + return @{ + Branch = $Branch + MergeRef = $LocalRef + Sha = $LocalSha + Source = 'local' + } } - return Test-GitRef "refs/remotes/$RemoteName/$Branch" + + if ($HasLocal) { + $Sha = Get-RefSha -Ref $LocalRef + if (-not $Sha) { return $null } + Write-Host "==> ${Branch}: no $RemoteRef — using local" + return @{ + Branch = $Branch + MergeRef = $LocalRef + Sha = $Sha + Source = 'local' + } + } + + if ($HasRemote) { + $Sha = Get-RefSha -Ref $RemoteRef + if (-not $Sha) { return $null } + return @{ + Branch = $Branch + MergeRef = $RemoteRef + Sha = $Sha + Source = 'remote' + } + } + + return $null } function Sync-RemoteBranches { @@ -438,7 +532,7 @@ if ($UseLocalOnly) { Write-Host "==> Branch source: local only" } else { - Write-Host "==> Branch source: $Remote (soft-fetch + merge remote-tracking refs)" + Write-Host "==> Branch source: $Remote (soft-fetch; prefer local when ahead/diverged)" } $ReturnBranch = Get-CurrentBranch @@ -446,56 +540,42 @@ if (-not $ReturnBranch) { throw "Could not determine the current branch." } -if ($UseLocalOnly) { - if (-not (Test-BranchAvailable -Branch $BaseBranch -RemoteName $Remote -UseLocalOnly $true)) { - throw "Base branch '$BaseBranch' not found locally." - } -} -else { +if (-not $UseLocalOnly) { try { Sync-RemoteBranches -RemoteName $Remote -Branches (@($BaseBranch) + $ExtraFeatureBranches) - if (-not (Test-BranchAvailable -Branch $BaseBranch -RemoteName $Remote -UseLocalOnly $false)) { - throw "Remote branch '$Remote/$BaseBranch' not found after fetch." - } } catch { - Write-Warning "Fetch from $Remote failed or remote base missing; falling back to local refs." + Write-Warning "Fetch from $Remote failed; resolving from existing local/remote-tracking refs." Write-Warning "$_" - $UseLocalOnly = $true - if (-not (Test-BranchAvailable -Branch $BaseBranch -RemoteName $Remote -UseLocalOnly $true)) { - throw "Base branch '$BaseBranch' not found locally after soft-fetch fallback." - } - Write-Host "==> Branch source: local only (fallback)" } } -$BaseRef = Get-BranchMergeRef -Branch $BaseBranch -RemoteName $Remote -UseLocalOnly $UseLocalOnly -$BaseSha = Get-RefSha -Ref $BaseRef -if (-not $BaseSha) { - throw "Could not resolve SHA for base ref '$BaseRef'." +$BaseTarget = Resolve-BranchMergeTarget -Branch $BaseBranch -RemoteName $Remote -UseLocalOnly $UseLocalOnly +if (-not $BaseTarget) { + $Where = if ($UseLocalOnly) { "locally" } else { "locally or as $Remote/$BaseBranch" } + throw "Base branch '$BaseBranch' not found $Where." } +$BaseRef = $BaseTarget.MergeRef +$BaseSha = $BaseTarget.Sha +Write-Host "==> Base: $BaseRef ($($BaseTarget.Source))" + $ExtraStampPairs = [System.Collections.Generic.List[string]]::new() $ResolvedExtras = [System.Collections.Generic.List[hashtable]]::new() foreach ($ExtraBranch in $ExtraFeatureBranches) { if (-not $ExtraBranch) { continue } - $MergeRef = Get-BranchMergeRef -Branch $ExtraBranch -RemoteName $Remote -UseLocalOnly $UseLocalOnly - if (Test-BranchAvailable -Branch $ExtraBranch -RemoteName $Remote -UseLocalOnly $UseLocalOnly) { - $ExtraSha = Get-RefSha -Ref $MergeRef - if (-not $ExtraSha) { - Write-Warning "Could not resolve SHA for '$MergeRef' — continuing without it." - continue - } - $ExtraStampPairs.Add("$ExtraBranch=$ExtraSha") - $ResolvedExtras.Add(@{ - Branch = $ExtraBranch - MergeRef = $MergeRef - }) - } - else { - $Label = if ($UseLocalOnly) { "local branch" } else { "remote branch" } - Write-Warning "Extra feature $Label '$ExtraBranch' not found — continuing without it." - } + $ExtraTarget = Resolve-BranchMergeTarget -Branch $ExtraBranch -RemoteName $Remote -UseLocalOnly $UseLocalOnly + if (-not $ExtraTarget) { + $Where = if ($UseLocalOnly) { "locally" } else { "locally or on $Remote" } + Write-Warning "Extra feature branch '$ExtraBranch' not found $Where — continuing without it." + continue + } + Write-Host "==> Extra: $($ExtraTarget.MergeRef) ($($ExtraTarget.Source))" + $ExtraStampPairs.Add("$ExtraBranch=$($ExtraTarget.Sha)") + $ResolvedExtras.Add(@{ + Branch = $ExtraTarget.Branch + MergeRef = $ExtraTarget.MergeRef + }) } $ConfigIdentity = @( From 31583760a0131e1b3c2c242a139fae96a544c735 Mon Sep 17 00:00:00 2001 From: Carsten Schurig Date: Thu, 30 Jul 2026 08:06:24 +0200 Subject: [PATCH 05/16] Fix test-combined docs to use -LocalOnly instead of --local-only. PowerShell only accepts the PascalCase switch; the kebab-case form never matched a parameter. Co-authored-by: Cursor --- docs/fork-workflow.md | 4 ++-- scripts/test-combined.ps1 | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/fork-workflow.md b/docs/fork-workflow.md index 85a08e0..086dd19 100644 --- a/docs/fork-workflow.md +++ b/docs/fork-workflow.md @@ -27,14 +27,14 @@ Config lives in `team/test-combined.json` on the `team/local` branch: ``` - `deleteTestBranchFirst`: when `true`, delete `testBranch` before recreating it (clean slate). Only applies when the branch is rebuilt, not when it is reused. -- By default, `baseBranch` and `extraFeatureBranches` are **soft-fetched from `origin`**. Each input tip is resolved so **local work is never dropped**: if the local branch is ahead of or diverged from `origin/`, the local tip is used; if local is behind, `origin/` is used; branches that exist only locally or only on the remote are accepted either way. If fetch fails (offline), the same resolution runs against existing refs. Use `--local-only` to skip fetch and use local branches only. +- By default, `baseBranch` and `extraFeatureBranches` are **soft-fetched from `origin`**. Each input tip is resolved so **local work is never dropped**: if the local branch is ahead of or diverged from `origin/`, the local tip is used; if local is behind, `origin/` is used; branches that exist only locally or only on the remote are accepted either way. If fetch fails (offline), the same resolution runs against existing refs. Use `-LocalOnly` to skip fetch and use local branches only. - When the existing `testBranch` tip has a matching stamp (input SHAs + config identity stored as a git note), the branch is **reused** instead of rebuilt. Pass `-Rebuild` to force a clean recreate. - Topology pytest runs by default. Pass `-SkipTests` to skip them (the Altium launcher always passes `-SkipTests`). - Override any field on the command line, e.g. `-DeleteTestBranchFirst:$false`. ```powershell pwsh scripts/test-combined.ps1 -pwsh scripts/test-combined.ps1 --local-only +pwsh scripts/test-combined.ps1 -LocalOnly pwsh scripts/test-combined.ps1 -Rebuild pwsh scripts/test-combined.ps1 -SkipTests ``` diff --git a/scripts/test-combined.ps1 b/scripts/test-combined.ps1 index bad0aa9..0ba18e1 100644 --- a/scripts/test-combined.ps1 +++ b/scripts/test-combined.ps1 @@ -8,7 +8,7 @@ # # Usage (from repo root, any branch): # pwsh scripts/test-combined.ps1 -# pwsh scripts/test-combined.ps1 --local-only +# pwsh scripts/test-combined.ps1 -LocalOnly # pwsh scripts/test-combined.ps1 -Rebuild # pwsh scripts/test-combined.ps1 -SkipTests # pwsh scripts/test-combined.ps1 -ConfigPath scripts/test-combined.json @@ -21,7 +21,7 @@ # are accepted either way. If fetch fails (offline), existing refs are resolved # the same way. When input SHAs match the stamp on an existing test branch, that # branch is reused instead of rebuilt. Pass -Rebuild to force a clean recreate. -# Pass --local-only to skip fetch and use local branches only. +# Pass -LocalOnly to skip fetch and use local branches only. # # Workflow: # 1. Remember current branch From f6dd733d2b6db5ca319a442dee4e117943e019c5 Mon Sep 17 00:00:00 2001 From: Carsten Schurig Date: Tue, 4 Aug 2026 15:03:51 +0200 Subject: [PATCH 06/16] Add Get-Help/-? comment-based help to test-combined.ps1. Co-authored-by: Cursor --- scripts/test-combined.ps1 | 142 +++++++++++++++++++++++++++++--------- 1 file changed, 109 insertions(+), 33 deletions(-) diff --git a/scripts/test-combined.ps1 b/scripts/test-combined.ps1 index 0ba18e1..cbba5b9 100644 --- a/scripts/test-combined.ps1 +++ b/scripts/test-combined.ps1 @@ -1,36 +1,112 @@ -# Build a local combined test branch from a base branch + feature branches, run tests/FYPA, then switch back. -# -# Config (first match wins): -# scripts/test-combined.json local override (gitignored) -# team/test-combined.json working tree -# team/local:team/test-combined.json from team/local branch via git show (no checkout) -# scripts/test-combined.example.json fallback -# -# Usage (from repo root, any branch): -# pwsh scripts/test-combined.ps1 -# pwsh scripts/test-combined.ps1 -LocalOnly -# pwsh scripts/test-combined.ps1 -Rebuild -# pwsh scripts/test-combined.ps1 -SkipTests -# pwsh scripts/test-combined.ps1 -ConfigPath scripts/test-combined.json -# pwsh scripts/test-combined.ps1 -PrjPcb path\to\YourBoard.PrjPcb -# -# By default baseBranch and extraFeatureBranches are soft-fetched from origin. -# Each input is resolved to the tip that includes local work: if the local branch -# is ahead of (or diverged from) origin/, the local tip is merged; if -# local is behind, origin/ is used; local-only or remote-only branches -# are accepted either way. If fetch fails (offline), existing refs are resolved -# the same way. When input SHAs match the stamp on an existing test branch, that -# branch is reused instead of rebuilt. Pass -Rebuild to force a clean recreate. -# Pass -LocalOnly to skip fetch and use local branches only. -# -# Workflow: -# 1. Remember current branch -# 2. Soft-fetch inputs (or local-only); resolve tips (prefer local when ahead); -# reuse test branch if stamp matches -# 3. Otherwise optionally delete, recreate from base, merge feature branches -# (.gitignore conflicts auto-resolved with --ours); write stamp note -# 4. Optionally run pytest topology suite (-SkipTests to skip), then uv run FYPA.py -# 5. Return to the branch you started on (even if a step exits with an error) +<# +.SYNOPSIS + Build a local combined test branch, run tests/FYPA, then switch back. + +.DESCRIPTION + Merges a base branch plus feature branches into a disposable test branch, + optionally runs the pytest topology suite and FYPA.py, then returns to the + branch you started on (even if a step exits with an error). + + Config resolution (first match wins): + scripts/test-combined.json local override (gitignored) + team/test-combined.json working tree + team/local:team/test-combined.json from team/local via git show (no checkout) + scripts/test-combined.example.json fallback + + By default baseBranch and extraFeatureBranches are soft-fetched from origin. + Each input is resolved to the tip that includes local work: if the local + branch is ahead of (or diverged from) origin/, the local tip is + merged; if local is behind, origin/ is used; local-only or + remote-only branches are accepted either way. If fetch fails (offline), + existing refs are resolved the same way. + + When input SHAs match the stamp on an existing test branch, that branch is + reused instead of rebuilt. Pass -Rebuild to force a clean recreate. + Pass -LocalOnly to skip fetch and use local branches only. + + Workflow: + 1. Remember current branch + 2. Soft-fetch inputs (or local-only); resolve tips (prefer local when ahead); + reuse test branch if stamp matches + 3. Otherwise optionally delete, recreate from base, merge feature branches + (.gitignore conflicts auto-resolved with --ours); write stamp note + 4. Optionally run pytest topology suite (-SkipTests to skip), then uv run FYPA.py + 5. Return to the starting branch + +.PARAMETER ConfigPath + Path to a JSON config file. Overrides the default config search order. + +.PARAMETER TeamConfigRef + Git ref used when reading team/test-combined.json via git show. + Default: team/local + +.PARAMETER Remote + Remote name used for soft-fetch and tip resolution. Default: origin + +.PARAMETER LocalOnly + Skip fetch; resolve and merge local branches only. + +.PARAMETER Rebuild + Force delete/recreate of the test branch even when the stamp matches. + +.PARAMETER SkipTests + Skip the pytest topology suite; still runs FYPA.py unless the script exits earlier. + +.PARAMETER BaseBranch + Override config baseBranch (branch the test branch is created from). + +.PARAMETER TestBranch + Override config testBranch (name of the disposable combined branch). + +.PARAMETER ExtraFeatureBranches + Override config extraFeatureBranches (branches merged onto the base). + +.PARAMETER DeleteTestBranchFirst + Override config deleteTestBranchFirst. When true, delete the existing test + branch before recreating it. + +.PARAMETER PrjPcb + Path to a .PrjPcb passed through to FYPA.py. + +.EXAMPLE + pwsh scripts/test-combined.ps1 + + Use default config resolution, soft-fetch, reuse or rebuild the test branch, + run tests and FYPA, then switch back. + +.EXAMPLE + pwsh scripts/test-combined.ps1 -LocalOnly + + Skip remote fetch; use local branch tips only. + +.EXAMPLE + pwsh scripts/test-combined.ps1 -Rebuild + + Force a clean recreate of the test branch. + +.EXAMPLE + pwsh scripts/test-combined.ps1 -SkipTests + + Build/reuse the test branch and run FYPA without pytest. + +.EXAMPLE + pwsh scripts/test-combined.ps1 -ConfigPath scripts/test-combined.json + + Use an explicit local config file. + +.EXAMPLE + pwsh scripts/test-combined.ps1 -PrjPcb path\to\YourBoard.PrjPcb + + Pass a project file through to FYPA.py. + +.EXAMPLE + Get-Help .\scripts\test-combined.ps1 -Full + + Show this help. Equivalent: pwsh scripts/test-combined.ps1 -? + +.NOTES + Run from the repo root (or any branch); the script cds to the repo root itself. +#> [CmdletBinding()] param( From de94da1c86597b36fd7927214c39c850f20ddc7f Mon Sep 17 00:00:00 2001 From: Carsten Schurig Date: Wed, 5 Aug 2026 14:53:50 +0200 Subject: [PATCH 07/16] Centralize test/combined: maintain script, slim launcher, docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuild and publish the shared tip via maintain-test-combined.ps1 (origin tips only). test-combined.ps1 and launch-combined-gui.ps1 only check out origin/test/combined — no merges at Altium start. Co-authored-by: Cursor --- docs/fork-workflow.md | 51 ++- scripts/launch-combined-gui.ps1 | 122 ++++++ scripts/maintain-test-combined.ps1 | 610 ++++++++++++++++++++++++++ scripts/test-combined.ps1 | 683 ++--------------------------- 4 files changed, 817 insertions(+), 649 deletions(-) create mode 100644 scripts/launch-combined-gui.ps1 create mode 100644 scripts/maintain-test-combined.ps1 diff --git a/docs/fork-workflow.md b/docs/fork-workflow.md index 086dd19..1180579 100644 --- a/docs/fork-workflow.md +++ b/docs/fork-workflow.md @@ -7,15 +7,16 @@ This fork keeps upstream-ready work separate from local team tooling. | Branch | Purpose | |--------|---------| | `main` | Tracks upstream; use as the base for upstream pull requests | -| `team/local` | Shared fork config (combined-test branch list, etc.) | +| `team/local` | Shared fork config (combined-test branch list, GH Action for maintain) | +| `test/combined` | Published integration tip built from the JSON list (force-pushed) | Daily development can use `team/local` or feature branches. **Do not merge `team/local` into branches you open upstream.** -## Combined local test +## Combined test (`test/combined`) -`scripts/test-combined.ps1` builds a throwaway branch, merges configured feature branches, optionally runs topology tests and FYPA, then returns to your previous branch. Run it from **any** branch — config is read from `team/local` via `git show` when the file is not in your working tree. +Feature branches listed in `team/test-combined.json` on `team/local` are merged **once** into the shared `test/combined` branch and pushed to GitHub. Machines only fetch and check out that tip — they do not merge at Altium/FYPA start. -Config lives in `team/test-combined.json` on the `team/local` branch: +Config on `team/local`: ```json { @@ -26,22 +27,46 @@ Config lives in `team/test-combined.json` on the `team/local` branch: } ``` -- `deleteTestBranchFirst`: when `true`, delete `testBranch` before recreating it (clean slate). Only applies when the branch is rebuilt, not when it is reused. -- By default, `baseBranch` and `extraFeatureBranches` are **soft-fetched from `origin`**. Each input tip is resolved so **local work is never dropped**: if the local branch is ahead of or diverged from `origin/`, the local tip is used; if local is behind, `origin/` is used; branches that exist only locally or only on the remote are accepted either way. If fetch fails (offline), the same resolution runs against existing refs. Use `-LocalOnly` to skip fetch and use local branches only. -- When the existing `testBranch` tip has a matching stamp (input SHAs + config identity stored as a git note), the branch is **reused** instead of rebuilt. Pass `-Rebuild` to force a clean recreate. -- Topology pytest runs by default. Pass `-SkipTests` to skip them (the Altium launcher always passes `-SkipTests`). -- Override any field on the command line, e.g. `-DeleteTestBranchFirst:$false`. +### Maintain (rebuild + publish) + +Uses **origin tips only** (unpushed local commits are ignored). Missing remotes abort. + +```powershell +pwsh scripts/maintain-test-combined.ps1 -Rebuild -Push +``` + +Omit `-Push` to rebuild locally while resolving merge conflicts, then push when clean. + +On conflict, only `.gitignore` and `FYPA.code-workspace` auto-resolve with `--ours`; other conflicts stop the script. + +Config resolution for maintain: `scripts/test-combined.json` (gitignored override) → `team/test-combined.json` → `team/local:team/test-combined.json` → example file. + +### GitHub Action (fork only) + +`.github/workflows/maintain-test-combined.yml` lives **only on `team/local`** — never commit it to `main` or to branches destined for an upstream PR. + +Triggers: `workflow_dispatch` (use `--ref team/local`) and pushes to `team/local` that touch `team/test-combined.json`. The job runs maintain with `-Rebuild -Push`. + +### Launch (no merge) ```powershell pwsh scripts/test-combined.ps1 -pwsh scripts/test-combined.ps1 -LocalOnly -pwsh scripts/test-combined.ps1 -Rebuild pwsh scripts/test-combined.ps1 -SkipTests +pwsh scripts/test-combined.ps1 -SkipTests -PrjPcb path\to\Board.PrjPcb ``` -Resolution order: `scripts/test-combined.json` (local override) → `team/test-combined.json` → `team/local:team/test-combined.json` → example file. +Checks out `origin/test/combined`, optionally runs topology pytest, then FYPA. `-Rebuild` is not supported here — use maintain. + +Altium bootstrap (`Run_FYPA.ps1`) calls `scripts/launch-combined-gui.ps1` after clone/`uv sync`: fetch + hard-reset to `origin/test/combined`, then `Launch_GUI.py`. + +### Typical flow + +1. Push the feature branch to `origin`. +2. Add it to `team/test-combined.json` on `team/local` and push `team/local`. +3. Run maintain (or let the Action rebuild) so `origin/test/combined` updates. +4. On any machine: Altium → Run FYPA → fetch + checkout — done. -For a one-off local config, copy `scripts/test-combined.example.json` to `scripts/test-combined.json` (gitignored). +Prefer clean feature branches in the JSON (not pre-merged `*-combined` stacks). ## Upstream pull requests diff --git a/scripts/launch-combined-gui.ps1 b/scripts/launch-combined-gui.ps1 new file mode 100644 index 0000000..70678b9 --- /dev/null +++ b/scripts/launch-combined-gui.ps1 @@ -0,0 +1,122 @@ +<# +.SYNOPSIS + Check out origin/test/combined and launch the GUI (Altium bootstrap path). + +.DESCRIPTION + Used by Run_FYPA.ps1 after clone/uv sync. Fetches the shared combined branch, + hard-resets to the remote tip, and runs Launch_GUI.py (or FYPA.py gui). + + Does not merge feature branches. Maintain the shared branch with: + pwsh scripts/maintain-test-combined.ps1 -Rebuild -Push + +.PARAMETER PrjPcb + Path to the focused .PrjPcb. + +.PARAMETER LaunchGui + Absolute path to Launch_GUI.py (outside the disposable clone). Optional. + +.PARAMETER Remote + Remote name. Default: origin + +.PARAMETER TestBranch + Combined branch name. Default: test/combined + +.PARAMETER RepoRoot + FYPA repo root. Default: parent of scripts/. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $PrjPcb, + + [string] $LaunchGui, + + [string] $Remote = "origin", + + [string] $TestBranch = "test/combined", + + [string] $RepoRoot +) + +$ErrorActionPreference = "Stop" + +if (-not $RepoRoot) { + $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +} +else { + $RepoRoot = (Resolve-Path -LiteralPath $RepoRoot).Path +} +Set-Location $RepoRoot + +if (-not (Test-Path -LiteralPath (Join-Path $RepoRoot 'FYPA.py'))) { + throw "FYPA.py not found in $RepoRoot" +} + +if (-not (Test-Path -LiteralPath $PrjPcb)) { + throw "PrjPcb not found: $PrjPcb" +} +$PrjPcbPath = (Resolve-Path -LiteralPath $PrjPcb).Path + +function Invoke-GitLogged { + param( + [Parameter(Mandatory, ValueFromRemainingArguments)] + [string[]] $GitArgs + ) + Write-Host (">> git {0}" -f ($GitArgs -join ' ')) -ForegroundColor DarkGray + & git.exe @GitArgs + if ($LASTEXITCODE -ne 0) { + throw "git $($GitArgs -join ' ') failed (exit $LASTEXITCODE)" + } +} + +$RemoteRef = "$Remote/$TestBranch" +Write-Host "==> Fetch $Remote $TestBranch" +& git.exe fetch $Remote $TestBranch 2>&1 | Out-Null +if ($LASTEXITCODE -ne 0) { + Write-Warning "git fetch $Remote $TestBranch failed; trying existing $RemoteRef" +} + +& git.exe rev-parse --verify "$RemoteRef^{commit}" 2>$null | Out-Null +if ($LASTEXITCODE -ne 0) { + throw @" +$RemoteRef not found. +Publish the shared branch first (on a maintainer machine or via the team/local Action): + + pwsh scripts/maintain-test-combined.ps1 -Rebuild -Push +"@ +} + +$Tip = ([string](& git.exe rev-parse --verify "$RemoteRef^{commit}")).Trim() +Write-Host "==> Checkout $TestBranch @ $Tip" +Invoke-GitLogged @('checkout', '-B', $TestBranch, $RemoteRef) +Invoke-GitLogged @('reset', '--hard', $RemoteRef) + +Write-Host "==> uv sync (on $TestBranch)" +& uv sync +if ($null -ne $LASTEXITCODE -and $LASTEXITCODE -ne 0) { + $venvPython = Join-Path $RepoRoot '.venv\Scripts\python.exe' + if (Test-Path -LiteralPath $venvPython) { + Write-Warning "uv sync failed; reusing existing .venv" + } + else { + throw "uv sync failed (exit $LASTEXITCODE)" + } +} + +Write-Host "==> Launch GUI" +$env:PYTHONUNBUFFERED = '1' +if ($LaunchGui -and (Test-Path -LiteralPath $LaunchGui)) { + Write-Host " Using Launch_GUI.py (File > Import style, GUI first)" + & uv run --extra spacemouse python $LaunchGui $PrjPcbPath +} +else { + if ($LaunchGui) { + Write-Warning "Launch_GUI.py missing at $LaunchGui — falling back to FYPA.py gui" + } + & uv run --extra spacemouse FYPA.py gui $PrjPcbPath +} + +if ($null -ne $LASTEXITCODE -and $LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} diff --git a/scripts/maintain-test-combined.ps1 b/scripts/maintain-test-combined.ps1 new file mode 100644 index 0000000..438e9a4 --- /dev/null +++ b/scripts/maintain-test-combined.ps1 @@ -0,0 +1,610 @@ +<# +.SYNOPSIS + Rebuild origin/test/combined from team/local config and optionally push. + +.DESCRIPTION + Reads team/test-combined.json (team/local by default), fetches base + extras + from origin, recreates the disposable test branch using remote tips only, + and optionally force-pushes with lease. + + Local unpushed commits are never merged — tips are always origin/. + Missing remote extras abort the run. + + Config resolution (first match wins): + scripts/test-combined.json local override (gitignored) + team/test-combined.json working tree + team/local:team/test-combined.json from team/local via git show + scripts/test-combined.example.json fallback + +.PARAMETER ConfigPath + Path or ref:path to a JSON config. Overrides the default search order. + +.PARAMETER TeamConfigRef + Git ref for team/test-combined.json via git show. Default: team/local + +.PARAMETER Remote + Remote name. Default: origin + +.PARAMETER Rebuild + Force recreate even when the stamp on the existing local test branch matches. + +.PARAMETER Push + After a successful rebuild (or reuse), push --force-with-lease to Remote. + +.PARAMETER BaseBranch / TestBranch / ExtraFeatureBranches / DeleteTestBranchFirst + Override individual config fields. + +.EXAMPLE + pwsh scripts/maintain-test-combined.ps1 -Rebuild -Push + +.EXAMPLE + pwsh scripts/maintain-test-combined.ps1 + Build locally without pushing (e.g. to resolve merge conflicts). +#> + +[CmdletBinding()] +param( + [string] $ConfigPath, + [string] $TeamConfigRef = "team/local", + [string] $Remote = "origin", + [switch] $Rebuild, + [switch] $Push, + [string] $BaseBranch, + [string] $TestBranch, + [string[]] $ExtraFeatureBranches, + [bool] $DeleteTestBranchFirst +) + +$ErrorActionPreference = "Stop" + +$RepoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +Set-Location $RepoRoot + +function Invoke-GitCore { + param( + [Parameter(Mandatory, ValueFromRemainingArguments)] + [string[]] $GitArgs, + [switch] $Quiet + ) + if ($GitArgs.Count -eq 0) { + throw "Invoke-GitCore: no arguments" + } + + $Output = @(& git.exe @GitArgs 2>&1) + $ExitCode = $LASTEXITCODE + + if (-not $Quiet) { + foreach ($Line in $Output) { + if ($Line -is [System.Management.Automation.ErrorRecord]) { + Write-Warning $Line.ToString() + } + else { + Write-Host $Line + } + } + } + + $Stdout = @( + $Output | + Where-Object { $_ -isnot [System.Management.Automation.ErrorRecord] } | + ForEach-Object { [string] $_ } + ) + + return @{ + ExitCode = $ExitCode + Output = $Stdout + } +} + +function Invoke-Git { + param( + [Parameter(Mandatory, ValueFromRemainingArguments)] + [string[]] $GitArgs + ) + $Result = Invoke-GitCore @GitArgs + if ($Result.ExitCode -ne 0) { + throw "git $($GitArgs -join ' ') failed (exit $($Result.ExitCode))" + } + return $Result.Output +} + +function Invoke-GitSoft { + param( + [Parameter(Mandatory, ValueFromRemainingArguments)] + [string[]] $GitArgs + ) + return (Invoke-GitCore @GitArgs).ExitCode +} + +function Test-GitRef { + param([string] $Ref) + & git show-ref --verify --quiet $Ref + return $LASTEXITCODE -eq 0 +} + +function Get-RefSha { + param([string] $Ref) + $Sha = ([string] (& git.exe rev-parse --verify "$Ref^{commit}" 2>$null)).Trim() + if ($LASTEXITCODE -ne 0 -or -not $Sha) { + return $null + } + return $Sha +} + +function Sync-RemoteBranches { + param( + [string] $RemoteName, + [string[]] $Branches + ) + + $UniqueBranches = @($Branches | Where-Object { $_ } | Select-Object -Unique) + if ($UniqueBranches.Count -eq 0) { + return + } + + Write-Host "==> Fetch $RemoteName $($UniqueBranches -join ', ')" + $Result = Invoke-GitCore -Quiet @(@('fetch', $RemoteName) + $UniqueBranches) + if ($Result.ExitCode -ne 0) { + $Detail = ($Result.Output -join "`n").Trim() + if ($Detail) { + throw "git fetch $RemoteName failed (exit $($Result.ExitCode)): $Detail" + } + throw "git fetch $RemoteName failed (exit $($Result.ExitCode))" + } +} + +function Resolve-RemoteTip { + param( + [string] $Branch, + [string] $RemoteName + ) + + $RemoteRef = "$RemoteName/$Branch" + if (-not (Test-GitRef "refs/remotes/$RemoteName/$Branch")) { + return $null + } + $Sha = Get-RefSha -Ref $RemoteRef + if (-not $Sha) { return $null } + return @{ + Branch = $Branch + MergeRef = $RemoteRef + Sha = $Sha + Source = 'remote' + } +} + +function Get-InputStamp { + param( + [string] $ConfigIdentity, + [string] $BaseName, + [string] $BaseSha, + [string[]] $ExtraPairs + ) + + $Parts = [System.Collections.Generic.List[string]]::new() + $Parts.Add("config=$ConfigIdentity") + $Parts.Add("base=$BaseName=$BaseSha") + foreach ($Pair in $ExtraPairs) { + if ($Pair) { $Parts.Add("extra=$Pair") } + } + return ($Parts -join '|') +} + +function Get-TestCombinedStamp { + param([string] $Commit) + if (-not $Commit) { return $null } + $Lines = @(& git.exe notes --ref=test-combined show $Commit 2>$null) + if ($LASTEXITCODE -ne 0) { + return $null + } + return (($Lines -join "`n").Trim()) +} + +function Set-TestCombinedStamp { + param( + [string] $Commit, + [string] $Stamp + ) + $ExitCode = Invoke-GitSoft @( + 'notes', '--ref=test-combined', 'add', '-f', '-m', $Stamp, $Commit + ) + if ($ExitCode -ne 0) { + Write-Warning "Could not write test-combined stamp note on $Commit" + } +} + +function ConvertTo-NormalizedStamp { + param([string] $Stamp) + if (-not $Stamp) { return $null } + $Normalized = $Stamp.Trim() -replace "`r`n", "`n" -replace "`r", "`n" + if ($Normalized.Contains("`n")) { + $Normalized = (($Normalized -split "`n") | ForEach-Object { $_.Trim() } | Where-Object { $_ }) -join '|' + } + return $Normalized +} + +function Test-MergeInProgress { + $MergeHead = & git.exe rev-parse -q --verify MERGE_HEAD 2>$null + return [bool] $MergeHead +} + +function Get-UnmergedPaths { + $Output = & git.exe diff --name-only --diff-filter=U 2>$null + if ($LASTEXITCODE -ne 0) { + return @() + } + return @($Output | Where-Object { $_ }) +} + +function Resolve-IgnoredMergeConflicts { + param( + [string[]] $IgnoredPaths, + [ValidateSet('ours', 'theirs')] + [string] $Prefer = 'ours' + ) + + foreach ($Path in (Get-UnmergedPaths)) { + if ($Path -in $IgnoredPaths) { + Write-Host "==> Auto-resolve merge conflict in $Path ($Prefer)" + Invoke-Git @('checkout', "--$Prefer", '--', $Path) + Invoke-Git @('add', '--', $Path) + } + } + + return @(Get-UnmergedPaths) +} + +function Merge-FeatureBranch { + param( + [string] $MergeRef, + [string] $ExtraBranch, + [string[]] $IgnoredPaths + ) + + $MergeMessage = "test: merge $ExtraBranch for combined testing" + $ExitCode = Invoke-GitSoft @( + 'merge', $MergeRef, '--no-edit', '-m', $MergeMessage + ) + if ($ExitCode -eq 0) { + return + } + + if (-not (Test-MergeInProgress)) { + throw "git merge $MergeRef failed (exit $ExitCode)" + } + + $Remaining = Resolve-IgnoredMergeConflicts -IgnoredPaths $IgnoredPaths -Prefer 'ours' + if ($Remaining.Count -gt 0) { + throw "Merge conflict in: $($Remaining -join ', ')" + } + + Invoke-Git @('commit', '--no-edit') +} + +function Get-CurrentBranch { + return ([string] (Invoke-Git @('branch', '--show-current') | Select-Object -First 1)).Trim() +} + +function Restore-DevBranch { + param([string] $Branch) + if ($Branch) { + Invoke-Git @('checkout', $Branch) + } +} + +function Get-GitConfigJson { + param( + [string[]] $Refs, + [string] $RepoPath = "team/test-combined.json" + ) + + foreach ($Ref in $Refs) { + if (-not $Ref) { continue } + $Spec = "${Ref}:${RepoPath}" + $Json = & git show $Spec 2>$null + if ($LASTEXITCODE -eq 0 -and $Json) { + return @{ Source = $Spec; Json = [string] $Json } + } + } + + return $null +} + +function Resolve-ConfigSource { + param( + [string] $ExplicitPath, + [string] $TeamRef + ) + + if ($ExplicitPath) { + if (Test-Path $ExplicitPath) { + return @{ + Source = (Resolve-Path $ExplicitPath).Path + Json = $null + } + } + if ($ExplicitPath -match ':') { + $Json = & git show $ExplicitPath 2>$null + if ($LASTEXITCODE -eq 0 -and $Json) { + return @{ Source = $ExplicitPath; Json = [string] $Json } + } + } + throw "Config file not found: $ExplicitPath" + } + + $LocalCandidates = @( + (Join-Path $RepoRoot "scripts/test-combined.json"), + (Join-Path $RepoRoot "team/test-combined.json") + ) + + foreach ($Candidate in $LocalCandidates) { + if (Test-Path $Candidate) { + return @{ + Source = (Resolve-Path $Candidate).Path + Json = $null + } + } + } + + $GitRefs = @( + $TeamRef, + "origin/$TeamRef" + ) + $FromGit = Get-GitConfigJson -Refs $GitRefs + if ($FromGit) { + return $FromGit + } + + $Example = Join-Path $RepoRoot "scripts/test-combined.example.json" + if (Test-Path $Example) { + Write-Warning "Using example config ($Example). Copy to scripts/test-combined.json or update team/local." + return @{ + Source = (Resolve-Path $Example).Path + Json = $null + } + } + + throw @" +No test-combined config found. +Fetch team/local (git fetch origin team/local) or create scripts/test-combined.json from scripts/test-combined.example.json. +"@ +} + +function Read-TestCombinedConfig { + param( + [string] $Source, + [string] $Json + ) + + try { + if ($Json) { + $Config = $Json | ConvertFrom-Json + } + else { + $Config = Get-Content -Raw -Path $Source | ConvertFrom-Json + } + } + catch { + throw "Failed to parse config JSON at '$Source': $_" + } + + foreach ($Required in @("baseBranch", "testBranch", "extraFeatureBranches")) { + if (-not ($Config.PSObject.Properties.Name -contains $Required)) { + throw "Config '$Source' is missing required field '$Required'." + } + } + + return $Config +} + +if (-not (Test-Path "FYPA.py")) { + throw "FYPA.py not found in $RepoRoot — run this script from the FYPA repo." +} + +# Soft-fetch team config ref so git show origin/team/local:... works. +if (-not $ConfigPath) { + try { + Sync-RemoteBranches -RemoteName $Remote -Branches @($TeamConfigRef) + } + catch { + Write-Warning "Fetch $Remote $TeamConfigRef failed; using existing refs if present." + Write-Warning "$_" + } +} + +$ConfigSource = Resolve-ConfigSource -ExplicitPath $ConfigPath -TeamRef $TeamConfigRef +Write-Host "==> Config: $($ConfigSource.Source)" +$Config = Read-TestCombinedConfig -Source $ConfigSource.Source -Json $ConfigSource.Json + +$BaseBranch = if ($PSBoundParameters.ContainsKey("BaseBranch")) { $BaseBranch } else { [string] $Config.baseBranch } +$TestBranch = if ($PSBoundParameters.ContainsKey("TestBranch")) { $TestBranch } else { [string] $Config.testBranch } +$ExtraFeatureBranches = if ($PSBoundParameters.ContainsKey("ExtraFeatureBranches")) { + $ExtraFeatureBranches +} +else { + @($Config.extraFeatureBranches | ForEach-Object { [string] $_ }) +} +$DeleteTestBranchFirst = if ($PSBoundParameters.ContainsKey("DeleteTestBranchFirst")) { + $DeleteTestBranchFirst +} +elseif ($Config.PSObject.Properties.Name -contains "deleteTestBranchFirst") { + [bool] $Config.deleteTestBranchFirst +} +else { + $false +} + +if (-not $BaseBranch) { throw "baseBranch is empty." } +if (-not $TestBranch) { throw "testBranch is empty." } + +Write-Host "==> Branch source: $Remote tips only (no local-ahead merge)" + +$ReturnBranch = Get-CurrentBranch +if (-not $ReturnBranch) { + throw "Could not determine the current branch (detached HEAD?). Check out a branch first." +} + +Sync-RemoteBranches -RemoteName $Remote -Branches (@($BaseBranch) + $ExtraFeatureBranches + @($TestBranch)) + +$BaseTarget = Resolve-RemoteTip -Branch $BaseBranch -RemoteName $Remote +if (-not $BaseTarget) { + throw "Base branch '$BaseBranch' not found as $Remote/$BaseBranch. Push it first." +} + +$BaseRef = $BaseTarget.MergeRef +$BaseSha = $BaseTarget.Sha +Write-Host "==> Base: $BaseRef" + +$ExtraStampPairs = [System.Collections.Generic.List[string]]::new() +$ResolvedExtras = [System.Collections.Generic.List[hashtable]]::new() +$MissingExtras = [System.Collections.Generic.List[string]]::new() +foreach ($ExtraBranch in $ExtraFeatureBranches) { + if (-not $ExtraBranch) { continue } + $ExtraTarget = Resolve-RemoteTip -Branch $ExtraBranch -RemoteName $Remote + if (-not $ExtraTarget) { + $MissingExtras.Add($ExtraBranch) + continue + } + Write-Host "==> Extra: $($ExtraTarget.MergeRef)" + $ExtraStampPairs.Add("$ExtraBranch=$($ExtraTarget.Sha)") + $ResolvedExtras.Add(@{ + Branch = $ExtraTarget.Branch + MergeRef = $ExtraTarget.MergeRef + }) +} + +if ($MissingExtras.Count -gt 0) { + throw @" +Missing on ${Remote}: $($MissingExtras -join ', '). +Push each feature branch before maintaining $TestBranch. +"@ +} + +$ConfigIdentity = @( + "base=$BaseBranch", + "test=$TestBranch", + "deleteFirst=$DeleteTestBranchFirst", + "extras=$($ExtraFeatureBranches -join ',')" +) -join ';' + +$DesiredStamp = ConvertTo-NormalizedStamp (Get-InputStamp ` + -ConfigIdentity $ConfigIdentity ` + -BaseName $BaseBranch ` + -BaseSha $BaseSha ` + -ExtraPairs @($ExtraStampPairs)) + +$TestBranchExists = Test-GitRef "refs/heads/$TestBranch" +$ExistingTip = if ($TestBranchExists) { Get-RefSha -Ref $TestBranch } else { $null } +$ExistingStamp = ConvertTo-NormalizedStamp (Get-TestCombinedStamp -Commit $ExistingTip) +$CanReuse = ( + -not $Rebuild -and + $TestBranchExists -and + $ExistingStamp -and + ($ExistingStamp -eq $DesiredStamp) +) + +if (-not $CanReuse -and $TestBranchExists -and -not $Rebuild) { + if (-not $ExistingStamp) { + Write-Host "==> No reuse stamp on $TestBranch — will rebuild" + } + else { + Write-Host "==> Stamp mismatch on $TestBranch — will rebuild" + } +} + +$IgnoredPaths = @('.gitignore', 'FYPA.code-workspace') +$Status = @(Invoke-Git @('status', '--porcelain')) +$BlockingStatus = @($Status | Where-Object { + $path = $_.Substring(3).Trim() + if ($path -match ' -> ') { $path = ($path -split ' -> ', 2)[-1].Trim() } + elseif ($path -match "`t") { $path = ($path -split "`t", 2)[-1].Trim() } + $path -notin $IgnoredPaths +}) +if ($BlockingStatus.Count -gt 0) { + throw @" +Uncommitted changes detected on '$ReturnBranch'. +Commit or stash them before running maintain-test-combined. +"@ +} + +$Returned = $false +try { + if ($CanReuse) { + Write-Host "==> Reuse $TestBranch (inputs unchanged)" + if ((Get-CurrentBranch) -ne $TestBranch) { + Invoke-Git @('checkout', $TestBranch) + } + } + else { + if ($Rebuild) { + Write-Host "==> Rebuild requested — recreating $TestBranch" + } + elseif (-not $TestBranchExists) { + Write-Host "==> $TestBranch missing — creating from $BaseRef" + } + else { + Write-Host "==> Inputs changed — recreating $TestBranch from $BaseRef" + } + + if ($DeleteTestBranchFirst -and (Test-GitRef "refs/heads/$TestBranch")) { + Write-Host "==> Delete $TestBranch" + if ((Get-CurrentBranch) -eq $TestBranch) { + Invoke-Git @('checkout', $ReturnBranch) + } + Invoke-Git @('branch', '-D', $TestBranch) + } + + if (Test-GitRef "refs/heads/$TestBranch") { + Write-Host "==> Recreate $TestBranch from $BaseRef" + Invoke-Git @('branch', '-f', $TestBranch, $BaseRef) + Invoke-Git @('checkout', $TestBranch) + } + else { + Write-Host "==> Create $TestBranch from $BaseRef" + Invoke-Git @('checkout', '-b', $TestBranch, $BaseRef) + } + + foreach ($Extra in $ResolvedExtras) { + Write-Host "==> Merge $($Extra.MergeRef) into $TestBranch" + Merge-FeatureBranch -MergeRef $Extra.MergeRef -ExtraBranch $Extra.Branch -IgnoredPaths $IgnoredPaths + } + + $NewTip = Get-RefSha -Ref 'HEAD' + if ($NewTip) { + Set-TestCombinedStamp -Commit $NewTip -Stamp $DesiredStamp + Write-Host "==> Stamp written for $TestBranch" + } + } + + $Tip = Get-RefSha -Ref 'HEAD' + Write-Host "==> $TestBranch tip: $Tip" + + if ($Push) { + Write-Host "==> Push --force-with-lease $Remote $TestBranch" + Invoke-Git @('push', '--force-with-lease', $Remote, "HEAD:refs/heads/$TestBranch") + Write-Host "==> Pushed $Remote/$TestBranch" + } + else { + Write-Host "==> Local only (pass -Push to update $Remote/$TestBranch)" + } +} +catch { + if ((Get-CurrentBranch) -ne $ReturnBranch) { + & git merge --abort 2>$null | Out-Null + & git rebase --abort 2>$null | Out-Null + } + throw +} +finally { + $Current = Get-CurrentBranch + if ($Current -ne $ReturnBranch) { + Write-Host "==> Return to $ReturnBranch" + Restore-DevBranch -Branch $ReturnBranch + $Returned = $true + } +} + +if (-not $Returned) { + Write-Host "==> Return to $ReturnBranch" + Restore-DevBranch -Branch $ReturnBranch +} diff --git a/scripts/test-combined.ps1 b/scripts/test-combined.ps1 index cbba5b9..3ce0138 100644 --- a/scripts/test-combined.ps1 +++ b/scripts/test-combined.ps1 @@ -1,125 +1,43 @@ <# .SYNOPSIS - Build a local combined test branch, run tests/FYPA, then switch back. + Check out origin/test/combined, optionally run tests/FYPA, then switch back. .DESCRIPTION - Merges a base branch plus feature branches into a disposable test branch, - optionally runs the pytest topology suite and FYPA.py, then returns to the - branch you started on (even if a step exits with an error). - - Config resolution (first match wins): - scripts/test-combined.json local override (gitignored) - team/test-combined.json working tree - team/local:team/test-combined.json from team/local via git show (no checkout) - scripts/test-combined.example.json fallback - - By default baseBranch and extraFeatureBranches are soft-fetched from origin. - Each input is resolved to the tip that includes local work: if the local - branch is ahead of (or diverged from) origin/, the local tip is - merged; if local is behind, origin/ is used; local-only or - remote-only branches are accepted either way. If fetch fails (offline), - existing refs are resolved the same way. - - When input SHAs match the stamp on an existing test branch, that branch is - reused instead of rebuilt. Pass -Rebuild to force a clean recreate. - Pass -LocalOnly to skip fetch and use local branches only. - - Workflow: - 1. Remember current branch - 2. Soft-fetch inputs (or local-only); resolve tips (prefer local when ahead); - reuse test branch if stamp matches - 3. Otherwise optionally delete, recreate from base, merge feature branches - (.gitignore conflicts auto-resolved with --ours); write stamp note - 4. Optionally run pytest topology suite (-SkipTests to skip), then uv run FYPA.py - 5. Return to the starting branch - -.PARAMETER ConfigPath - Path to a JSON config file. Overrides the default config search order. - -.PARAMETER TeamConfigRef - Git ref used when reading team/test-combined.json via git show. - Default: team/local + The combined branch is maintained centrally (see scripts/maintain-test-combined.ps1). + This script only fetches and checks out the remote tip — it does not merge + feature branches. + + To rebuild and push test/combined: + pwsh scripts/maintain-test-combined.ps1 -Rebuild -Push .PARAMETER Remote - Remote name used for soft-fetch and tip resolution. Default: origin + Remote name. Default: origin -.PARAMETER LocalOnly - Skip fetch; resolve and merge local branches only. +.PARAMETER TestBranch + Combined branch name. Default: test/combined .PARAMETER Rebuild - Force delete/recreate of the test branch even when the stamp matches. + Not supported here — prints how to run maintain-test-combined.ps1 and exits 1. .PARAMETER SkipTests Skip the pytest topology suite; still runs FYPA.py unless the script exits earlier. -.PARAMETER BaseBranch - Override config baseBranch (branch the test branch is created from). - -.PARAMETER TestBranch - Override config testBranch (name of the disposable combined branch). - -.PARAMETER ExtraFeatureBranches - Override config extraFeatureBranches (branches merged onto the base). - -.PARAMETER DeleteTestBranchFirst - Override config deleteTestBranchFirst. When true, delete the existing test - branch before recreating it. - .PARAMETER PrjPcb Path to a .PrjPcb passed through to FYPA.py. .EXAMPLE pwsh scripts/test-combined.ps1 - Use default config resolution, soft-fetch, reuse or rebuild the test branch, - run tests and FYPA, then switch back. - .EXAMPLE - pwsh scripts/test-combined.ps1 -LocalOnly - - Skip remote fetch; use local branch tips only. - -.EXAMPLE - pwsh scripts/test-combined.ps1 -Rebuild - - Force a clean recreate of the test branch. - -.EXAMPLE - pwsh scripts/test-combined.ps1 -SkipTests - - Build/reuse the test branch and run FYPA without pytest. - -.EXAMPLE - pwsh scripts/test-combined.ps1 -ConfigPath scripts/test-combined.json - - Use an explicit local config file. - -.EXAMPLE - pwsh scripts/test-combined.ps1 -PrjPcb path\to\YourBoard.PrjPcb - - Pass a project file through to FYPA.py. - -.EXAMPLE - Get-Help .\scripts\test-combined.ps1 -Full - - Show this help. Equivalent: pwsh scripts/test-combined.ps1 -? - -.NOTES - Run from the repo root (or any branch); the script cds to the repo root itself. + pwsh scripts/test-combined.ps1 -SkipTests -PrjPcb path\to\Board.PrjPcb #> [CmdletBinding()] param( - [string] $ConfigPath, - [string] $TeamConfigRef = "team/local", [string] $Remote = "origin", - [switch] $LocalOnly, + [string] $TestBranch = "test/combined", [switch] $Rebuild, [switch] $SkipTests, - [string] $BaseBranch, - [string] $TestBranch, - [string[]] $ExtraFeatureBranches, - [bool] $DeleteTestBranchFirst, [string] $PrjPcb ) @@ -128,6 +46,18 @@ $ErrorActionPreference = "Stop" $RepoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") Set-Location $RepoRoot +if ($Rebuild) { + Write-Error @" +-Rebuild is no longer supported by test-combined.ps1. +Rebuild and publish the shared branch with: + + pwsh scripts/maintain-test-combined.ps1 -Rebuild -Push + +Then re-run this script to check out $Remote/$TestBranch. +"@ + exit 1 +} + function Invoke-GitCore { param( [Parameter(Mandatory, ValueFromRemainingArguments)] @@ -176,12 +106,8 @@ function Invoke-Git { return $Result.Output } -function Invoke-GitSoft { - param( - [Parameter(Mandatory, ValueFromRemainingArguments)] - [string[]] $GitArgs - ) - return (Invoke-GitCore @GitArgs).ExitCode +function Get-CurrentBranch { + return ([string] (Invoke-Git @('branch', '--show-current') | Select-Object -First 1)).Trim() } function Test-GitRef { @@ -190,408 +116,10 @@ function Test-GitRef { return $LASTEXITCODE -eq 0 } -function Test-GitAncestor { - param( - [string] $Ancestor, - [string] $Descendant - ) - & git.exe merge-base --is-ancestor $Ancestor $Descendant 2>$null | Out-Null - return $LASTEXITCODE -eq 0 -} - -function Resolve-BranchMergeTarget { - param( - [string] $Branch, - [string] $RemoteName, - [bool] $UseLocalOnly - ) - - $LocalRef = $Branch - $RemoteRef = "$RemoteName/$Branch" - $HasLocal = Test-GitRef "refs/heads/$Branch" - $HasRemote = Test-GitRef "refs/remotes/$RemoteName/$Branch" - - if ($UseLocalOnly) { - if (-not $HasLocal) { return $null } - $Sha = Get-RefSha -Ref $LocalRef - if (-not $Sha) { return $null } - return @{ - Branch = $Branch - MergeRef = $LocalRef - Sha = $Sha - Source = 'local' - } - } - - if ($HasLocal -and $HasRemote) { - $LocalSha = Get-RefSha -Ref $LocalRef - $RemoteSha = Get-RefSha -Ref $RemoteRef - if (-not $LocalSha -and -not $RemoteSha) { return $null } - if (-not $LocalSha) { - return @{ - Branch = $Branch - MergeRef = $RemoteRef - Sha = $RemoteSha - Source = 'remote' - } - } - if (-not $RemoteSha) { - return @{ - Branch = $Branch - MergeRef = $LocalRef - Sha = $LocalSha - Source = 'local' - } - } - if ($LocalSha -eq $RemoteSha) { - return @{ - Branch = $Branch - MergeRef = $LocalRef - Sha = $LocalSha - Source = 'local' - } - } - # Local contains remote → unpushed local commits; keep them. - if (Test-GitAncestor -Ancestor $RemoteSha -Descendant $LocalSha) { - Write-Host "==> ${Branch}: using local (ahead of $RemoteRef)" - return @{ - Branch = $Branch - MergeRef = $LocalRef - Sha = $LocalSha - Source = 'local' - } - } - # Remote contains local → local checkout is stale; take remote. - if (Test-GitAncestor -Ancestor $LocalSha -Descendant $RemoteSha) { - return @{ - Branch = $Branch - MergeRef = $RemoteRef - Sha = $RemoteSha - Source = 'remote' - } - } - # Diverged: never drop local work. - Write-Warning "${Branch}: local and $RemoteRef have diverged — using local tip" - return @{ - Branch = $Branch - MergeRef = $LocalRef - Sha = $LocalSha - Source = 'local' - } - } - - if ($HasLocal) { - $Sha = Get-RefSha -Ref $LocalRef - if (-not $Sha) { return $null } - Write-Host "==> ${Branch}: no $RemoteRef — using local" - return @{ - Branch = $Branch - MergeRef = $LocalRef - Sha = $Sha - Source = 'local' - } - } - - if ($HasRemote) { - $Sha = Get-RefSha -Ref $RemoteRef - if (-not $Sha) { return $null } - return @{ - Branch = $Branch - MergeRef = $RemoteRef - Sha = $Sha - Source = 'remote' - } - } - - return $null -} - -function Sync-RemoteBranches { - param( - [string] $RemoteName, - [string[]] $Branches - ) - - $UniqueBranches = @($Branches | Where-Object { $_ } | Select-Object -Unique) - if ($UniqueBranches.Count -eq 0) { - return - } - - Write-Host "==> Fetch $RemoteName $($UniqueBranches -join ', ')" - # git writes progress to stderr; don't surface it as PowerShell warnings. - $Result = Invoke-GitCore -Quiet @(@('fetch', $RemoteName) + $UniqueBranches) - if ($Result.ExitCode -ne 0) { - $Detail = ($Result.Output -join "`n").Trim() - if ($Detail) { - throw "git fetch $RemoteName failed (exit $($Result.ExitCode)): $Detail" - } - throw "git fetch $RemoteName failed (exit $($Result.ExitCode))" - } -} - -function Get-RefSha { - param([string] $Ref) - $Sha = ([string] (& git.exe rev-parse --verify "$Ref^{commit}" 2>$null)).Trim() - if ($LASTEXITCODE -ne 0 -or -not $Sha) { - return $null - } - return $Sha -} - -function Get-InputStamp { - param( - [string] $ConfigIdentity, - [string] $BaseName, - [string] $BaseSha, - [string[]] $ExtraPairs - ) - - # Single-line stamp: PowerShell [string] casts of multi-line git output join - # with spaces and would break equality checks if we used newlines. - $Parts = [System.Collections.Generic.List[string]]::new() - $Parts.Add("config=$ConfigIdentity") - $Parts.Add("base=$BaseName=$BaseSha") - foreach ($Pair in $ExtraPairs) { - if ($Pair) { $Parts.Add("extra=$Pair") } - } - return ($Parts -join '|') -} - -function Get-TestCombinedStamp { - param([string] $Commit) - if (-not $Commit) { return $null } - $Lines = @(& git.exe notes --ref=test-combined show $Commit 2>$null) - if ($LASTEXITCODE -ne 0) { - return $null - } - # Join exactly as written; Trim only outer whitespace. - return (($Lines -join "`n").Trim()) -} - -function Set-TestCombinedStamp { - param( - [string] $Commit, - [string] $Stamp - ) - $ExitCode = Invoke-GitSoft @( - 'notes', '--ref=test-combined', 'add', '-f', '-m', $Stamp, $Commit - ) - if ($ExitCode -ne 0) { - Write-Warning "Could not write test-combined stamp note on $Commit" - } -} - -function ConvertTo-NormalizedStamp { - param([string] $Stamp) - if (-not $Stamp) { return $null } - # Accept legacy multiline notes (joined with `n) and new single-line (`|`) form. - $Normalized = $Stamp.Trim() -replace "`r`n", "`n" -replace "`r", "`n" - if ($Normalized.Contains("`n")) { - $Normalized = (($Normalized -split "`n") | ForEach-Object { $_.Trim() } | Where-Object { $_ }) -join '|' - } - return $Normalized -} - -function Test-MergeInProgress { - $MergeHead = & git.exe rev-parse -q --verify MERGE_HEAD 2>$null - return [bool] $MergeHead -} - -function Get-UnmergedPaths { - $Output = & git.exe diff --name-only --diff-filter=U 2>$null - if ($LASTEXITCODE -ne 0) { - return @() - } - return @($Output | Where-Object { $_ }) -} - -function Resolve-IgnoredMergeConflicts { - param( - [string[]] $IgnoredPaths, - [ValidateSet('ours', 'theirs')] - [string] $Prefer = 'ours' - ) - - foreach ($Path in (Get-UnmergedPaths)) { - if ($Path -in $IgnoredPaths) { - Write-Host "==> Auto-resolve merge conflict in $Path ($Prefer)" - Invoke-Git @('checkout', "--$Prefer", '--', $Path) - Invoke-Git @('add', '--', $Path) - } - } - - return @(Get-UnmergedPaths) -} - -function Merge-FeatureBranch { - param( - [string] $MergeRef, - [string] $ExtraBranch, - [string[]] $IgnoredPaths - ) - - $MergeMessage = "test: merge $ExtraBranch for local testing" - $ExitCode = Invoke-GitSoft @( - 'merge', $MergeRef, '--no-edit', '-m', $MergeMessage - ) - if ($ExitCode -eq 0) { - return - } - - if (-not (Test-MergeInProgress)) { - throw "git merge $MergeRef failed (exit $ExitCode)" - } - - $Remaining = Resolve-IgnoredMergeConflicts -IgnoredPaths $IgnoredPaths -Prefer 'ours' - if ($Remaining.Count -gt 0) { - throw "Merge conflict in: $($Remaining -join ', ')" - } - - Invoke-Git @('commit', '--no-edit') -} - -function Get-CurrentBranch { - return ([string] (Invoke-Git @('branch', '--show-current') | Select-Object -First 1)).Trim() -} - -function Restore-DevBranch { - param([string] $Branch) - if ($Branch) { - Invoke-Git @('checkout', $Branch) - } -} - -function Get-GitConfigJson { - param( - [string[]] $Refs, - [string] $RepoPath = "team/test-combined.json" - ) - - foreach ($Ref in $Refs) { - if (-not $Ref) { continue } - $Spec = "${Ref}:${RepoPath}" - $Json = & git show $Spec 2>$null - if ($LASTEXITCODE -eq 0 -and $Json) { - return @{ Source = $Spec; Json = [string] $Json } - } - } - - return $null -} - -function Resolve-ConfigSource { - param( - [string] $ExplicitPath, - [string] $TeamRef - ) - - if ($ExplicitPath) { - if (Test-Path $ExplicitPath) { - return @{ - Source = (Resolve-Path $ExplicitPath).Path - Json = $null - } - } - if ($ExplicitPath -match ':') { - $Json = & git show $ExplicitPath 2>$null - if ($LASTEXITCODE -eq 0 -and $Json) { - return @{ Source = $ExplicitPath; Json = [string] $Json } - } - } - throw "Config file not found: $ExplicitPath" - } - - $LocalCandidates = @( - (Join-Path $RepoRoot "scripts/test-combined.json"), - (Join-Path $RepoRoot "team/test-combined.json") - ) - - foreach ($Candidate in $LocalCandidates) { - if (Test-Path $Candidate) { - return @{ - Source = (Resolve-Path $Candidate).Path - Json = $null - } - } - } - - $GitRefs = @( - $TeamRef, - "origin/$TeamRef" - ) - $FromGit = Get-GitConfigJson -Refs $GitRefs - if ($FromGit) { - return $FromGit - } - - $Example = Join-Path $RepoRoot "scripts/test-combined.example.json" - if (Test-Path $Example) { - Write-Warning "Using example config ($Example). Copy to scripts/test-combined.json or update team/local." - return @{ - Source = (Resolve-Path $Example).Path - Json = $null - } - } - - throw @" -No test-combined config found. -Fetch team/local (git fetch origin team/local) or create scripts/test-combined.json from scripts/test-combined.example.json. -"@ -} - -function Read-TestCombinedConfig { - param( - [string] $Source, - [string] $Json - ) - - try { - if ($Json) { - $Config = $Json | ConvertFrom-Json - } - else { - $Config = Get-Content -Raw -Path $Source | ConvertFrom-Json - } - } - catch { - throw "Failed to parse config JSON at '$Source': $_" - } - - foreach ($Required in @("baseBranch", "testBranch", "extraFeatureBranches")) { - if (-not ($Config.PSObject.Properties.Name -contains $Required)) { - throw "Config '$Source' is missing required field '$Required'." - } - } - - return $Config -} - if (-not (Test-Path "FYPA.py")) { throw "FYPA.py not found in $RepoRoot — run this script from the FYPA repo." } -$ConfigSource = Resolve-ConfigSource -ExplicitPath $ConfigPath -TeamRef $TeamConfigRef -Write-Host "==> Config: $($ConfigSource.Source)" -$Config = Read-TestCombinedConfig -Source $ConfigSource.Source -Json $ConfigSource.Json - -$BaseBranch = if ($PSBoundParameters.ContainsKey("BaseBranch")) { $BaseBranch } else { [string] $Config.baseBranch } -$TestBranch = if ($PSBoundParameters.ContainsKey("TestBranch")) { $TestBranch } else { [string] $Config.testBranch } -$ExtraFeatureBranches = if ($PSBoundParameters.ContainsKey("ExtraFeatureBranches")) { - $ExtraFeatureBranches -} -else { - @($Config.extraFeatureBranches | ForEach-Object { [string] $_ }) -} -$DeleteTestBranchFirst = if ($PSBoundParameters.ContainsKey("DeleteTestBranchFirst")) { - $DeleteTestBranchFirst -} -elseif ($Config.PSObject.Properties.Name -contains "deleteTestBranchFirst") { - [bool] $Config.deleteTestBranchFirst -} -else { - $false -} - $PrjPcbPath = $null if ($PrjPcb) { if (-not (Test-Path -LiteralPath $PrjPcb)) { @@ -600,96 +128,11 @@ if ($PrjPcb) { $PrjPcbPath = (Resolve-Path -LiteralPath $PrjPcb).Path } -if (-not $BaseBranch) { throw "baseBranch is empty." } -if (-not $TestBranch) { throw "testBranch is empty." } - -$UseLocalOnly = [bool] $LocalOnly -if ($UseLocalOnly) { - Write-Host "==> Branch source: local only" -} -else { - Write-Host "==> Branch source: $Remote (soft-fetch; prefer local when ahead/diverged)" -} - $ReturnBranch = Get-CurrentBranch if (-not $ReturnBranch) { throw "Could not determine the current branch." } -if (-not $UseLocalOnly) { - try { - Sync-RemoteBranches -RemoteName $Remote -Branches (@($BaseBranch) + $ExtraFeatureBranches) - } - catch { - Write-Warning "Fetch from $Remote failed; resolving from existing local/remote-tracking refs." - Write-Warning "$_" - } -} - -$BaseTarget = Resolve-BranchMergeTarget -Branch $BaseBranch -RemoteName $Remote -UseLocalOnly $UseLocalOnly -if (-not $BaseTarget) { - $Where = if ($UseLocalOnly) { "locally" } else { "locally or as $Remote/$BaseBranch" } - throw "Base branch '$BaseBranch' not found $Where." -} - -$BaseRef = $BaseTarget.MergeRef -$BaseSha = $BaseTarget.Sha -Write-Host "==> Base: $BaseRef ($($BaseTarget.Source))" - -$ExtraStampPairs = [System.Collections.Generic.List[string]]::new() -$ResolvedExtras = [System.Collections.Generic.List[hashtable]]::new() -foreach ($ExtraBranch in $ExtraFeatureBranches) { - if (-not $ExtraBranch) { continue } - $ExtraTarget = Resolve-BranchMergeTarget -Branch $ExtraBranch -RemoteName $Remote -UseLocalOnly $UseLocalOnly - if (-not $ExtraTarget) { - $Where = if ($UseLocalOnly) { "locally" } else { "locally or on $Remote" } - Write-Warning "Extra feature branch '$ExtraBranch' not found $Where — continuing without it." - continue - } - Write-Host "==> Extra: $($ExtraTarget.MergeRef) ($($ExtraTarget.Source))" - $ExtraStampPairs.Add("$ExtraBranch=$($ExtraTarget.Sha)") - $ResolvedExtras.Add(@{ - Branch = $ExtraTarget.Branch - MergeRef = $ExtraTarget.MergeRef - }) -} - -$ConfigIdentity = @( - "base=$BaseBranch", - "test=$TestBranch", - "deleteFirst=$DeleteTestBranchFirst", - "extras=$($ExtraFeatureBranches -join ',')" -) -join ';' - -$DesiredStamp = ConvertTo-NormalizedStamp (Get-InputStamp ` - -ConfigIdentity $ConfigIdentity ` - -BaseName $BaseBranch ` - -BaseSha $BaseSha ` - -ExtraPairs @($ExtraStampPairs)) - -$TestBranchExists = Test-GitRef "refs/heads/$TestBranch" -$ExistingTip = if ($TestBranchExists) { Get-RefSha -Ref $TestBranch } else { $null } -$ExistingStampRaw = $null -if ($ExistingTip) { - $ExistingStampRaw = Get-TestCombinedStamp -Commit $ExistingTip -} -$ExistingStamp = ConvertTo-NormalizedStamp $ExistingStampRaw -$CanReuse = ( - -not $Rebuild -and - $TestBranchExists -and - $ExistingStamp -and - ($ExistingStamp -eq $DesiredStamp) -) - -if (-not $CanReuse -and $TestBranchExists -and -not $Rebuild) { - if (-not $ExistingStamp) { - Write-Host "==> No reuse stamp on $TestBranch — will rebuild" - } - else { - Write-Host "==> Stamp mismatch on $TestBranch — will rebuild" - } -} - $IgnoredPaths = @('.gitignore', 'FYPA.code-workspace') $Status = @(Invoke-Git @('status', '--porcelain')) $BlockingStatus = @($Status | Where-Object { @@ -705,55 +148,27 @@ Commit or stash them before running the test script. "@ } +$RemoteRef = "$Remote/$TestBranch" +Write-Host "==> Fetch $Remote $TestBranch" +$FetchResult = Invoke-GitCore -Quiet @('fetch', $Remote, $TestBranch) +if ($FetchResult.ExitCode -ne 0) { + Write-Warning "Fetch failed; using existing $RemoteRef if present." +} + +if (-not (Test-GitRef "refs/remotes/$Remote/$TestBranch")) { + throw @" +$RemoteRef not found. +Publish the shared branch first: + pwsh scripts/maintain-test-combined.ps1 -Rebuild -Push +"@ +} + $Returned = $false $FypaExit = 0 try { - if ($CanReuse) { - Write-Host "==> Reuse $TestBranch (inputs unchanged)" - if ((Get-CurrentBranch) -ne $TestBranch) { - Invoke-Git @('checkout', $TestBranch) - } - } - else { - if ($Rebuild) { - Write-Host "==> Rebuild requested — recreating $TestBranch" - } - elseif (-not $TestBranchExists) { - Write-Host "==> $TestBranch missing — creating from $BaseRef" - } - else { - Write-Host "==> Inputs changed — recreating $TestBranch from $BaseRef" - } - - if ($DeleteTestBranchFirst -and (Test-GitRef "refs/heads/$TestBranch")) { - Write-Host "==> Delete $TestBranch" - if ((Get-CurrentBranch) -eq $TestBranch) { - Invoke-Git @('checkout', $ReturnBranch) - } - Invoke-Git @('branch', '-D', $TestBranch) - } - - if (Test-GitRef "refs/heads/$TestBranch") { - Write-Host "==> Recreate $TestBranch from $BaseRef" - Invoke-Git @('branch', '-f', $TestBranch, $BaseRef) - Invoke-Git @('checkout', $TestBranch) - } - else { - Write-Host "==> Create $TestBranch from $BaseRef" - Invoke-Git @('checkout', '-b', $TestBranch, $BaseRef) - } - - foreach ($Extra in $ResolvedExtras) { - Write-Host "==> Merge $($Extra.MergeRef) into $TestBranch" - Merge-FeatureBranch -MergeRef $Extra.MergeRef -ExtraBranch $Extra.Branch -IgnoredPaths $IgnoredPaths - } - - $NewTip = Get-RefSha -Ref 'HEAD' - if ($NewTip) { - Set-TestCombinedStamp -Commit $NewTip -Stamp $DesiredStamp - Write-Host "==> Stamp written for $TestBranch" - } - } + Write-Host "==> Checkout $TestBranch from $RemoteRef" + Invoke-Git @('checkout', '-B', $TestBranch, $RemoteRef) + Invoke-Git @('reset', '--hard', $RemoteRef) if ($SkipTests) { Write-Host "==> Skip pytest (-SkipTests)" @@ -783,24 +198,20 @@ try { $FypaExit = $LASTEXITCODE } catch { - if (Get-CurrentBranch -ne $ReturnBranch) { - & git merge --abort 2>$null | Out-Null - & git rebase --abort 2>$null | Out-Null - } throw } finally { $Current = Get-CurrentBranch if ($Current -ne $ReturnBranch) { Write-Host "==> Return to $ReturnBranch" - Restore-DevBranch -Branch $ReturnBranch + Invoke-Git @('checkout', $ReturnBranch) $Returned = $true } } if (-not $Returned) { Write-Host "==> Return to $ReturnBranch" - Restore-DevBranch -Branch $ReturnBranch + Invoke-Git @('checkout', $ReturnBranch) } if ($FypaExit -and $FypaExit -ne 0) { From 5c27b28ffb6783d8df62f3be7383e335b68f4c95 Mon Sep 17 00:00:00 2001 From: Carsten Schurig Date: Wed, 5 Aug 2026 15:29:56 +0200 Subject: [PATCH 08/16] Centralize test/combined: soft-fetch missing test branch First publish has no origin/test/combined yet; do not fail the input fetch when that ref is absent. Co-authored-by: Cursor --- scripts/maintain-test-combined.ps1 | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/maintain-test-combined.ps1 b/scripts/maintain-test-combined.ps1 index 438e9a4..64ceaf7 100644 --- a/scripts/maintain-test-combined.ps1 +++ b/scripts/maintain-test-combined.ps1 @@ -444,7 +444,14 @@ if (-not $ReturnBranch) { throw "Could not determine the current branch (detached HEAD?). Check out a branch first." } -Sync-RemoteBranches -RemoteName $Remote -Branches (@($BaseBranch) + $ExtraFeatureBranches + @($TestBranch)) +Sync-RemoteBranches -RemoteName $Remote -Branches (@($BaseBranch) + $ExtraFeatureBranches) +# test/combined may not exist yet on first publish — soft-fetch only. +try { + Sync-RemoteBranches -RemoteName $Remote -Branches @($TestBranch) +} +catch { + Write-Host "==> $Remote/$TestBranch not fetched yet (ok on first publish)" +} $BaseTarget = Resolve-RemoteTip -Branch $BaseBranch -RemoteName $Remote if (-not $BaseTarget) { From fe17b9916a931d245aed897ef2f40da96f6fc1c8 Mon Sep 17 00:00:00 2001 From: Carsten Schurig Date: Wed, 5 Aug 2026 15:31:16 +0200 Subject: [PATCH 09/16] maintain-test-combined: stay on branch when merge conflicts Leave the in-progress merge on test/combined so conflicts can be resolved without losing MERGE_HEAD. Co-authored-by: Cursor --- scripts/maintain-test-combined.ps1 | 37 ++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/scripts/maintain-test-combined.ps1 b/scripts/maintain-test-combined.ps1 index 64ceaf7..f76c6ae 100644 --- a/scripts/maintain-test-combined.ps1 +++ b/scripts/maintain-test-combined.ps1 @@ -535,6 +535,7 @@ Commit or stash them before running maintain-test-combined. } $Returned = $false +$LeaveOnConflict = $false try { if ($CanReuse) { Write-Host "==> Reuse $TestBranch (inputs unchanged)" @@ -596,22 +597,40 @@ try { } } catch { - if ((Get-CurrentBranch) -ne $ReturnBranch) { + $msg = "$_" + if ($msg -match 'Merge conflict' -and (Test-MergeInProgress)) { + $LeaveOnConflict = $true + Write-Host @" + +==> Merge conflict — staying on $TestBranch with the conflict in place. +Resolve, commit, then: + + git notes --ref=test-combined add -f -m '' HEAD # optional + git push --force-with-lease $Remote HEAD:refs/heads/$TestBranch + +Or re-run: pwsh scripts/maintain-test-combined.ps1 -Rebuild -Push +"@ + } + elseif ((Get-CurrentBranch) -ne $ReturnBranch) { & git merge --abort 2>$null | Out-Null & git rebase --abort 2>$null | Out-Null } throw } finally { - $Current = Get-CurrentBranch - if ($Current -ne $ReturnBranch) { - Write-Host "==> Return to $ReturnBranch" - Restore-DevBranch -Branch $ReturnBranch - $Returned = $true + if (-not $LeaveOnConflict) { + $Current = Get-CurrentBranch + if ($Current -ne $ReturnBranch) { + Write-Host "==> Return to $ReturnBranch" + Restore-DevBranch -Branch $ReturnBranch + $Returned = $true + } } } -if (-not $Returned) { - Write-Host "==> Return to $ReturnBranch" - Restore-DevBranch -Branch $ReturnBranch +if (-not $LeaveOnConflict) { + if (-not $Returned) { + Write-Host "==> Return to $ReturnBranch" + Restore-DevBranch -Branch $ReturnBranch + } } From 1f5db126e0c446825881b7e787165e93defe2a3a Mon Sep 17 00:00:00 2001 From: Carsten Schurig Date: Wed, 5 Aug 2026 16:16:33 +0200 Subject: [PATCH 10/16] maintain-test-combined: incremental default + -Abort escape hatch Default updates from origin/test/combined and merges only missing tips so prior conflict resolutions survive. -Rebuild stays the clean recreate. -Abort resets a stuck test/combined without forcing a dirty checkout. Document the finish/abort conflict path in fork-workflow.md. Co-authored-by: Cursor --- docs/fork-workflow.md | 35 ++--- scripts/maintain-test-combined.ps1 | 208 +++++++++++++++++++++++------ 2 files changed, 184 insertions(+), 59 deletions(-) diff --git a/docs/fork-workflow.md b/docs/fork-workflow.md index 1180579..ecdf437 100644 --- a/docs/fork-workflow.md +++ b/docs/fork-workflow.md @@ -14,7 +14,7 @@ Daily development can use `team/local` or feature branches. **Do not merge `team ## Combined test (`test/combined`) -Feature branches listed in `team/test-combined.json` on `team/local` are merged **once** into the shared `test/combined` branch and pushed to GitHub. Machines only fetch and check out that tip — they do not merge at Altium/FYPA start. +Feature branches listed in `team/test-combined.json` on `team/local` are merged into the shared `test/combined` branch and pushed to GitHub. Machines only fetch and check out that tip — they do not merge at Altium/FYPA start. Config on `team/local`: @@ -27,53 +27,54 @@ Config on `team/local`: } ``` -### Maintain (rebuild + publish) +### Maintain (update + publish) -Uses **origin tips only** (unpushed local commits are ignored). Missing remotes abort. +Uses **origin tips only**. Prefer **incremental** updates (default): check out `origin/test/combined` and merge only extras not already in that tip. That keeps prior conflict resolutions. ```powershell -pwsh scripts/maintain-test-combined.ps1 -Rebuild -Push +pwsh scripts/maintain-test-combined.ps1 -Push # incremental +pwsh scripts/maintain-test-combined.ps1 -Rebuild -Push # clean recreate from main +pwsh scripts/maintain-test-combined.ps1 -Abort # escape stuck merge ``` -Omit `-Push` to rebuild locally while resolving merge conflicts, then push when clean. +Omit `-Push` while resolving conflicts locally, then `-Push` when clean. -On conflict, only `.gitignore` and `FYPA.code-workspace` auto-resolve with `--ours`; other conflicts stop the script. +**Conflicts:** only `.gitignore` / `FYPA.code-workspace` auto-resolve. On a real conflict the script stays on `test/combined` — do not `git switch` away. Either finish (`git add` + `git commit`, then `-Push`) or run `-Abort` to hard-reset to `origin/test/combined` and return to your previous branch. -Config resolution for maintain: `scripts/test-combined.json` (gitignored override) → `team/test-combined.json` → `team/local:team/test-combined.json` → example file. +Use `-Rebuild` only when `main` moved a lot, extras were removed/reordered, or the tip is broken. Expect to resolve the same conflicts again. + +Config resolution: `scripts/test-combined.json` (gitignored) → `team/test-combined.json` → `team/local:team/test-combined.json` → example file. ### GitHub Action (fork only) -`.github/workflows/maintain-test-combined.yml` lives **only on `team/local`** — never commit it to `main` or to branches destined for an upstream PR. +`.github/workflows/maintain-test-combined.yml` lives **only on `team/local`** — never commit it to `main` or upstream PR branches. -Triggers: `workflow_dispatch` (use `--ref team/local`) and pushes to `team/local` that touch `team/test-combined.json`. The job runs maintain with `-Rebuild -Push`. +Triggers: `workflow_dispatch` (`--ref team/local`) and pushes to `team/local` that touch `team/test-combined.json`. ### Launch (no merge) ```powershell pwsh scripts/test-combined.ps1 -pwsh scripts/test-combined.ps1 -SkipTests pwsh scripts/test-combined.ps1 -SkipTests -PrjPcb path\to\Board.PrjPcb ``` -Checks out `origin/test/combined`, optionally runs topology pytest, then FYPA. `-Rebuild` is not supported here — use maintain. +Checks out `origin/test/combined` only. `-Rebuild` is not supported — use maintain. -Altium bootstrap (`Run_FYPA.ps1`) calls `scripts/launch-combined-gui.ps1` after clone/`uv sync`: fetch + hard-reset to `origin/test/combined`, then `Launch_GUI.py`. +Altium (`Run_FYPA.ps1`) calls `scripts/launch-combined-gui.ps1`. ### Typical flow 1. Push the feature branch to `origin`. 2. Add it to `team/test-combined.json` on `team/local` and push `team/local`. -3. Run maintain (or let the Action rebuild) so `origin/test/combined` updates. -4. On any machine: Altium → Run FYPA → fetch + checkout — done. +3. `pwsh scripts/maintain-test-combined.ps1 -Push` (incremental). +4. On any machine: Altium / `test-combined.ps1` → `origin/test/combined`. Prefer clean feature branches in the JSON (not pre-merged `*-combined` stacks). ## Upstream pull requests -Create the PR branch from upstream, not from `team/local`: - ```powershell git fetch upstream git checkout -b feature/my-fix upstream/main -git cherry-pick # feature commits only +git cherry-pick ``` diff --git a/scripts/maintain-test-combined.ps1 b/scripts/maintain-test-combined.ps1 index f76c6ae..519f8c2 100644 --- a/scripts/maintain-test-combined.ps1 +++ b/scripts/maintain-test-combined.ps1 @@ -4,9 +4,14 @@ .DESCRIPTION Reads team/test-combined.json (team/local by default), fetches base + extras - from origin, recreates the disposable test branch using remote tips only, + from origin, updates the disposable test branch using remote tips only, and optionally force-pushes with lease. + Default (no -Rebuild): if origin/test/combined exists, check it out and + merge only extras whose tips are not already ancestors of HEAD + (incremental — keeps prior conflict resolutions). Pass -Rebuild for a + clean recreate from baseBranch (expect conflicts again). + Local unpushed commits are never merged — tips are always origin/. Missing remote extras abort the run. @@ -26,20 +31,34 @@ Remote name. Default: origin .PARAMETER Rebuild - Force recreate even when the stamp on the existing local test branch matches. + Delete/recreate from baseBranch even when a published tip exists. + Prefer incremental updates without this switch. + +.PARAMETER Abort + Abort a stuck merge: hard-reset local test/combined to origin/test/combined + (if present), clear merge/rebase state, and check out the starting branch. + Does not push. .PARAMETER Push - After a successful rebuild (or reuse), push --force-with-lease to Remote. + After a successful update (or reuse), push --force-with-lease to Remote. .PARAMETER BaseBranch / TestBranch / ExtraFeatureBranches / DeleteTestBranchFirst Override individual config fields. +.EXAMPLE + pwsh scripts/maintain-test-combined.ps1 -Push + + Incremental update from origin/test/combined, then publish. + .EXAMPLE pwsh scripts/maintain-test-combined.ps1 -Rebuild -Push + Clean recreate from main (resolves conflicts from scratch). + .EXAMPLE - pwsh scripts/maintain-test-combined.ps1 - Build locally without pushing (e.g. to resolve merge conflicts). + pwsh scripts/maintain-test-combined.ps1 -Abort + + Escape a mid-merge worktree and return to the previous branch. #> [CmdletBinding()] @@ -48,6 +67,7 @@ param( [string] $TeamConfigRef = "team/local", [string] $Remote = "origin", [switch] $Rebuild, + [switch] $Abort, [switch] $Push, [string] $BaseBranch, [string] $TestBranch, @@ -122,6 +142,15 @@ function Test-GitRef { return $LASTEXITCODE -eq 0 } +function Test-GitAncestor { + param( + [string] $Ancestor, + [string] $Descendant + ) + & git.exe merge-base --is-ancestor $Ancestor $Descendant 2>$null | Out-Null + return $LASTEXITCODE -eq 0 +} + function Get-RefSha { param([string] $Ref) $Sha = ([string] (& git.exe rev-parse --verify "$Ref^{commit}" 2>$null)).Trim() @@ -397,10 +426,78 @@ function Read-TestCombinedConfig { return $Config } +function Clear-TestCombinedUpstream { + param([string] $Branch) + # Creating from origin/main sets upstream to main — confusing ("ahead of main"). + & git.exe branch --unset-upstream $Branch 2>$null | Out-Null +} + if (-not (Test-Path "FYPA.py")) { throw "FYPA.py not found in $RepoRoot — run this script from the FYPA repo." } +$ReturnBranch = Get-CurrentBranch +if (-not $ReturnBranch) { + throw "Could not determine the current branch (detached HEAD?). Check out a branch first." +} + +if ($Abort) { + $AbortTestBranch = if ($PSBoundParameters.ContainsKey("TestBranch") -and $TestBranch) { + $TestBranch + } + else { + "test/combined" + } + Write-Host "==> Abort: clear merge/rebase state and reset $AbortTestBranch" + $onAbortBranch = ((Get-CurrentBranch) -eq $AbortTestBranch) + if ($onAbortBranch) { + & git merge --abort 2>$null | Out-Null + & git rebase --abort 2>$null | Out-Null + & git reset --merge 2>$null | Out-Null + } + try { + Sync-RemoteBranches -RemoteName $Remote -Branches @($AbortTestBranch) + } + catch { + Write-Warning "Fetch $Remote/$AbortTestBranch failed — using existing refs if present." + } + $RemoteAbortRef = "$Remote/$AbortTestBranch" + if (Test-GitRef "refs/remotes/$Remote/$AbortTestBranch") { + if ($onAbortBranch) { + Invoke-Git @('reset', '--hard', $RemoteAbortRef) + Clear-TestCombinedUpstream -Branch $AbortTestBranch + Write-Host "==> $AbortTestBranch reset to $RemoteAbortRef" + if ($ReturnBranch -ne $AbortTestBranch) { + Write-Host "==> Return to $ReturnBranch" + Restore-DevBranch -Branch $ReturnBranch + } + } + else { + # Update the local branch tip without checking it out (worktree may be dirty). + if (Test-GitRef "refs/heads/$AbortTestBranch") { + Invoke-Git @('branch', '-f', $AbortTestBranch, $RemoteAbortRef) + } + else { + Invoke-Git @('branch', $AbortTestBranch, $RemoteAbortRef) + } + Clear-TestCombinedUpstream -Branch $AbortTestBranch + Write-Host "==> Local $AbortTestBranch forced to $RemoteAbortRef (no checkout)" + } + } + elseif ($onAbortBranch) { + Write-Host "==> No $RemoteAbortRef — checking out $ReturnBranch and deleting local tip" + Restore-DevBranch -Branch $ReturnBranch + if (Test-GitRef "refs/heads/$AbortTestBranch") { + Invoke-Git @('branch', '-D', $AbortTestBranch) + } + } + else { + Write-Host "==> No local/remote $AbortTestBranch to reset" + } + Write-Host "==> Abort done — on $(Get-CurrentBranch)" + exit 0 +} + # Soft-fetch team config ref so git show origin/team/local:... works. if (-not $ConfigPath) { try { @@ -439,11 +536,6 @@ if (-not $TestBranch) { throw "testBranch is empty." } Write-Host "==> Branch source: $Remote tips only (no local-ahead merge)" -$ReturnBranch = Get-CurrentBranch -if (-not $ReturnBranch) { - throw "Could not determine the current branch (detached HEAD?). Check out a branch first." -} - Sync-RemoteBranches -RemoteName $Remote -Branches (@($BaseBranch) + $ExtraFeatureBranches) # test/combined may not exist yet on first publish — soft-fetch only. try { @@ -477,6 +569,7 @@ foreach ($ExtraBranch in $ExtraFeatureBranches) { $ResolvedExtras.Add(@{ Branch = $ExtraTarget.Branch MergeRef = $ExtraTarget.MergeRef + Sha = $ExtraTarget.Sha }) } @@ -500,23 +593,34 @@ $DesiredStamp = ConvertTo-NormalizedStamp (Get-InputStamp ` -BaseSha $BaseSha ` -ExtraPairs @($ExtraStampPairs)) -$TestBranchExists = Test-GitRef "refs/heads/$TestBranch" -$ExistingTip = if ($TestBranchExists) { Get-RefSha -Ref $TestBranch } else { $null } -$ExistingStamp = ConvertTo-NormalizedStamp (Get-TestCombinedStamp -Commit $ExistingTip) +$RemoteTestRef = "$Remote/$TestBranch" +$HasRemoteTest = Test-GitRef "refs/remotes/$Remote/$TestBranch" +$RemoteTestSha = if ($HasRemoteTest) { Get-RefSha -Ref $RemoteTestRef } else { $null } +$RemoteStamp = ConvertTo-NormalizedStamp (Get-TestCombinedStamp -Commit $RemoteTestSha) + $CanReuse = ( -not $Rebuild -and - $TestBranchExists -and - $ExistingStamp -and - ($ExistingStamp -eq $DesiredStamp) + $RemoteStamp -and + ($RemoteStamp -eq $DesiredStamp) ) -if (-not $CanReuse -and $TestBranchExists -and -not $Rebuild) { - if (-not $ExistingStamp) { - Write-Host "==> No reuse stamp on $TestBranch — will rebuild" - } - else { - Write-Host "==> Stamp mismatch on $TestBranch — will rebuild" - } +$UseIncremental = ( + -not $Rebuild -and + -not $CanReuse -and + $HasRemoteTest +) + +if ($CanReuse) { + Write-Host "==> Stamp matches $RemoteTestRef — reuse" +} +elseif ($UseIncremental) { + Write-Host "==> Incremental update from $RemoteTestRef (pass -Rebuild for clean recreate)" +} +elseif ($Rebuild) { + Write-Host "==> -Rebuild: clean recreate from $BaseRef" +} +else { + Write-Host "==> No $RemoteTestRef yet — first create from $BaseRef" } $IgnoredPaths = @('.gitignore', 'FYPA.code-workspace') @@ -531,6 +635,7 @@ if ($BlockingStatus.Count -gt 0) { throw @" Uncommitted changes detected on '$ReturnBranch'. Commit or stash them before running maintain-test-combined. +To escape a stuck merge: pwsh scripts/maintain-test-combined.ps1 -Abort "@ } @@ -538,22 +643,36 @@ $Returned = $false $LeaveOnConflict = $false try { if ($CanReuse) { - Write-Host "==> Reuse $TestBranch (inputs unchanged)" - if ((Get-CurrentBranch) -ne $TestBranch) { - Invoke-Git @('checkout', $TestBranch) + Write-Host "==> Checkout $TestBranch @ $RemoteTestRef" + Invoke-Git @('checkout', '-B', $TestBranch, $RemoteTestRef) + Invoke-Git @('reset', '--hard', $RemoteTestRef) + Clear-TestCombinedUpstream -Branch $TestBranch + } + elseif ($UseIncremental) { + Write-Host "==> Checkout $TestBranch @ $RemoteTestRef" + Invoke-Git @('checkout', '-B', $TestBranch, $RemoteTestRef) + Invoke-Git @('reset', '--hard', $RemoteTestRef) + Clear-TestCombinedUpstream -Branch $TestBranch + + $HeadSha = Get-RefSha -Ref 'HEAD' + foreach ($Extra in $ResolvedExtras) { + if (Test-GitAncestor -Ancestor $Extra.Sha -Descendant $HeadSha) { + Write-Host "==> Skip $($Extra.Branch) (already in $TestBranch)" + continue + } + Write-Host "==> Merge $($Extra.MergeRef) into $TestBranch" + Merge-FeatureBranch -MergeRef $Extra.MergeRef -ExtraBranch $Extra.Branch -IgnoredPaths $IgnoredPaths + $HeadSha = Get-RefSha -Ref 'HEAD' + } + + $NewTip = Get-RefSha -Ref 'HEAD' + if ($NewTip) { + Set-TestCombinedStamp -Commit $NewTip -Stamp $DesiredStamp + Write-Host "==> Stamp written for $TestBranch" } } else { - if ($Rebuild) { - Write-Host "==> Rebuild requested — recreating $TestBranch" - } - elseif (-not $TestBranchExists) { - Write-Host "==> $TestBranch missing — creating from $BaseRef" - } - else { - Write-Host "==> Inputs changed — recreating $TestBranch from $BaseRef" - } - + # Full recreate from base (first publish or -Rebuild). if ($DeleteTestBranchFirst -and (Test-GitRef "refs/heads/$TestBranch")) { Write-Host "==> Delete $TestBranch" if ((Get-CurrentBranch) -eq $TestBranch) { @@ -571,6 +690,7 @@ try { Write-Host "==> Create $TestBranch from $BaseRef" Invoke-Git @('checkout', '-b', $TestBranch, $BaseRef) } + Clear-TestCombinedUpstream -Branch $TestBranch foreach ($Extra in $ResolvedExtras) { Write-Host "==> Merge $($Extra.MergeRef) into $TestBranch" @@ -598,17 +718,21 @@ try { } catch { $msg = "$_" - if ($msg -match 'Merge conflict' -and (Test-MergeInProgress)) { + if ($msg -match 'Merge conflict' -and ( + (Test-MergeInProgress) -or ((Get-UnmergedPaths).Count -gt 0) + )) { $LeaveOnConflict = $true Write-Host @" -==> Merge conflict — staying on $TestBranch with the conflict in place. -Resolve, commit, then: +==> Merge conflict — still on $TestBranch (do not git switch away). - git notes --ref=test-combined add -f -m '' HEAD # optional - git push --force-with-lease $Remote HEAD:refs/heads/$TestBranch +Finish: + git add -u + git commit --no-edit + pwsh scripts/maintain-test-combined.ps1 -Push -Or re-run: pwsh scripts/maintain-test-combined.ps1 -Rebuild -Push +Abort back to published tip + previous branch: + pwsh scripts/maintain-test-combined.ps1 -Abort "@ } elseif ((Get-CurrentBranch) -ne $ReturnBranch) { From ad8f18cd84db53129e47e752cca09aba50673719 Mon Sep 17 00:00:00 2001 From: Carsten Schurig Date: Fri, 7 Aug 2026 08:23:49 +0200 Subject: [PATCH 11/16] Allow SOURCE/SINK terminals to pull pads from other designators (lab banana plugs). Co-authored-by: Cursor --- fypa/altium/annotations.py | 224 ++++++++++++++++++++++++++----- fypa/altium/loader.py | 12 +- fypa/topology/metadata_schema.py | 1 + 3 files changed, 203 insertions(+), 34 deletions(-) diff --git a/fypa/altium/annotations.py b/fypa/altium/annotations.py index 3b54ec0..add5b03 100644 --- a/fypa/altium/annotations.py +++ b/fypa/altium/annotations.py @@ -16,10 +16,10 @@ ============ ============================= ================================================== Role Value params Net / pin params ============ ============================= ================================================== -SOURCE PDN_V PDN_P_NET, PDN_N_NET (overrides: *_PINS) - *or* PDN_NET (overrides: PDN_PINS) -SINK PDN_I PDN_P_NET, PDN_N_NET (overrides: *_PINS) - *or* PDN_NET (overrides: PDN_PINS) +SOURCE PDN_V PDN_P_NET, PDN_N_NET (overrides: *_PINS, *_DES) + *or* PDN_NET (overrides: PDN_PINS) +SINK PDN_I PDN_P_NET, PDN_N_NET (overrides: *_PINS, *_DES) + *or* PDN_NET (overrides: PDN_PINS) SERIES PDN_R PDN_P_NET, PDN_N_NET (optional) (overrides: *_PINS) REGULATOR PDN_V PDN_OUT_P_NET, PDN_OUT_N_NET, PDN_REGULATOR_TYPE PDN_IN_P_NET, PDN_IN_N_NET (overrides: *_PINS) @@ -27,6 +27,18 @@ PDN_QUIESCENT (optional) ============ ============================= ================================================== +Multi-connector P / N pads (``*_DES``) +-------------------------------------- +Two-terminal SOURCE / SINK channels may pull a terminal's pads from **other** +components via ``PDN_P_DES`` / ``PDN_N_DES`` (or ``PDNn_P_DES`` / +``PDNn_N_DES``): a comma-separated list of designators (e.g. ``J1,J2``). +Without ``*_DES``, pads come from the host component only (unchanged). +With ``*_DES``, pads come **only** from the listed designators on the named +net — the host is not auto-included. ``PDN_*_PINS`` still filters pads on +those chosen components. Single-net (``PDN_NET``) mode stays single-component; +SERIES / REGULATOR ignore ``*_DES``. ``SourceSpec.designator`` remains the +host. + Single-net (point-to-point) SOURCE / SINK ------------------------------------------ A SOURCE or SINK normally names a rail net (``PDN_P_NET``) and a return net @@ -180,8 +192,8 @@ "NET", "PINS", "P_NET", "N_NET", "P_PINS", "N_PINS", }) _KNOWN_SUFFIXES_BY_ROLE: dict[str, frozenset[str]] = { - "SOURCE": _COMMON_TERMINAL_SUFFIXES | frozenset({"V"}), - "SINK": _COMMON_TERMINAL_SUFFIXES | frozenset({"I", "MIN_V"}), + "SOURCE": _COMMON_TERMINAL_SUFFIXES | frozenset({"V", "P_DES", "N_DES"}), + "SINK": _COMMON_TERMINAL_SUFFIXES | frozenset({"I", "MIN_V", "P_DES", "N_DES"}), "REGULATOR": frozenset({ "V", "GAIN", "REGULATOR_TYPE", "REGULATOR_EFFICIENCY", "QUIESCENT", "OUT_P_NET", "OUT_N_NET", "OUT_P_PINS", "OUT_N_PINS", @@ -471,7 +483,7 @@ class TerminalPin: pad_polygon: shapely.geometry.Polygon | None = None # Owning PCB component when known. Used so P/N overlap arbitration does # not treat pad ``"1"`` on J2 and pad ``"1"`` on J3 as the same pin - # (multi-connector / banana-jack sources). + # (multi-connector / banana-jack sources via ``PDN_*_DES``). component_designator: str | None = None @@ -1382,6 +1394,7 @@ def _resolve_terminal( ) return None, errors, match_tier + comp_des = proj.pcb_components[pcb_index].designator pins = tuple( TerminalPin( pad_designator=p.designator, @@ -1398,6 +1411,128 @@ def _resolve_terminal( ), errors, match_tier +def _resolve_terminal_multi( + proj: ExtractedProject, + designators: list[str], + net_name: str | None, + override_pins: list[str] | None, + enabled_layers: list[int], + role_diagnostic: str, + warnings: list[str] | None = None, + net_remap: dict[int, int] | None = None, + schdoc_name: str | None = None, +) -> tuple[TerminalSpec | None, list[str]]: + """Resolve a terminal from pads on *other* components named by ``*_DES``. + + Each designator must exist on the PCB and contribute at least one matching + pad. The host component is not consulted — callers pass only the listed + designators. ``override_pins`` (from ``*_PINS``) filters pads across those + components; a pin name is satisfied if any listed component has it. + """ + errors: list[str] = [] + all_pins: list[TerminalPin] = [] + resolved_via_local = False + + if not designators: + errors.append(f"{role_diagnostic}: empty designator list") + return None, errors + + # Preserve author order; ignore duplicate names (case-insensitive). + seen_des: set[str] = set() + unique_des: list[str] = [] + for des in designators: + key = des.upper() + if key in seen_des: + continue + seen_des.add(key) + unique_des.append(des) + + for des in unique_des: + indices = _find_pcb_instances(proj, des) + if not indices: + errors.append( + f"{role_diagnostic}: designator {des!r} not found on the PCB" + ) + continue + + des_pins: list[TerminalPin] = [] + des_local = False + + if override_pins: + wanted = {pin.upper() for pin in override_pins} + for ix in indices: + comp_des = proj.pcb_components[ix].designator + component_pads = _pads_by_component_all(proj).get(ix, []) + matched = [ + p for p in component_pads + if p.designator.upper() in wanted + ] + for p in matched: + des_pins.append(TerminalPin( + pad_designator=p.designator, + layer_id=(_tl := _terminal_layer_for_pad( + p, enabled_layers, + )), + net_index=p.net_index, + point=p.center, + pad_polygon=_pad_polygon(p, _tl), + component_designator=comp_des, + )) + if not des_pins: + errors.append( + f"{role_diagnostic}: designator {des!r} has none of the " + f"override pins {sorted(wanted)}" + ) + else: + des_errs: list[str] = [] + for ix in indices: + pcb_comp = proj.pcb_components[ix] + sch_lookup = pcb_comp.source_designator or pcb_comp.designator + spec, err = _resolve_terminal( + proj, ix, net_name, None, enabled_layers, + f"{role_diagnostic} ({des})", + warnings=warnings, + net_remap=net_remap, + sch_lookup_designator=sch_lookup, + schdoc_name=schdoc_name, + ) + if spec is not None: + des_pins.extend(spec.pins) + des_local = des_local or spec.resolved_via_local + else: + des_errs.extend(err) + if not des_pins: + if des_errs: + errors.extend(des_errs) + else: + errors.append( + f"{role_diagnostic}: designator {des!r} has no pad " + f"on net {net_name!r}" + ) + + all_pins.extend(des_pins) + resolved_via_local = resolved_via_local or des_local + + if override_pins and all_pins: + found = {p.pad_designator.upper() for p in all_pins} + missing = {pin.upper() for pin in override_pins} - found + if missing: + errors.append( + f"{role_diagnostic}: pin overrides not found on listed " + f"designators: {sorted(missing)}" + ) + + if errors: + return None, errors + if not all_pins: + return None, [f"{role_diagnostic}: no pads resolved"] + return TerminalSpec( + pins=tuple(all_pins), + requested_net=net_name, + resolved_via_local=resolved_via_local, + ), [] + + def _find_pcb_instances(proj: ExtractedProject, sch_designator: str) -> list[int]: """Return the indices of every PCB component placed from a schematic part. @@ -1835,11 +1970,19 @@ def _resolve_two_terminal( net_remap: dict[int, int] | None = None, sch_lookup_designator: str | None = None, schdoc_name: str | None = None, + p_des_key: str | None = None, + n_des_key: str | None = None, ) -> tuple[TerminalSpec, TerminalSpec] | None: p_net = _ci_get(params, p_net_key) n_net = _ci_get(params, n_net_key) p_pins = _split_pin_list(_ci_get(params, p_pins_key)) n_pins = _split_pin_list(_ci_get(params, n_pins_key)) + p_des = ( + _split_pin_list(_ci_get(params, p_des_key)) if p_des_key else None + ) + n_des = ( + _split_pin_list(_ci_get(params, n_des_key)) if n_des_key else None + ) if p_net is None and p_pins is None: result.errors.append(f"{role_diag}: missing {p_net_key} (or {p_pins_key})") @@ -1848,22 +1991,31 @@ def _resolve_two_terminal( if p_net is None and p_pins is None or n_net is None and n_pins is None: return None - p_spec, p_err, p_tier = _resolve_terminal( - proj, pcb_index, p_net, p_pins, enabled_layers, - f"{role_diag} P-terminal", - warnings=result.warnings, - net_remap=net_remap, - sch_lookup_designator=sch_lookup_designator, - schdoc_name=schdoc_name, - ) - n_spec, n_err, n_tier = _resolve_terminal( - proj, pcb_index, n_net, n_pins, enabled_layers, - f"{role_diag} N-terminal", - warnings=result.warnings, - net_remap=net_remap, - sch_lookup_designator=sch_lookup_designator, - schdoc_name=schdoc_name, - ) + def _side( + net: str | None, + pins: list[str] | None, + des_list: list[str] | None, + side: str, + ) -> tuple[TerminalSpec | None, list[str], int]: + side_diag = f"{role_diag} {side}-terminal" + if des_list is not None: + spec, errs = _resolve_terminal_multi( + proj, des_list, net, pins, enabled_layers, side_diag, + warnings=result.warnings, + net_remap=net_remap, + schdoc_name=schdoc_name, + ) + return spec, errs, _LOCAL_NET_TIER_DIRECT + return _resolve_terminal( + proj, pcb_index, net, pins, enabled_layers, side_diag, + warnings=result.warnings, + net_remap=net_remap, + sch_lookup_designator=sch_lookup_designator, + schdoc_name=schdoc_name, + ) + + p_spec, p_err, p_tier = _side(p_net, p_pins, p_des, "P") + n_spec, n_err, n_tier = _side(n_net, n_pins, n_des, "N") result.errors.extend(p_err) result.errors.extend(n_err) if p_spec is None or n_spec is None: @@ -2013,9 +2165,10 @@ def _terminal_mode(params: dict[str, str], idx: int | None, """Decide whether a SOURCE/SINK channel is single-net or two-terminal. A single-net channel carries ``PDN_NET`` (or ``PDN_PINS``); a two-terminal - channel carries ``PDN_P_NET``/``PDN_N_NET`` (or their ``*_PINS``). The two - are mutually exclusive — see the module docstring. Returns ``"single"``, - ``"two"``, or ``None`` (a validation error has been appended to ``result``). + channel carries ``PDN_P_NET``/``PDN_N_NET`` (or their ``*_PINS`` / + ``*_DES``). The two are mutually exclusive — see the module docstring. + Returns ``"single"``, ``"two"``, or ``None`` (a validation error has been + appended to ``result``). """ net_key = _channel_key("NET", idx) pins_key = _channel_key("PINS", idx) @@ -2028,7 +2181,7 @@ def _terminal_mode(params: dict[str, str], idx: int | None, if _ci_get(params, pins_key) is not None: single_set.append(f"{pins_key} (single-net pin override)") two_set: list[str] = [] - for suffix in ("P_NET", "N_NET", "P_PINS", "N_PINS"): + for suffix in ("P_NET", "N_NET", "P_PINS", "N_PINS", "P_DES", "N_DES"): key = _channel_key(suffix, idx) if _ci_get(params, key) is not None: two_set.append(key) @@ -2181,6 +2334,8 @@ def _parse_source(comp, proj, enabled_layers, result, net_remap=net_remap, sch_lookup_designator=comp.lookup_designator, schdoc_name=comp.schdoc_name, + p_des_key=_channel_key("P_DES", idx), + n_des_key=_channel_key("N_DES", idx), ) if pair is None: continue @@ -2262,6 +2417,8 @@ def _parse_sink(comp, proj, enabled_layers, result, net_remap=net_remap, sch_lookup_designator=comp.lookup_designator, schdoc_name=comp.schdoc_name, + p_des_key=_channel_key("P_DES", idx), + n_des_key=_channel_key("N_DES", idx), ) if pair is None: continue @@ -3264,10 +3421,15 @@ def parse_annotations(proj: ExtractedProject, # --- self-check --------------------------------------------------------------- def _describe_terminal(label: str, term: TerminalSpec) -> str: - parts = [ - f"{p.pad_designator}@layer{p.layer_id}({p.point.x:.2f},{p.point.y:.2f})" - for p in term.pins - ] + parts = [] + for p in term.pins: + pad = ( + f"{p.component_designator}-{p.pad_designator}" + if p.component_designator else p.pad_designator + ) + parts.append( + f"{pad}@layer{p.layer_id}({p.point.x:.2f},{p.point.y:.2f})" + ) return f" {label:<8} pins: {', '.join(parts) if parts else '(none)'}" diff --git a/fypa/altium/loader.py b/fypa/altium/loader.py index 30f603d..4d36409 100644 --- a/fypa/altium/loader.py +++ b/fypa/altium/loader.py @@ -2631,13 +2631,19 @@ def _terminal_summary(term, nets) -> dict: for pin in term.pins: net_name = (nets[pin.net_index].name if 0 <= pin.net_index < len(nets) else "(none)") - pins.append({ - "pad": pin.pad_designator, + pad_label = pin.pad_designator + if pin.component_designator: + pad_label = f"{pin.component_designator}-{pin.pad_designator}" + entry = { + "pad": pad_label, "layer_id": pin.layer_id, "net": net_name, "x_mm": pin.point.x, "y_mm": pin.point.y, - }) + } + if pin.component_designator: + entry["component"] = pin.component_designator + pins.append(entry) return { "pin_count": len(pins), "pins": pins, diff --git a/fypa/topology/metadata_schema.py b/fypa/topology/metadata_schema.py index 21b5a37..df3ee41 100644 --- a/fypa/topology/metadata_schema.py +++ b/fypa/topology/metadata_schema.py @@ -10,6 +10,7 @@ class TerminalPinDict(TypedDict, total=False): pad: str + component: str net: str layer_id: int x_mm: float From 3da46b0d9092b6ce1e6d7c7facf6d67ee71bc0e0 Mon Sep 17 00:00:00 2001 From: Carsten Schurig Date: Fri, 7 Aug 2026 08:23:49 +0200 Subject: [PATCH 12/16] Cover multi-connector P/N DES resolution in annotation tests. Co-authored-by: Cursor --- tests/test_annotations.py | 217 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) diff --git a/tests/test_annotations.py b/tests/test_annotations.py index 850264b..ec79bb0 100644 --- a/tests/test_annotations.py +++ b/tests/test_annotations.py @@ -3256,7 +3256,224 @@ def test_is_solveable_still_false_without_a_source(): assert not loaded.is_solveable +# --- Multi-connector PDN_*_DES ------------------------------------------------ +def _banana_source_proj(*, n_des: str | None = "J3,J5,J7", + extra_params: dict | None = None, + omit_j7_gnd: bool = False, + include_sink: bool = True): + """SOURCE on J2 (VIN/GND) with optional PDN_N_DES listing return connectors. + + Nets: 0=GND, 1=VIN. Each connector has pin 1 on VIN and pin 2 on GND + unless ``omit_j7_gnd`` drops J7's GND pad. + """ + src_params = { + "PDN_ROLE": "SOURCE", + "PDN_V": "5", + "PDN_P_NET": "VIN", + "PDN_N_NET": "GND", + } + if n_des is not None: + src_params["PDN_N_DES"] = n_des + if extra_params: + src_params.update(extra_params) + + sch = [ + RawSchComponent( + designator="J2", schdoc_name="Pwr.SchDoc", + parameters=src_params, + pin_designators=("1", "2"), + ), + RawSchComponent( + designator="J3", schdoc_name="Pwr.SchDoc", + parameters={"Comment": "CONN"}, pin_designators=("1", "2"), + ), + RawSchComponent( + designator="J5", schdoc_name="Pwr.SchDoc", + parameters={"Comment": "CONN"}, pin_designators=("1", "2"), + ), + RawSchComponent( + designator="J7", schdoc_name="Pwr.SchDoc", + parameters={"Comment": "CONN"}, pin_designators=("1", "2"), + ), + ] + pcb = [ + RawPcbComponent( + designator="J2", center=Pt2D(0, 0), rotation_deg=0.0, + layer_name="TOP", footprint="CONN", source_designator="J2", + ), + RawPcbComponent( + designator="J3", center=Pt2D(5, 0), rotation_deg=0.0, + layer_name="TOP", footprint="CONN", source_designator="J3", + ), + RawPcbComponent( + designator="J5", center=Pt2D(10, 0), rotation_deg=0.0, + layer_name="TOP", footprint="CONN", source_designator="J5", + ), + RawPcbComponent( + designator="J7", center=Pt2D(15, 0), rotation_deg=0.0, + layer_name="TOP", footprint="CONN", source_designator="J7", + ), + ] + pads = [ + _pad(0, "1", 1, 0), # J2 VIN + _pad(0, "2", 0, 1), # J2 GND + _pad(1, "1", 1, 5), # J3 VIN + _pad(1, "2", 0, 6), # J3 GND + _pad(2, "1", 1, 10), # J5 VIN + _pad(2, "2", 0, 11), # J5 GND + _pad(3, "1", 1, 15), # J7 VIN + ] + if not omit_j7_gnd: + pads.append(_pad(3, "2", 0, 16)) # J7 GND + + if include_sink: + sch.append(RawSchComponent( + designator="U1", schdoc_name="Pwr.SchDoc", + parameters={ + "PDN_ROLE": "SINK", + "PDN_I": "1A", + "PDN_P_NET": "VIN", + "PDN_N_NET": "GND", + }, + pin_designators=("1", "2"), + )) + pcb.append(RawPcbComponent( + designator="U1", center=Pt2D(20, 0), rotation_deg=0.0, + layer_name="TOP", footprint="QFN", source_designator="U1", + )) + pads.extend([_pad(4, "1", 1, 20), _pad(4, "2", 0, 21)]) + + return _minimal_proj( + nets=(RawNet("GND"), RawNet("VIN")), + sch_components=tuple(sch), + pcb_components=tuple(pcb), + pads=tuple(pads), + ) + + +def test_source_n_des_multi_connector(): + """SOURCE on J2 with PDN_N_DES=J3,J5,J7 → P on J2, N from three connectors.""" + proj = _banana_source_proj() + result = parse_annotations(proj, enabled_layers=[1]) + assert result.ok, result.errors + sources = [d for d in result.directives if isinstance(d, SourceSpec)] + assert len(sources) == 1 + src = sources[0] + assert src.designator == "J2" + assert {p.pad_designator for p in src.p.pins} == {"1"} + assert {p.component_designator for p in src.p.pins} == {"J2"} + assert len(src.n.pins) == 3 + assert {p.component_designator for p in src.n.pins} == {"J3", "J5", "J7"} + assert {p.pad_designator for p in src.n.pins} == {"2"} + # Host J2 GND pad is NOT auto-included when N_DES is set. + assert "J2" not in {p.component_designator for p in src.n.pins} + + +def test_source_n_des_missing_designator_errors(): + proj = _banana_source_proj(n_des="J3,J99,J5") + result = parse_annotations(proj, enabled_layers=[1]) + assert not result.ok + assert any( + "J99" in e and "not found" in e for e in result.errors + ), result.errors + + +def test_source_n_des_no_pad_on_net_errors(): + proj = _banana_source_proj(n_des="J3,J5,J7", omit_j7_gnd=True) + result = parse_annotations(proj, enabled_layers=[1]) + assert not result.ok + assert any( + "J7" in e and "no pad" in e and "GND" in e for e in result.errors + ), result.errors + + +def test_source_without_des_backward_compat(): + """Without *_DES, P and N pads come from the host only.""" + proj = _banana_source_proj(n_des=None) + result = parse_annotations(proj, enabled_layers=[1]) + assert result.ok, result.errors + src = next(d for d in result.directives if isinstance(d, SourceSpec)) + assert {p.component_designator for p in src.p.pins} == {"J2"} + assert {p.component_designator for p in src.n.pins} == {"J2"} + assert {p.pad_designator for p in src.p.pins} == {"1"} + assert {p.pad_designator for p in src.n.pins} == {"2"} + + +def test_sink_p_des_multi_connector(): + """Analogous SINK: host J1 draws from +5V; P pads from J2,J3.""" + proj = _minimal_proj( + nets=(RawNet("GND"), RawNet("+5V")), + sch_components=( + RawSchComponent( + designator="J1", schdoc_name="Pwr.SchDoc", + parameters={ + "PDN_ROLE": "SINK", + "PDN_I": "2A", + "PDN_P_NET": "+5V", + "PDN_N_NET": "GND", + "PDN_P_DES": "J2,J3", + }, + pin_designators=("1", "2"), + ), + RawSchComponent( + designator="J2", schdoc_name="Pwr.SchDoc", + parameters={"Comment": "CONN"}, pin_designators=("1", "2"), + ), + RawSchComponent( + designator="J3", schdoc_name="Pwr.SchDoc", + parameters={"Comment": "CONN"}, pin_designators=("1", "2"), + ), + RawSchComponent( + designator="J5", schdoc_name="Pwr.SchDoc", + parameters={ + "PDN_ROLE": "SOURCE", + "PDN_V": "5", + "PDN_P_NET": "+5V", + "PDN_N_NET": "GND", + }, + pin_designators=("1", "2"), + ), + ), + pcb_components=( + RawPcbComponent( + designator="J1", center=Pt2D(0, 0), rotation_deg=0.0, + layer_name="TOP", footprint="CONN", source_designator="J1", + ), + RawPcbComponent( + designator="J2", center=Pt2D(5, 0), rotation_deg=0.0, + layer_name="TOP", footprint="CONN", source_designator="J2", + ), + RawPcbComponent( + designator="J3", center=Pt2D(10, 0), rotation_deg=0.0, + layer_name="TOP", footprint="CONN", source_designator="J3", + ), + RawPcbComponent( + designator="J5", center=Pt2D(15, 0), rotation_deg=0.0, + layer_name="TOP", footprint="CONN", source_designator="J5", + ), + ), + pads=( + _pad(0, "1", 1, 0), # J1 +5V (host — NOT in P when P_DES set) + _pad(0, "2", 0, 1), # J1 GND + _pad(1, "1", 1, 5), # J2 +5V + _pad(1, "2", 0, 6), + _pad(2, "1", 1, 10), # J3 +5V + _pad(2, "2", 0, 11), + _pad(3, "1", 1, 15), # J5 SOURCE +5V + _pad(3, "2", 0, 16), + ), + ) + result = parse_annotations(proj, enabled_layers=[1]) + assert result.ok, result.errors + sinks = [d for d in result.directives if isinstance(d, SinkSpec)] + assert len(sinks) == 1 + snk = sinks[0] + assert snk.designator == "J1" + assert len(snk.p.pins) == 2 + assert {p.component_designator for p in snk.p.pins} == {"J2", "J3"} + assert {p.component_designator for p in snk.n.pins} == {"J1"} + assert "J1" not in {p.component_designator for p in snk.p.pins} def test_format_solve_blockers_lists_errors(): From 3163b417b376f9db42391dc2c9d56ee42f1cc55b Mon Sep 17 00:00:00 2001 From: Carsten Schurig Date: Fri, 7 Aug 2026 08:23:49 +0200 Subject: [PATCH 13/16] Resolve editor p_des/n_des for multi-connector sources. Co-authored-by: Cursor --- fypa/editor_directives.py | 176 +++++++++++++++++++++++++------- fypa/project_file.py | 13 ++- tests/test_editor_directives.py | 116 +++++++++++++++++++++ 3 files changed, 266 insertions(+), 39 deletions(-) diff --git a/fypa/editor_directives.py b/fypa/editor_directives.py index bfd961b..e9b4239 100644 --- a/fypa/editor_directives.py +++ b/fypa/editor_directives.py @@ -65,11 +65,22 @@ def apply_editor_directives(loaded, editor_directives) -> list[str]: if nm: net_index.setdefault(nm.upper(), i) - # physical PCB designator -> pcb_components index + # physical PCB designator -> pcb_components index (exact, then + # case-insensitive fallback for multi-DES lists). comp_index: dict[str, int] = {} + comp_index_ci: dict[str, int] = {} for i, comp in enumerate(extracted.pcb_components): if comp.designator: comp_index.setdefault(comp.designator, i) + comp_index_ci.setdefault(comp.designator.upper(), i) + + def _comp_idx(designator: str | None) -> int | None: + if not designator: + return None + hit = comp_index.get(designator) + if hit is not None: + return hit + return comp_index_ci.get(designator.upper()) # --- Per-rail return groups for single-net editor directives ---------- # Each electrically-connected rail needs its OWN ideal-0 V return node. @@ -132,7 +143,7 @@ def _return_group_for(net_name: str) -> int: return gid def _component_center(designator: str | None) -> tuple[float, float] | None: - ci = comp_index.get(designator) if designator else None + ci = _comp_idx(designator) if ci is None: return None pts = [p.center for p in extracted.pads if p.component_index == ci] @@ -141,6 +152,28 @@ def _component_center(designator: str | None) -> tuple[float, float] | None: return (sum(p.x for p in pts) / len(pts), sum(p.y for p in pts) / len(pts)) + def _pads_on_component(ci: int, nidx: int, wanted_pins): + """Pads of component ``ci`` on net ``nidx``, optionally pin-filtered.""" + out: list = [] + pcb_des = extracted.pcb_components[ci].designator or None + for p in extracted.pads: + if p.component_index != ci or p.net_index != nidx: + continue + if wanted_pins is not None and \ + (p.designator or "").upper() not in wanted_pins: + continue + through = getattr(p, "is_through_hole", False) + lid = top_layer if through else p.layer_id + out.append(TerminalPin( + pad_designator=p.designator or "(editor)", + layer_id=lid, + net_index=nidx, + point=p.center, + pad_polygon=None, + component_designator=pcb_des, + )) + return out + def _resolve_terminal(net_name, *, designator, fallback_xy, fallback_layer_id, pin_filter=None): """Build a TerminalSpec on ``net_name``. A component-bound directive @@ -160,23 +193,9 @@ def _resolve_terminal(net_name, *, designator, fallback_xy, wanted_pins = ({str(p).upper() for p in pin_filter} if pin_filter else None) pins: list = [] - ci = comp_index.get(designator) if designator else None + ci = _comp_idx(designator) if ci is not None: - for p in extracted.pads: - if p.component_index != ci or p.net_index != nidx: - continue - if wanted_pins is not None and \ - (p.designator or "").upper() not in wanted_pins: - continue - through = getattr(p, "is_through_hole", False) - lid = top_layer if through else p.layer_id - pins.append(TerminalPin( - pad_designator=p.designator or _ANCHOR_PAD, - layer_id=lid, - net_index=nidx, - point=p.center, - pad_polygon=None, - )) + pins = _pads_on_component(ci, nidx, wanted_pins) if not pins: # Free marker, or a component with no pad on this net — couple # at the supplied fallback point on the net's copper. @@ -187,9 +206,61 @@ def _resolve_terminal(net_name, *, designator, fallback_xy, net_index=nidx, point=Pt2D(float(fx), float(fy)), pad_polygon=None, + component_designator=designator or None, )) return TerminalSpec(pins=tuple(pins), requested_net=net_name) + def _resolve_terminal_multi_des(net_name, *, designators, pin_filter=None, + label="", side=""): + """Resolve a terminal from pads on the listed designators only. + + Mirrors schematic ``PDN_*_DES`` semantics: the host is not + auto-included. Returns ``(TerminalSpec | None, warning | None)``. + """ + if not net_name: + return None, f"{label}: {side} net is empty; skipped." + nidx = net_index.get(net_name.upper()) + if nidx is None: + return None, ( + f"{label}: {side} net {net_name!r} not found on the board; " + "skipped." + ) + if not designators: + return None, ( + f"{label}: {side}-DES list is empty; skipped." + ) + wanted_pins = ({str(p).upper() for p in pin_filter} + if pin_filter else None) + # Preserve author order; ignore duplicate names (case-insensitive). + seen: set[str] = set() + unique: list[str] = [] + for des in designators: + key = des.upper() + if key in seen: + continue + seen.add(key) + unique.append(des) + + all_pins: list = [] + for des in unique: + ci = _comp_idx(des) + if ci is None: + return None, ( + f"{label}: designator {des!r} not found on the board; " + "skipped." + ) + des_pins = _pads_on_component(ci, nidx, wanted_pins) + if not des_pins: + return None, ( + f"{label}: designator {des!r} has no pad on net " + f"{net_name!r}; skipped." + ) + all_pins.extend(des_pins) + return ( + TerminalSpec(pins=tuple(all_pins), requested_net=net_name), + None, + ) + warnings: list[str] = [] # Drop schematic directives that an unlocked editor directive overrides, @@ -237,21 +308,41 @@ def _resolve_terminal(net_name, *, designator, fallback_xy, # Pin restrictions only apply to a component-bound terminal that # actually has pads to pick from; a free marker couples at its anchor. + # Multi-DES lists (PDN_*_DES) likewise apply only to component-bound + # two-net SOURCE/SINK — listed designators only, host not included. p_pins = getattr(ed, "p_pins", None) if ed.kind != "free" else None n_pins = getattr(ed, "n_pins", None) if ed.kind != "free" else None - p_term = _resolve_terminal( - ed.p_net, designator=ed.designator, - fallback_xy=fallback_xy, fallback_layer_id=fallback_lid, - pin_filter=p_pins, - ) - if p_term is None: - warnings.append( - f"{label}: P net {ed.p_net!r} not found on the board; skipped." - ) - continue + p_des = getattr(ed, "p_des", None) if ed.kind != "free" else None + n_des = getattr(ed, "n_des", None) if ed.kind != "free" else None # SERIES always bridges two real nets; SOURCE / SINK honour the # directive's single-net flag. two_net = (not ed.single_net) or ed.role == "SERIES" + # *_DES is SOURCE/SINK two-net only (mirrors schematic). + use_des = two_net and ed.role in ("SOURCE", "SINK") + if not use_des: + p_des = None + n_des = None + + if p_des is not None: + p_term, des_warn = _resolve_terminal_multi_des( + ed.p_net, designators=p_des, pin_filter=p_pins, + label=label, side="P", + ) + if des_warn: + warnings.append(des_warn) + continue + else: + p_term = _resolve_terminal( + ed.p_net, designator=ed.designator, + fallback_xy=fallback_xy, fallback_layer_id=fallback_lid, + pin_filter=p_pins, + ) + if p_term is None: + warnings.append( + f"{label}: P net {ed.p_net!r} not found on the board; " + "skipped." + ) + continue n_term = None if two_net: if ed.role == "SERIES" and not ed.n_net: @@ -260,17 +351,26 @@ def _resolve_terminal(net_name, *, designator, fallback_xy, "skipped." ) continue - n_term = _resolve_terminal( - ed.n_net, designator=ed.designator, - fallback_xy=fallback_xy, fallback_layer_id=fallback_lid, - pin_filter=n_pins, - ) - if n_term is None: - warnings.append( - f"{label}: N net {ed.n_net!r} not found on the board; " - "skipped." + if n_des is not None: + n_term, des_warn = _resolve_terminal_multi_des( + ed.n_net, designators=n_des, pin_filter=n_pins, + label=label, side="N", ) - continue + if des_warn: + warnings.append(des_warn) + continue + else: + n_term = _resolve_terminal( + ed.n_net, designator=ed.designator, + fallback_xy=fallback_xy, fallback_layer_id=fallback_lid, + pin_filter=n_pins, + ) + if n_term is None: + warnings.append( + f"{label}: N net {ed.n_net!r} not found on the " + "board; skipped." + ) + continue # The same short the annotation path arbitrates, which this path # never called: a lumped element with both terminals on one node. # Two shapes reach it — both terminals on one real pad (overlapping diff --git a/fypa/project_file.py b/fypa/project_file.py index 67e33fd..c85b581 100644 --- a/fypa/project_file.py +++ b/fypa/project_file.py @@ -80,6 +80,13 @@ class EditorDirective: # with ``p_net`` (PDN_PINS in single-net mode), ``n_pins`` with ``n_net``. p_pins: list[str] | None = None n_pins: list[str] | None = None + # Optional multi-connector designator lists (schematic ``PDN_P_DES`` / + # ``PDN_N_DES``). When set on a two-net SOURCE/SINK, that terminal's pads + # come only from the listed designators — the host is not auto-included. + # ``None`` keeps host-only resolution (backward compatible). Ignored for + # free markers and single-net / SERIES directives. + p_des: list[str] | None = None + n_des: list[str] | None = None voltage: float | None = None current: float | None = None resistance: float | None = None # SERIES only, ohms @@ -101,11 +108,13 @@ def to_dict(self) -> dict[str, Any]: d["anchor_xy"] = [float(self.anchor_xy[0]), float(self.anchor_xy[1])] d["p_pins"] = list(self.p_pins) if self.p_pins is not None else None d["n_pins"] = list(self.n_pins) if self.n_pins is not None else None + d["p_des"] = list(self.p_des) if self.p_des is not None else None + d["n_des"] = list(self.n_des) if self.n_des is not None else None return d @staticmethod def _coerce_pins(raw: Any) -> list[str] | None: - """Normalise a stored pin list to ``list[str]`` (or ``None``). + """Normalise a stored pin / designator list to ``list[str]`` (or ``None``). Drops blanks / whitespace; an empty result collapses to ``None`` so "no restriction" and "explicitly empty" are the same thing.""" @@ -131,6 +140,8 @@ def from_dict(cls, d: dict[str, Any]) -> EditorDirective: n_net=d.get("n_net"), p_pins=cls._coerce_pins(d.get("p_pins")), n_pins=cls._coerce_pins(d.get("n_pins")), + p_des=cls._coerce_pins(d.get("p_des")), + n_des=cls._coerce_pins(d.get("n_des")), voltage=(None if d.get("voltage") is None else float(d["voltage"])), current=(None if d.get("current") is None else float(d["current"])), resistance=(None if d.get("resistance") is None diff --git a/tests/test_editor_directives.py b/tests/test_editor_directives.py index c485665..04ef43d 100644 --- a/tests/test_editor_directives.py +++ b/tests/test_editor_directives.py @@ -164,3 +164,119 @@ def test_editor_series_on_two_nets_is_still_accepted(): resistance=0.05)] assert apply_editor_directives(loaded, eds) == [] assert len(loaded.annotations.directives) == 1 + + +# --- Multi-connector p_des / n_des ------------------------------------------- + +def _loaded_with_connectors(): + """Board stand-in: J2 (VIN+GND), J3/J5/J7 (GND return bananas), U1 load.""" + from fypa.altium.extract import Pt2D + + nets = [SimpleNamespace(name="GND"), SimpleNamespace(name="VIN")] + comps = [ + SimpleNamespace(designator="J2"), + SimpleNamespace(designator="J3"), + SimpleNamespace(designator="J5"), + SimpleNamespace(designator="J7"), + SimpleNamespace(designator="U1"), + ] + pads = [ + SimpleNamespace(component_index=0, net_index=1, designator="1", + center=Pt2D(0, 0), layer_id=1, is_through_hole=False), + SimpleNamespace(component_index=0, net_index=0, designator="2", + center=Pt2D(1, 0), layer_id=1, is_through_hole=False), + SimpleNamespace(component_index=1, net_index=0, designator="2", + center=Pt2D(5, 0), layer_id=1, is_through_hole=False), + SimpleNamespace(component_index=2, net_index=0, designator="2", + center=Pt2D(10, 0), layer_id=1, is_through_hole=False), + SimpleNamespace(component_index=3, net_index=0, designator="2", + center=Pt2D(15, 0), layer_id=1, is_through_hole=False), + SimpleNamespace(component_index=4, net_index=1, designator="1", + center=Pt2D(20, 0), layer_id=1, is_through_hole=False), + SimpleNamespace(component_index=4, net_index=0, designator="2", + center=Pt2D(21, 0), layer_id=1, is_through_hole=False), + ] + extracted = SimpleNamespace( + nets=nets, pcb_components=comps, pads=pads, + enabled_copper_layer_ids=lambda: [1], + ) + return SimpleNamespace(extracted=extracted, annotations=AnnotationResult()) + + +def test_editor_source_n_des_multi_connector(): + loaded = _loaded_with_connectors() + eds = [ + EditorDirective( + kind="component", role="SOURCE", designator="J2", + single_net=False, p_net="VIN", n_net="GND", + n_des=["J3", "J5", "J7"], voltage=5.0, + ), + EditorDirective( + kind="component", role="SINK", designator="U1", + single_net=False, p_net="VIN", n_net="GND", current=1.0, + ), + ] + warnings = apply_editor_directives(loaded, eds) + assert warnings == [], warnings + src = next(s for s in loaded.annotations.directives + if isinstance(s, SourceSpec)) + assert src.designator == "J2" + assert {p.component_designator for p in src.p.pins} == {"J2"} + assert {p.component_designator for p in src.n.pins} == {"J3", "J5", "J7"} + assert "J2" not in {p.component_designator for p in src.n.pins} + + +def test_editor_n_des_missing_designator_skipped(): + loaded = _loaded_with_connectors() + eds = [EditorDirective( + kind="component", role="SOURCE", designator="J2", + single_net=False, p_net="VIN", n_net="GND", + n_des=["J3", "J99"], voltage=5.0, + )] + warnings = apply_editor_directives(loaded, eds) + assert loaded.annotations.directives == [] + assert any("J99" in w and "not found" in w for w in warnings) + + +def test_editor_n_des_no_pad_on_net_skipped(): + loaded = _loaded_with_connectors() + # J7 has only a GND pad in the fixture; point N_DES at a designator + # whose pads are all on VIN instead — drop J3's GND by renaming net. + loaded.extracted.pads[2].net_index = 1 # J3 pad now on VIN, not GND + eds = [EditorDirective( + kind="component", role="SOURCE", designator="J2", + single_net=False, p_net="VIN", n_net="GND", + n_des=["J3"], voltage=5.0, + )] + warnings = apply_editor_directives(loaded, eds) + assert loaded.annotations.directives == [] + assert any("J3" in w and "no pad" in w for w in warnings) + + +def test_editor_without_des_backward_compat(): + loaded = _loaded_with_connectors() + eds = [EditorDirective( + kind="component", role="SOURCE", designator="J2", + single_net=False, p_net="VIN", n_net="GND", voltage=5.0, + )] + warnings = apply_editor_directives(loaded, eds) + assert warnings == [] + src = loaded.annotations.directives[0] + assert isinstance(src, SourceSpec) + assert {p.component_designator for p in src.p.pins} == {"J2"} + assert {p.component_designator for p in src.n.pins} == {"J2"} + + +def test_editor_directive_p_des_n_des_round_trip(): + d = EditorDirective( + kind="component", role="SOURCE", designator="J2", + single_net=False, p_net="VIN", n_net="GND", + p_des=["J1"], n_des=["J3", "J5"], voltage=5.0, + ) + restored = EditorDirective.from_dict(d.to_dict()) + assert restored.p_des == ["J1"] + assert restored.n_des == ["J3", "J5"] + # Absent keys stay None (backward compatible .fypa files). + bare = EditorDirective.from_dict({"role": "SINK", "p_net": "+5V"}) + assert bare.p_des is None + assert bare.n_des is None From 352408d97b78e4802a18ca795f183040f6981a33 Mon Sep 17 00:00:00 2001 From: Carsten Schurig Date: Fri, 7 Aug 2026 08:23:49 +0200 Subject: [PATCH 14/16] Document and expose P/N DES in the editor panel. Co-authored-by: Cursor --- README.md | 7 ++++ docs/user-guide/01-sources-and-sinks.md | 23 +++++++++++ fypa/altium_viewer.py | 52 +++++++++++++++++++++++-- 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e641b4c..258a1fc 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,13 @@ define the power-delivery topology: | `SERIES` | `PDN_R`, `PDN_P_NET`\*, `PDN_N_NET`\* | Series resistance / fuse / ferrite / inductor DCR (rail bridge) | | `REGULATOR` | `PDN_V`, `PDN_REGULATOR_TYPE`, `PDN_REGULATOR_EFFICIENCY`, optional `PDN_QUIESCENT` — *or* `PDN_GAIN`, plus `PDN_OUT_*` / `PDN_IN_*` nets | On-board regulator (LDO / buck) — models BOTH input and output rails | +Optional two-terminal helpers (SOURCE / SINK): + +| Parameter | Purpose | +|-----------|---------| +| `PDN_P_PINS` / `PDN_N_PINS` | Restrict which pads on the host (or DES-listed parts) couple | +| `PDN_P_DES` / `PDN_N_DES` | Pull that terminal's pads from other designators only (host not auto-included); for multi-connector / banana-style sources | + \* `PDN_P_NET` and `PDN_N_NET` are optional for `SERIES` on a 2-pin part — the tool auto-infers them from the component's pad connectivity. diff --git a/docs/user-guide/01-sources-and-sinks.md b/docs/user-guide/01-sources-and-sinks.md index c80d70b..2482fe3 100644 --- a/docs/user-guide/01-sources-and-sinks.md +++ b/docs/user-guide/01-sources-and-sinks.md @@ -173,6 +173,29 @@ To override the inferred pad set (e.g. to exclude a thermal pad), use the `PDN_P_PINS` / `PDN_N_PINS` parameters documented in the [main README](../../README.md). +### Multi-connector / banana-style sources + +Some bench setups feed power through one connector and return through +several others (banana jacks, sense returns, distributed ground posts). +Annotate the SOURCE on the **host** connector that carries the value / +role, then list the other designators on the terminal that should pull +pads from them: + +| Name | Value | +|--------------|--------------| +| `PDN_ROLE` | `SOURCE` | +| `PDN_V` | `5V` | +| `PDN_P_NET` | `VIN` | +| `PDN_N_NET` | `GND` | +| `PDN_N_DES` | `J3,J5,J7` | + +Here J2 is the host (P pads stay on J2). The N terminal uses **only** +pads on J3, J5, and J7 that sit on `GND` — the host is not +auto-included. `PDN_P_DES` works the same way on the P side. Optional +`PDN_P_PINS` / `PDN_N_PINS` still filter pad numbers across the listed +parts. Indexed channels use `PDNn_P_DES` / `PDNn_N_DES`. Without +`*_DES`, behaviour is unchanged (host pads only). + ### Several rails on one part (multi-channel) An IC that draws from more than one supply rail is a single part with diff --git a/fypa/altium_viewer.py b/fypa/altium_viewer.py index f5f6033..144a583 100644 --- a/fypa/altium_viewer.py +++ b/fypa/altium_viewer.py @@ -19915,10 +19915,33 @@ def _populate_editor_form(self) -> None: ) self._ef_npins_label = QLabel("N pins") form2.addRow(self._ef_npins_label, self._ef_npins) - # Pins apply to a real component's pads only. + # Multi-connector designator lists (PDN_P_DES / PDN_N_DES) — CSV of + # other component designators whose pads feed this terminal. Two-net + # SOURCE/SINK only; host is not auto-included when set. + self._ef_pdes = QLineEdit() + self._ef_pdes.setPlaceholderText("host only") + self._ef_pdes.setToolTip( + "Optional: comma-separated designators for the P terminal " + "(PDN_P_DES). Pads come only from those parts — the host is " + "not auto-included. Leave blank to use the host component." + ) + self._ef_pdes_label = QLabel("P DES") + form2.addRow(self._ef_pdes_label, self._ef_pdes) + self._ef_ndes = QLineEdit() + self._ef_ndes.setPlaceholderText("host only") + self._ef_ndes.setToolTip( + "Optional: comma-separated designators for the N terminal " + "(PDN_N_DES). Pads come only from those parts — the host is " + "not auto-included. Leave blank to use the host component." + ) + self._ef_ndes_label = QLabel("N DES") + form2.addRow(self._ef_ndes_label, self._ef_ndes) + # Pins / DES apply to a real component's pads only. self._ef_pins_apply = sel["kind"] == "component" for _w in (self._ef_pins, self._ef_pins_label, - self._ef_npins, self._ef_npins_label): + self._ef_npins, self._ef_npins_label, + self._ef_pdes, self._ef_pdes_label, + self._ef_ndes, self._ef_ndes_label): _w.setVisible(self._ef_pins_apply) lay.addLayout(form2) @@ -19955,6 +19978,10 @@ def _populate_editor_form(self) -> None: self._set_combo(self._ef_nnet, existing.n_net) self._ef_pins.setText(", ".join(existing.p_pins or [])) self._ef_npins.setText(", ".join(existing.n_pins or [])) + self._ef_pdes.setText(", ".join( + getattr(existing, "p_des", None) or [])) + self._ef_ndes.setText(", ".join( + getattr(existing, "n_des", None) or [])) self._ef_remove.setEnabled(True) if existing.overrides_designator: self._ef_status.setText( @@ -20041,12 +20068,22 @@ def _on_editor_model_changed(self, *_args) -> None: self._ef_pnet_label.setText("P net" if two else "Net") # The N-pin restriction only exists in two-net mode (single-net's N # terminal is an ideal return with no pads). Keep it in step with the - # N-net picker, and only for a component selection. + # N-net picker, and only for a component selection. P/N DES likewise + # apply only to two-net SOURCE/SINK (SERIES ignores them). if hasattr(self, "_ef_npins"): show_npins = two and getattr(self, "_ef_pins_apply", False) self._ef_npins.setVisible(show_npins) self._ef_npins_label.setVisible(show_npins) self._ef_pins_label.setText("P pins" if two else "Pins") + if hasattr(self, "_ef_pdes"): + role = (self._ef_role.currentText() + if hasattr(self, "_ef_role") else "") + show_des = (two and getattr(self, "_ef_pins_apply", False) + and role in ("SOURCE", "SINK")) + self._ef_pdes.setVisible(show_des) + self._ef_pdes_label.setVisible(show_des) + self._ef_ndes.setVisible(show_des) + self._ef_ndes_label.setVisible(show_des) def _on_editor_apply(self) -> None: """Commit the form into an :class:`EditorDirective` on the project, @@ -20124,6 +20161,13 @@ def _on_editor_apply(self) -> None: d.p_pins = self._parse_pin_field(self._ef_pins.text()) d.n_pins = (None if single else self._parse_pin_field(self._ef_npins.text())) + # Multi-connector DES lists (PDN_*_DES). Two-net SOURCE/SINK only. + if single or role not in ("SOURCE", "SINK"): + d.p_des = None + d.n_des = None + else: + d.p_des = self._parse_pin_field(self._ef_pdes.text()) + d.n_des = self._parse_pin_field(self._ef_ndes.text()) # If this component has a schematic directive, mark the editor # directive as its override so the re-solve drops the schematic # one instead of stamping both. @@ -20135,6 +20179,8 @@ def _on_editor_apply(self) -> None: d.kind = "free" d.p_pins = None d.n_pins = None + d.p_des = None + d.n_des = None self._editor_selection = {"kind": "free", "id": d.id} self._ensure_project().upsert_directive(d) From febf18fcea99cb8880a2c34379255c8aa72e4fe7 Mon Sep 17 00:00:00 2001 From: Carsten Schurig Date: Fri, 7 Aug 2026 08:36:02 +0200 Subject: [PATCH 15/16] Fix editor unlock and DES parity after multi-connector pad labels. Co-authored-by: Cursor --- fypa/altium/loader.py | 11 +-- fypa/altium_viewer.py | 82 ++++++++++++++++++++-- fypa/editor_directives.py | 56 ++++++++++----- fypa/topology/metadata/specs.py | 6 +- tests/test_editor_directives.py | 117 ++++++++++++++++++++++++++++++++ 5 files changed, 244 insertions(+), 28 deletions(-) diff --git a/fypa/altium/loader.py b/fypa/altium/loader.py index 4d36409..532894d 100644 --- a/fypa/altium/loader.py +++ b/fypa/altium/loader.py @@ -2631,11 +2631,11 @@ def _terminal_summary(term, nets) -> dict: for pin in term.pins: net_name = (nets[pin.net_index].name if 0 <= pin.net_index < len(nets) else "(none)") - pad_label = pin.pad_designator - if pin.component_designator: - pad_label = f"{pin.component_designator}-{pin.pad_designator}" + # Keep ``pad`` as the raw pad designator so Unlock seeding of + # PDN_*_PINS fields stays resolvable (``"1"``, not ``"J2-1"``). + # ``component`` + ``pad_label`` cover multi-DES display. entry = { - "pad": pad_label, + "pad": pin.pad_designator, "layer_id": pin.layer_id, "net": net_name, "x_mm": pin.point.x, @@ -2643,6 +2643,9 @@ def _terminal_summary(term, nets) -> dict: } if pin.component_designator: entry["component"] = pin.component_designator + entry["pad_label"] = ( + f"{pin.component_designator}-{pin.pad_designator}" + ) pins.append(entry) return { "pin_count": len(pins), diff --git a/fypa/altium_viewer.py b/fypa/altium_viewer.py index 144a583..e91d8e2 100644 --- a/fypa/altium_viewer.py +++ b/fypa/altium_viewer.py @@ -13310,7 +13310,7 @@ def _collect_candidates(role_filter: str | None continue out.append( (v_at, f"{d.get('label') or d.get('designator', '?')}" - f".{pin.get('pad', '?')}") + f".{self._pin_display_pad(pin) or '?'}") ) return out @@ -19513,11 +19513,74 @@ def _terminal_primary_net(term: dict | None) -> str | None: def _terminal_pin_pads(term: dict | None) -> list[str]: """Pad designators of a metadata directive terminal's pins — the PDN_PINS set the schematic resolved to. ``[]`` for an ideal return or - a terminal with no pins.""" + a terminal with no pins. + + Uses the raw ``pad`` field (not a compound ``J2-1`` label). Dedupes + case-insensitively so multi-DES terminals with the same pad number on + several connectors seed a single PDN_PINS entry. + """ if not term or term.get("ideal_return"): return [] - return [str(p.get("pad")) for p in term.get("pins", []) or [] - if p.get("pad") not in (None, "")] + seen: set[str] = set() + out: list[str] = [] + for p in term.get("pins", []) or []: + pad = p.get("pad") + if pad in (None, ""): + continue + pad_s = str(pad) + # Legacy metadata prefixed pad as ``COMP-PAD``; strip when the + # component field matches the prefix so Unlock stays resolvable. + comp = p.get("component") + if (comp and pad_s.upper().startswith(str(comp).upper() + "-")): + pad_s = pad_s[len(str(comp)) + 1:] + key = pad_s.upper() + if key in seen: + continue + seen.add(key) + out.append(pad_s) + return out + + @staticmethod + def _terminal_des_list(term: dict | None, + host: str | None) -> list[str]: + """Unique component designators that contributed pins to ``term``. + + Used to seed P DES / N DES on Unlock. Host-only terminals return + ``[]`` (blank DES ⇒ host component). Multi-connector terminals return + every unique ``component`` that contributed a pin (order preserved). + """ + if not term or term.get("ideal_return"): + return [] + seen: set[str] = set() + out: list[str] = [] + for p in term.get("pins", []) or []: + c = p.get("component") + if not c: + continue + key = str(c).upper() + if key in seen: + continue + seen.add(key) + out.append(str(c)) + if not out: + return [] + if host and len(out) == 1 and out[0].upper() == str(host).upper(): + return [] + return out + + @staticmethod + def _pin_display_pad(pin: dict | None) -> str: + """Display label for a metadata pin — prefer compound ``pad_label``.""" + if not pin: + return "" + label = pin.get("pad_label") + if label: + return str(label) + pad = pin.get("pad", "") + comp = pin.get("component") + if comp and pad: + return f"{comp}-{pad}" + return "" if pad is None else str(pad) @staticmethod def _parse_pin_field(text: str | None) -> list[str] | None: @@ -20020,6 +20083,13 @@ def _populate_editor_form(self) -> None: ", ".join(self._terminal_pin_pads(terms.get("P")))) self._ef_npins.setText( ", ".join(self._terminal_pin_pads(n_term))) + # Seed P/N DES from the components that actually contributed pins + # (multi-connector PDN_*_DES). Host-only → leave blank. + host_des = sel.get("designator") + self._ef_pdes.setText( + ", ".join(self._terminal_des_list(terms.get("P"), host_des))) + self._ef_ndes.setText( + ", ".join(self._terminal_des_list(n_term, host_des))) self._ef_remove.setEnabled(False) self._ef_status.setText( f"Unlocked — Apply " @@ -24908,7 +24978,7 @@ def _get_v_pd_kdtree(phys_name: str, net_name: str): "designator": display_desig, "schdoc": schdoc, "terminal": term_name, - "pad": pin.get("pad", ""), + "pad": self._pin_display_pad(pin), "net": net, "layer_id": layer_id, "x_mm": x, @@ -29412,7 +29482,7 @@ def _format_setup_html(solution, metadata: dict | None, net_cell = f"{_esc(req_net or actual_net)}" parts.append("" f"{_esc(term_name) if i == 0 else ''}" - f"{_esc(pin.get('pad',''))}" + f"{_esc(PdnViewer._pin_display_pad(pin))}" f"{net_cell}" f"{pin.get('layer_id','')}" f"{pin.get('x_mm', 0):.3f}" diff --git a/fypa/editor_directives.py b/fypa/editor_directives.py index e9b4239..7a60d35 100644 --- a/fypa/editor_directives.py +++ b/fypa/editor_directives.py @@ -65,22 +65,32 @@ def apply_editor_directives(loaded, editor_directives) -> list[str]: if nm: net_index.setdefault(nm.upper(), i) - # physical PCB designator -> pcb_components index (exact, then - # case-insensitive fallback for multi-DES lists). - comp_index: dict[str, int] = {} - comp_index_ci: dict[str, int] = {} - for i, comp in enumerate(extracted.pcb_components): - if comp.designator: - comp_index.setdefault(comp.designator, i) - comp_index_ci.setdefault(comp.designator.upper(), i) + def _comp_indices(designator: str | None) -> list[int]: + """All PCB placements matching ``designator``. - def _comp_idx(designator: str | None) -> int | None: + Mirrors schematic :func:`~fypa.altium.annotations._find_pcb_instances`: + prefer ``source_designator`` (multi-channel logical name), then fall + back to physical ``designator``. Returns every matching index so + multi-DES terminals merge pads from all channel placements. + """ if not designator: - return None - hit = comp_index.get(designator) - if hit is not None: - return hit - return comp_index_ci.get(designator.upper()) + return [] + target = designator.upper() + hits = [ + i for i, c in enumerate(extracted.pcb_components) + if getattr(c, "source_designator", None) + and str(c.source_designator).upper() == target + ] + if hits: + return hits + return [ + i for i, c in enumerate(extracted.pcb_components) + if (getattr(c, "designator", None) or "").upper() == target + ] + + def _comp_idx(designator: str | None) -> int | None: + indices = _comp_indices(designator) + return indices[0] if indices else None # --- Per-rail return groups for single-net editor directives ---------- # Each electrically-connected rail needs its OWN ideal-0 V return node. @@ -243,19 +253,31 @@ def _resolve_terminal_multi_des(net_name, *, designators, pin_filter=None, all_pins: list = [] for des in unique: - ci = _comp_idx(des) - if ci is None: + indices = _comp_indices(des) + if not indices: return None, ( f"{label}: designator {des!r} not found on the board; " "skipped." ) - des_pins = _pads_on_component(ci, nidx, wanted_pins) + des_pins: list = [] + for ci in indices: + des_pins.extend(_pads_on_component(ci, nidx, wanted_pins)) if not des_pins: return None, ( f"{label}: designator {des!r} has no pad on net " f"{net_name!r}; skipped." ) all_pins.extend(des_pins) + # Mirror schematic ``_resolve_terminal_multi``: every *_PINS entry + # must appear on at least one listed designator. + if wanted_pins and all_pins: + found = {p.pad_designator.upper() for p in all_pins} + missing = wanted_pins - found + if missing: + return None, ( + f"{label}: pin overrides not found on listed " + f"designators: {sorted(missing)}; skipped." + ) return ( TerminalSpec(pins=tuple(all_pins), requested_net=net_name), None, diff --git a/fypa/topology/metadata/specs.py b/fypa/topology/metadata/specs.py index fae0836..c80b9a0 100644 --- a/fypa/topology/metadata/specs.py +++ b/fypa/topology/metadata/specs.py @@ -199,11 +199,15 @@ def jump_row_for_directive(directive: DirectiveDict) -> JumpRowDict | None: for term_name, term in terms.items(): for pin in term.get("pins") or []: if pin.get("x_mm") is not None and pin.get("y_mm") is not None: + pad = (pin.get("pad_label") + or (f"{pin['component']}-{pin['pad']}" + if pin.get("component") and pin.get("pad") + else pin.get("pad", ""))) return { "designator": str(directive.get("designator") or label), "role": directive.get("role", ""), "terminal": term_name, - "pad": pin.get("pad", ""), + "pad": pad, "net": pin.get("net", ""), "layer_id": pin.get("layer_id"), "x_mm": pin.get("x_mm"), diff --git a/tests/test_editor_directives.py b/tests/test_editor_directives.py index 04ef43d..5b0152d 100644 --- a/tests/test_editor_directives.py +++ b/tests/test_editor_directives.py @@ -280,3 +280,120 @@ def test_editor_directive_p_des_n_des_round_trip(): bare = EditorDirective.from_dict({"role": "SINK", "p_net": "+5V"}) assert bare.p_des is None assert bare.n_des is None + + +def test_editor_n_des_missing_pin_override_skipped(): + """Every *_PINS entry must appear on at least one listed designator.""" + loaded = _loaded_with_connectors() + eds = [EditorDirective( + kind="component", role="SOURCE", designator="J2", + single_net=False, p_net="VIN", n_net="GND", + n_des=["J3", "J5"], n_pins=["2", "99"], voltage=5.0, + )] + warnings = apply_editor_directives(loaded, eds) + assert loaded.annotations.directives == [] + assert any( + "pin overrides not found" in w and "99" in w for w in warnings + ), warnings + + +def test_editor_n_des_multi_instance_merges_pads(): + """Multi-channel: one logical DES matches several physical placements.""" + from fypa.altium.extract import Pt2D + + nets = [SimpleNamespace(name="GND"), SimpleNamespace(name="VIN")] + comps = [ + SimpleNamespace(designator="J2", source_designator="J2"), + SimpleNamespace(designator="J3_CH1", source_designator="J3"), + SimpleNamespace(designator="J3_CH2", source_designator="J3"), + ] + pads = [ + SimpleNamespace(component_index=0, net_index=1, designator="1", + center=Pt2D(0, 0), layer_id=1, is_through_hole=False), + SimpleNamespace(component_index=0, net_index=0, designator="2", + center=Pt2D(1, 0), layer_id=1, is_through_hole=False), + SimpleNamespace(component_index=1, net_index=0, designator="2", + center=Pt2D(5, 0), layer_id=1, is_through_hole=False), + SimpleNamespace(component_index=2, net_index=0, designator="2", + center=Pt2D(10, 0), layer_id=1, is_through_hole=False), + ] + extracted = SimpleNamespace( + nets=nets, pcb_components=comps, pads=pads, + enabled_copper_layer_ids=lambda: [1], + ) + loaded = SimpleNamespace( + extracted=extracted, annotations=AnnotationResult(), + ) + eds = [EditorDirective( + kind="component", role="SOURCE", designator="J2", + single_net=False, p_net="VIN", n_net="GND", + n_des=["J3"], voltage=5.0, + )] + warnings = apply_editor_directives(loaded, eds) + assert warnings == [], warnings + src = loaded.annotations.directives[0] + assert isinstance(src, SourceSpec) + assert {p.component_designator for p in src.n.pins} == { + "J3_CH1", "J3_CH2", + } + assert len(src.n.pins) == 2 + + +# --- Unlock seeding helpers (no GUI) ----------------------------------------- + +def test_terminal_summary_pad_is_raw_not_compound(): + from fypa.altium.annotations import TerminalPin, TerminalSpec + from fypa.altium.extract import Pt2D + from fypa.altium.loader import _terminal_summary + + nets = [SimpleNamespace(name="GND"), SimpleNamespace(name="VIN")] + term = TerminalSpec(pins=( + TerminalPin( + pad_designator="1", layer_id=1, net_index=1, + point=Pt2D(0, 0), component_designator="J2", + ), + TerminalPin( + pad_designator="2", layer_id=1, net_index=0, + point=Pt2D(1, 0), component_designator="J3", + ), + ), requested_net="VIN") + summary = _terminal_summary(term, nets) + pads = [p["pad"] for p in summary["pins"]] + assert pads == ["1", "2"] + assert summary["pins"][0]["component"] == "J2" + assert summary["pins"][0]["pad_label"] == "J2-1" + assert summary["pins"][1]["pad_label"] == "J3-2" + + +def test_unlock_seeds_raw_pads_and_des_lists(): + """Unlock helpers: raw pads + DES from pin components (not host-only).""" + from fypa.altium_viewer import PdnViewer + + host = "J2" + p_term = { + "pins": [ + {"pad": "1", "component": "J2", "pad_label": "J2-1", "net": "VIN"}, + ], + } + n_term = { + "pins": [ + {"pad": "2", "component": "J3", "pad_label": "J3-2", "net": "GND"}, + {"pad": "2", "component": "J5", "pad_label": "J5-2", "net": "GND"}, + {"pad": "2", "component": "J7", "pad_label": "J7-2", "net": "GND"}, + ], + } + assert PdnViewer._terminal_pin_pads(p_term) == ["1"] + assert PdnViewer._terminal_pin_pads(n_term) == ["2"] + assert PdnViewer._terminal_des_list(p_term, host) == [] # host-only + assert PdnViewer._terminal_des_list(n_term, host) == ["J3", "J5", "J7"] + + +def test_unlock_strips_legacy_compound_pad(): + from fypa.altium_viewer import PdnViewer + + term = { + "pins": [ + {"pad": "J2-1", "component": "J2", "net": "VIN"}, + ], + } + assert PdnViewer._terminal_pin_pads(term) == ["1"] \ No newline at end of file From 10dd87c7aa0b4ae6844d75005ed7a4d298668925 Mon Sep 17 00:00:00 2001 From: Carsten Schurig Date: Fri, 7 Aug 2026 09:02:06 +0200 Subject: [PATCH 16/16] Accept 2- or 3-tuple returns from _resolve_terminal in multi-DES. Co-authored-by: Cursor --- fypa/altium/annotations.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/fypa/altium/annotations.py b/fypa/altium/annotations.py index add5b03..39aa653 100644 --- a/fypa/altium/annotations.py +++ b/fypa/altium/annotations.py @@ -1488,7 +1488,10 @@ def _resolve_terminal_multi( for ix in indices: pcb_comp = proj.pcb_components[ix] sch_lookup = pcb_comp.source_designator or pcb_comp.designator - spec, err = _resolve_terminal( + # ``_resolve_terminal`` returns ``(spec, errors)`` on main and + # ``(spec, errors, match_tier)`` on stacks that include pad + # arbitration (e.g. test/combined). Accept either shape. + resolved = _resolve_terminal( proj, ix, net_name, None, enabled_layers, f"{role_diagnostic} ({des})", warnings=warnings, @@ -1496,6 +1499,7 @@ def _resolve_terminal_multi( sch_lookup_designator=sch_lookup, schdoc_name=schdoc_name, ) + spec, err = resolved[0], resolved[1] if spec is not None: des_pins.extend(spec.pins) des_local = des_local or spec.resolved_via_local