diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 8ae22ea..da100e0 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -113,6 +113,10 @@ jobs: set "E=%ERRORLEVEL%" popd exit /b %E% + - name: Assert broken managed-link cleanup + shell: pwsh + run: ./tests/windows/assert-migration.ps1 + - shell: pwsh run: ./tests/windows/assert-state.ps1 -RepoRoot $env:GITHUB_WORKSPACE -ExpectedCommit $env:GITHUB_SHA - shell: cmd @@ -147,6 +151,10 @@ jobs: set "E=%ERRORLEVEL%" popd exit /b %E% + - name: Assert broken managed-link cleanup under AllSigned + shell: cmd + run: call "%GITHUB_WORKSPACE%\scripts\windows\invoke-ps-script.cmd" "%GITHUB_WORKSPACE%\tests\windows\assert-migration.ps1" + - shell: cmd run: call "%GITHUB_WORKSPACE%\scripts\windows\invoke-ps-script.cmd" "%GITHUB_WORKSPACE%\tests\windows\assert-allsigned.ps1" -RepoRoot "%GITHUB_WORKSPACE%" -ThumbprintFile "%RUNNER_TEMP%\dotfiles-cert-thumbprint.txt" - shell: cmd diff --git a/README.md b/README.md index 3e1d646..5ecd1bb 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Run this from a normal `cmd.exe` prompt: curl.exe -fsSLo "%TEMP%\dotfiles-bootstrap.cmd" https://raw.githubusercontent.com/jsilverdev/dotfiles/main/bootstrap.cmd && call "%TEMP%\dotfiles-bootstrap.cmd" ``` -The Windows bootstrap requires WinGet. If App Installer exists but WinGet is not registered for the current user, it attempts current-user App Installer registration. If corporate policy disables WinGet, it stops with an error. Bootstrap installs only Git, PowerShell 7, and chezmoi when they are missing; the platform installer owns the remaining application catalog. +The Windows bootstrap requires WinGet. Before applying chezmoi, it removes only broken links or reparse points that block a currently managed destination path. Parent components under the user profile are checked as well, but valid links are never traversed and unrelated broken links are left untouched. If App Installer exists but WinGet is not registered for the current user, it attempts current-user App Installer registration. If corporate policy disables WinGet, it stops with an error. Bootstrap installs only Git, PowerShell 7, and chezmoi when they are missing; the platform installer owns the remaining application catalog. Baseline CLI packages are declared in `scripts/windows/managed-apps.csv` and are installed with WinGet in user scope. An interactive run also installs workstation applications, fonts, Windows Terminal customization, and offers optional applications. `DOTFILES_NONINTERACTIVE=1` installs the complete baseline but skips those interactive/workstation customizations. @@ -26,7 +26,7 @@ On Debian or Arch Linux, run: bash <(curl -fsSL https://raw.githubusercontent.com/jsilverdev/dotfiles/main/bootstrap.sh) ``` -The bootstrap installs only the prerequisites required to obtain/apply the repository, installs chezmoi in `~/.local/bin` when needed, and then runs the package installer. Linux package catalogs are declared under `scripts/linux/`. Non-interactive mode still installs and validates the complete baseline; it only skips shell changes, WSL system configuration, and optional-package prompts. Arch continues to install and manage `yay`. +The bootstrap installs only the prerequisites required to obtain/apply the repository, installs chezmoi in `~/.local/bin` when needed, removes only broken symlinks that block currently managed destination paths, and then applies the source state before running the package installer. Parent components under `$HOME` are checked without traversing valid symlinks, and unrelated broken links are left untouched. Linux package catalogs are declared under `scripts/linux/`. Non-interactive mode still installs and validates the complete baseline; it only skips shell changes, WSL system configuration, and optional-package prompts. Arch continues to install and manage `yay`. ## Updates diff --git a/bootstrap.cmd b/bootstrap.cmd index 88a2cda..861aeca 100644 --- a/bootstrap.cmd +++ b/bootstrap.cmd @@ -6,6 +6,7 @@ if defined DOTFILES_REPO ( ) else ( set "REPO_URL=https://github.com/jsilverdev/dotfiles.git" ) +set "DOTFILES_EXISTING_SOURCE=0" where winget.exe >nul 2>&1 if errorlevel 1 ( echo WinGet is not registered for this user. Attempting App Installer registration... @@ -56,6 +57,23 @@ if not exist "%REPO_ROOT%\scripts\windows\invoke-ps-script.cmd" ( echo Resolved chezmoi working tree does not contain the dotfiles scripts: "%REPO_ROOT%" 1>&2 goto bootstrap_failed ) +if "%DOTFILES_EXISTING_SOURCE%"=="1" ( + git.exe -C "%REPO_ROOT%" pull --autostash --rebase + if errorlevel 1 ( + echo Unable to update the existing chezmoi source tree. 1>&2 + goto bootstrap_failed + ) +) +call "%REPO_ROOT%\scripts\windows\invoke-ps-script.cmd" "%REPO_ROOT%\scripts\windows\cleanup-broken-managed-links.ps1" -RepoRoot "%REPO_ROOT%" +if errorlevel 1 ( + echo Broken managed-link cleanup failed. 1>&2 + goto bootstrap_failed +) +chezmoi.exe --source "%REPO_ROOT%" apply +if errorlevel 1 ( + echo chezmoi apply failed. 1>&2 + goto bootstrap_failed +) if /I "%DOTFILES_NONINTERACTIVE%"=="1" ( call "%REPO_ROOT%\scripts\windows\invoke-ps-script.cmd" "%REPO_ROOT%\install.ps1" -NonInteractive -RepoRoot "%REPO_ROOT%" ) else ( @@ -96,19 +114,16 @@ if defined SOURCE_ROOT for %%P in ("%SOURCE_ROOT%\..") do if exist "%%~fP\instal exit /b 1 :initialize_chezmoi -if exist "%CD%\.chezmoiroot" ( - chezmoi.exe --source "%CD%" apply - exit /b %ERRORLEVEL% -) +if exist "%CD%\.chezmoiroot" exit /b 0 + set "SOURCE_ROOT=" for /f "delims=" %%R in ('chezmoi.exe source-path 2^>nul') do set "SOURCE_ROOT=%%R" if defined SOURCE_ROOT if exist "%SOURCE_ROOT%\.chezmoiroot" goto existing_chezmoi if defined SOURCE_ROOT for %%P in ("%SOURCE_ROOT%\..") do if exist "%%~fP\.chezmoiroot" goto existing_chezmoi + chezmoi.exe init "%REPO_URL%" -if errorlevel 1 exit /b %ERRORLEVEL% -chezmoi.exe apply exit /b %ERRORLEVEL% :existing_chezmoi -chezmoi.exe update -exit /b %ERRORLEVEL% +set "DOTFILES_EXISTING_SOURCE=1" +exit /b 0 diff --git a/bootstrap.sh b/bootstrap.sh index c7e5df1..d57e69b 100644 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -15,6 +15,29 @@ configure_local_chezmoi_source() { fi } +resolve_repo_root() { + local candidate parent + if [[ -f "$PWD/.chezmoiroot" && -f "$PWD/install.sh" ]]; then + printf '%s\n' "$PWD" + return 0 + fi + + for candidate in "$(chezmoi execute-template '{{ .chezmoi.workingTree }}' 2>/dev/null || true)" "$(chezmoi source-path 2>/dev/null || true)"; do + [[ -n "$candidate" ]] || continue + if [[ -f "$candidate/install.sh" ]]; then + printf '%s\n' "$candidate" + return 0 + fi + parent="$(dirname "$candidate")" + if [[ -f "$parent/install.sh" ]]; then + printf '%s\n' "$parent" + return 0 + fi + done + + return 1 +} + if [[ -f /etc/debian_version ]] && command -v apt-get >/dev/null 2>&1; then DISTRO="debian" missing=() @@ -51,38 +74,25 @@ fi export PATH="$HOME/.local/bin:$PATH" command -v chezmoi >/dev/null 2>&1 || { printf 'chezmoi installation failed.\n' >&2; exit 1; } +existing_source=false if [[ -f "$PWD/.chezmoiroot" ]]; then configure_local_chezmoi_source - chezmoi --source "$PWD" apply else SOURCE_ROOT="$(chezmoi source-path 2>/dev/null || true)" if [[ -f "$SOURCE_ROOT/.chezmoiroot" || -f "$(dirname "$SOURCE_ROOT")/.chezmoiroot" ]]; then - chezmoi update + existing_source=true else - chezmoi init --apply "$REPO_URL" + chezmoi init "$REPO_URL" fi fi -resolve_repo_root() { - local candidate parent - if [[ -f "$PWD/.chezmoiroot" && -f "$PWD/install.sh" ]]; then - printf '%s\n' "$PWD" - return 0 - fi - for candidate in "$(chezmoi execute-template '{{ .chezmoi.workingTree }}' 2>/dev/null || true)" "$(chezmoi source-path)"; do - [[ -n "$candidate" ]] || continue - if [[ -f "$candidate/install.sh" ]]; then - printf '%s\n' "$candidate" - return 0 - fi - parent="$(dirname "$candidate")" - if [[ -f "$parent/install.sh" ]]; then - printf '%s\n' "$parent" - return 0 - fi - done - return 1 -} - REPO_ROOT="$(resolve_repo_root)" || { printf 'Unable to resolve the chezmoi working tree.\n' >&2; exit 1; } + +if [[ "$existing_source" == true ]]; then + git -C "$REPO_ROOT" pull --autostash --rebase +fi + +"$REPO_ROOT/scripts/linux/cleanup-broken-managed-links.sh" "$REPO_ROOT" +chezmoi --source "$REPO_ROOT" apply + exec "$REPO_ROOT/install.sh" diff --git a/scripts/linux/cleanup-broken-managed-links.sh b/scripts/linux/cleanup-broken-managed-links.sh new file mode 100755 index 0000000..37dd498 --- /dev/null +++ b/scripts/linux/cleanup-broken-managed-links.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="${1:?repository root is required}" +home_root="${HOME%/}" + +cleanup_managed_target() { + local managed_path=$1 relative current component + + case "$managed_path" in + "$home_root") return 0 ;; + "$home_root"/*) relative=${managed_path#"$home_root"/} ;; + *) return 0 ;; + esac + + current="$home_root" + IFS='/' read -r -a components <<< "$relative" + for component in "${components[@]}"; do + [[ -n "$component" && "$component" != "." ]] || continue + [[ "$component" != ".." ]] || return 0 + + current="$current/$component" + + if [[ -L "$current" ]]; then + if [[ ! -e "$current" ]]; then + printf 'Removing broken managed symlink: %s\n' "$current" + rm -- "$current" + fi + + # Never traverse through a symlink, whether valid or broken. + return 0 + fi + + [[ -e "$current" ]] || return 0 + done +} + +while IFS= read -r -d '' managed_path; do + cleanup_managed_target "$managed_path" +done < <( + chezmoi --source "$repo_root" managed --path-style absolute --nul-path-separator +) diff --git a/scripts/windows/cleanup-broken-managed-links.ps1 b/scripts/windows/cleanup-broken-managed-links.ps1 new file mode 100644 index 0000000..dd2e635 --- /dev/null +++ b/scripts/windows/cleanup-broken-managed-links.ps1 @@ -0,0 +1,88 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$RepoRoot +) + +$ErrorActionPreference = "Stop" + +function Get-DirectoryEntry { + param([Parameter(Mandatory)][string]$Path) + + $parent = [IO.Path]::GetDirectoryName($Path) + $leaf = [IO.Path]::GetFileName($Path) + + if ([string]::IsNullOrWhiteSpace($parent) -or -not (Test-Path -LiteralPath $parent -PathType Container)) { + return $null + } + + Get-ChildItem -LiteralPath $parent -Force -ErrorAction Stop | + Where-Object Name -eq $leaf | + Select-Object -First 1 +} + +function Test-BrokenLink { + param([Parameter(Mandatory)]$Item) + + if (($Item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) { + return $false + } + + try { + $target = $Item.ResolveLinkTarget($true) + return $null -eq $target -or -not $target.Exists + } + catch { + return $true + } +} + +$managedPaths = @(& chezmoi.exe --source $RepoRoot managed --path-style absolute) +if ($LASTEXITCODE -ne 0) { + throw "chezmoi managed failed with exit code $LASTEXITCODE." +} + +$homeRoot = [IO.Path]::GetFullPath($HOME).TrimEnd([IO.Path]::DirectorySeparatorChar) + +foreach ($managedPath in $managedPaths) { + if ([string]::IsNullOrWhiteSpace($managedPath)) { + continue + } + + $fullPath = [IO.Path]::GetFullPath([string]$managedPath) + $relativePath = [IO.Path]::GetRelativePath($homeRoot, $fullPath) + + if ([IO.Path]::IsPathRooted($relativePath) -or $relativePath -eq ".." -or $relativePath.StartsWith("..$([IO.Path]::DirectorySeparatorChar)")) { + continue + } + + $current = $homeRoot + foreach ($component in $relativePath -split '[\\/]') { + if ([string]::IsNullOrWhiteSpace($component) -or $component -eq ".") { + continue + } + if ($component -eq "..") { + break + } + + $current = Join-Path $current $component + $item = Get-DirectoryEntry -Path $current + if ($null -eq $item) { + break + } + + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + if (Test-BrokenLink -Item $item) { + Write-Host "Removing broken managed reparse point: $current" -ForegroundColor Yellow + if ($item.PSIsContainer) { + [IO.Directory]::Delete($current) + } + else { + [IO.File]::Delete($current) + } + } + + # Never traverse through a reparse point, whether valid or broken. + break + } + } +} diff --git a/tests/README.md b/tests/README.md index 977cd6c..76908cb 100644 --- a/tests/README.md +++ b/tests/README.md @@ -3,7 +3,7 @@ GitHub Actions intentionally contains orchestration only. Reusable fixture and assertion logic lives under this directory. - `static/` validates source structure, manifests, shell code, PowerShell syntax, chezmoi templates, the encoded AllSigned bridge, and the workflow with actionlint. -- `linux/` runs the real non-interactive Debian and Arch baseline, including package installation, `yay` on Arch, update behavior, and post-bootstrap assertions. -- `windows/` owns WinGet fixture setup, normal-policy assertions, AllSigned assertions, and update fixtures. +- `linux/` runs the real non-interactive Debian and Arch baseline, including package installation, `yay` on Arch, broken-managed-link recovery, unrelated-link preservation, update behavior, and post-bootstrap assertions. +- `windows/` owns WinGet fixture setup, broken-link migration fixtures, normal-policy assertions, AllSigned assertions, and update fixtures. `DOTFILES_NONINTERACTIVE=1` is the only CI execution mode. It is also a supported real-world mode: baseline dependencies are installed normally while prompts, GUI/workstation customization, shell changes, and WSL setup are skipped. diff --git a/tests/linux/run-ci.sh b/tests/linux/run-ci.sh index 29e7fc3..7b86a0d 100644 --- a/tests/linux/run-ci.sh +++ b/tests/linux/run-ci.sh @@ -24,8 +24,24 @@ as_ci() { sudo -u dotfilesci -H env HOME=/home/dotfilesci USER=dotfilesci "$@" } +as_ci bash -lc ' + mkdir -p "$HOME/.config" + + ln -s /nonexistent/legacy-fdignore "$HOME/.fdignore" + ln -s /nonexistent/legacy-starship "$HOME/.config/starship" + ln -s /nonexistent/unrelated-link "$HOME/.dotfiles-ci-unrelated-broken-link" +' + as_ci GITHUB_WORKSPACE="$repo" DOTFILES_REPO="file://$remote" DOTFILES_NONINTERACTIVE=1 bash -lc 'cd /tmp; bash "$GITHUB_WORKSPACE/bootstrap.sh"' +as_ci bash -lc ' + [[ -f "$HOME/.fdignore" && ! -L "$HOME/.fdignore" ]] + [[ -d "$HOME/.config/starship" && ! -L "$HOME/.config/starship" ]] + [[ -f "$HOME/.config/starship/config.toml" ]] + [[ -L "$HOME/.dotfiles-ci-unrelated-broken-link" ]] + [[ ! -e "$HOME/.dotfiles-ci-unrelated-broken-link" ]] +' + as_ci PATH="/home/dotfilesci/.local/bin:$PATH" GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=safe.directory GIT_CONFIG_VALUE_0="$repo" GITHUB_WORKSPACE="$repo" D="$distro" C="$commit" bash -lc 'bash "$GITHUB_WORKSPACE/tests/linux/assert-state.sh" "$D" "$C"' marker="ci-update-marker-${GITHUB_RUN_ID:-local}" diff --git a/tests/static/validate-architecture.py b/tests/static/validate-architecture.py index 82d9269..ebfa350 100644 --- a/tests/static/validate-architecture.py +++ b/tests/static/validate-architecture.py @@ -35,15 +35,18 @@ def read_text(path: str) -> str: required_paths = ( "home", "scripts/linux/packages-debian.txt", + "scripts/linux/cleanup-broken-managed-links.sh", "scripts/linux/packages-arch.txt", "scripts/linux/required-commands.txt", "scripts/windows/managed-apps.csv", + "scripts/windows/cleanup-broken-managed-links.ps1", "scripts/windows/managed-modules.txt", "scripts/windows/invoke-ps-script.cmd", "scripts/windows/invoke-ps-script-bridge.ps1", "scripts/windows/signing.ps1", "scripts/windows/deploy-pwsh.ps1", "home/.chezmoiscripts/run_after_90-deploy-pwsh.cmd.tmpl", + "tests/windows/assert-migration.ps1", ) for relative in required_paths: if not (ROOT / relative).exists(): diff --git a/tests/static/validate-source.sh b/tests/static/validate-source.sh index 5426ef6..e290818 100644 --- a/tests/static/validate-source.sh +++ b/tests/static/validate-source.sh @@ -11,6 +11,7 @@ validate_shell() { update.sh tests/linux/assert-state.sh tests/linux/run-ci.sh + scripts/linux/cleanup-broken-managed-links.sh tests/static/validate-source.sh ) bash -n "${shell_files[@]}" diff --git a/tests/windows/assert-migration.ps1 b/tests/windows/assert-migration.ps1 new file mode 100644 index 0000000..445c39b --- /dev/null +++ b/tests/windows/assert-migration.ps1 @@ -0,0 +1,59 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = "Stop" + +function Fail([string]$Message) { + throw "ASSERTION FAILED: $Message" +} + +function Get-DirectoryEntry { + param([Parameter(Mandatory)][string]$Path) + + $parent = [IO.Path]::GetDirectoryName($Path) + $leaf = [IO.Path]::GetFileName($Path) + + if ([string]::IsNullOrWhiteSpace($parent) -or -not (Test-Path -LiteralPath $parent -PathType Container)) { + return $null + } + + Get-ChildItem -LiteralPath $parent -Force -ErrorAction Stop | + Where-Object Name -eq $leaf | + Select-Object -First 1 +} + +function Test-BrokenLink { + param([Parameter(Mandatory)]$Item) + + if (($Item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) { + return $false + } + + try { + $target = $Item.ResolveLinkTarget($true) + return $null -eq $target -or -not $target.Exists + } + catch { + return $true + } +} + +$fdignore = Get-DirectoryEntry -Path (Join-Path $HOME ".fdignore") +if ($null -eq $fdignore -or ($fdignore.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + Fail "managed .fdignore was not restored as a regular file" +} + +$starshipDirectory = Get-DirectoryEntry -Path (Join-Path $HOME ".config\starship") +if ($null -eq $starshipDirectory -or -not $starshipDirectory.PSIsContainer -or ($starshipDirectory.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + Fail "managed starship parent was not restored as a regular directory" +} +if (-not (Test-Path -LiteralPath (Join-Path $HOME ".config\starship\config.toml") -PathType Leaf)) { + Fail "managed starship config is missing" +} + +$unrelated = Get-DirectoryEntry -Path (Join-Path $HOME ".dotfiles-ci-unrelated-broken-link") +if ($null -eq $unrelated -or -not (Test-BrokenLink -Item $unrelated)) { + Fail "unrelated broken link was modified or removed" +} + +Write-Host "Broken managed-link migration assertions passed." diff --git a/tests/windows/prepare-ci.ps1 b/tests/windows/prepare-ci.ps1 index 0772dbf..6b0c118 100644 --- a/tests/windows/prepare-ci.ps1 +++ b/tests/windows/prepare-ci.ps1 @@ -42,3 +42,33 @@ function New-ExactCommitRemote { Ensure-WinGet Add-CiUserPaths New-ExactCommitRemote + + +function New-BrokenFileSymlinkFixture { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Target + ) + + New-Item -ItemType Directory -Force -Path ([IO.Path]::GetDirectoryName($Path)) | Out-Null + New-Item -ItemType Directory -Force -Path ([IO.Path]::GetDirectoryName($Target)) | Out-Null + Set-Content -LiteralPath $Target -Value "fixture" + New-Item -ItemType SymbolicLink -Path $Path -Target $Target -Force | Out-Null + Remove-Item -LiteralPath $Target -Force +} + +function New-BrokenDirectorySymlinkFixture { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Target + ) + + New-Item -ItemType Directory -Force -Path ([IO.Path]::GetDirectoryName($Path)) | Out-Null + New-Item -ItemType Directory -Force -Path $Target | Out-Null + New-Item -ItemType SymbolicLink -Path $Path -Target $Target -Force | Out-Null + Remove-Item -LiteralPath $Target -Recurse -Force +} + +New-BrokenFileSymlinkFixture -Path (Join-Path $HOME ".fdignore") -Target (Join-Path $env:RUNNER_TEMP "legacy-fdignore") +New-BrokenDirectorySymlinkFixture -Path (Join-Path $HOME ".config\starship") -Target (Join-Path $env:RUNNER_TEMP "legacy-starship") +New-BrokenFileSymlinkFixture -Path (Join-Path $HOME ".dotfiles-ci-unrelated-broken-link") -Target (Join-Path $env:RUNNER_TEMP "unrelated-link")