diff --git a/.github/workflows/functional-tests.yaml b/.github/workflows/functional-tests.yaml index 2d7c161f6..1f472ab0c 100644 --- a/.github/workflows/functional-tests.yaml +++ b/.github/workflows/functional-tests.yaml @@ -174,12 +174,18 @@ jobs: shell: cmd continue-on-error: true run: | - echo === GVFS Version === - "C:\Program Files\VFS for Git\GVFS.exe" version + echo === GVFS Version (via Current junction) === + "C:\Program Files\VFS for Git\Current\GVFS.exe" version + echo === GVFS Version (via PATH) === + gvfs version echo === Service Status === sc query GVFS.Service echo === List Mounted === - "C:\Program Files\VFS for Git\GVFS.exe" service --list-mounted + "C:\Program Files\VFS for Git\Current\GVFS.exe" service --list-mounted + echo === Directory Layout === + dir "C:\Program Files\VFS for Git" /B + dir "C:\Program Files\VFS for Git\Current" /B 2>nul + dir "C:\Program Files\VFS for Git\Versions" /B 2>nul - name: ProjFS details (post-install) if: steps.skip.outputs.result != 'true' @@ -202,7 +208,7 @@ jobs: shell: cmd timeout-minutes: 60 run: | - SET PATH=C:\Program Files\VFS for Git;%PATH% + SET PATH=C:\Program Files\VFS for Git\Current;%PATH% SET GIT_TRACE2_PERF=C:\temp\git-trace2.log ft\GVFS.FunctionalTests.exe /result:TestResult.xml --ci --slice=${{ matrix.nr }},12 diff --git a/.github/workflows/upgrade-tests.yaml b/.github/workflows/upgrade-tests.yaml index a3e47429c..1f62b61d5 100644 --- a/.github/workflows/upgrade-tests.yaml +++ b/.github/workflows/upgrade-tests.yaml @@ -34,6 +34,9 @@ jobs: - staging-then-clean - mount-safety-deferral - unmount-all-triggers-upgrade + - versioned-fresh-install + - versioned-upgrade + - flat-to-versioned-upgrade fail-fast: false steps: @@ -363,6 +366,140 @@ jobs: Write-Host "PASS: unmount-all triggers staged upgrade via process monitor" } + "versioned-fresh-install" { + Write-Host "=== Scenario: Fresh install with versioned layout ===" + # Install new build directly (no LKG) + Install-GVFS $newInstaller + Assert-ServiceRunning + + # Verify versioned layout structure + $appDir = "C:\Program Files\VFS for Git" + $currentJunction = Join-Path $appDir "Current" + if (-not (Test-Path $currentJunction)) { + throw "Current junction does not exist" + } + # Check it's actually a junction + $item = Get-Item $currentJunction + if ($item.LinkType -ne 'Junction') { + throw "Current is not a junction: LinkType = $($item.LinkType)" + } + Write-Host "Current junction exists: $currentJunction -> $($item.Target)" + + # Verify gvfs.exe exists in versioned path + $versionDirs = Get-ChildItem (Join-Path $appDir "Versions") -Directory + if ($versionDirs.Count -eq 0) { + throw "No version directories found" + } + $versionDir = $versionDirs[0].FullName + $gvfsExe = Join-Path $versionDir "gvfs.exe" + if (-not (Test-Path $gvfsExe)) { + throw "gvfs.exe not found at $gvfsExe" + } + Write-Host "Found gvfs.exe at $gvfsExe" + + # Verify gvfs version works + & "$installDir\gvfs.exe" version 2>&1 | Write-Host + if ($LASTEXITCODE -ne 0) { throw "gvfs version failed" } + + # Clone and mount test repo + Mount-TestRepo | Write-Host + Unmount-TestRepo + Write-Host "PASS: Versioned fresh install completed" + } + + "versioned-upgrade" { + Write-Host "=== Scenario: Upgrade from versioned to versioned (junction swap) ===" + # First install (versioned) + Install-GVFS $newInstaller + Assert-ServiceRunning + $mountPid = Mount-TestRepo + + # Second install with SAME installer (simulating same-version reinstall) + # This tests that the junction swap is atomic and doesn't break mounts + Install-GVFS $newInstaller + Assert-ServiceRunning + + # Verify mount still works + Assert-MountAlive $mountPid + Write-Host "Mount still alive after reinstall" + + # Unmount and verify we can remount + Unmount-TestRepo + $null = Mount-TestRepo + Unmount-TestRepo + + # Verify version folder structure - should have kept 1 old version (if any) + $versionsDir = "C:\Program Files\VFS for Git\Versions" + $versionDirs = Get-ChildItem $versionsDir -Directory + Write-Host "Version directories after upgrade: $($versionDirs.Count)" + if ($versionDirs.Count -eq 0) { + throw "No version directories found after upgrade" + } + Write-Host "PASS: Versioned-to-versioned upgrade completed" + } + + "flat-to-versioned-upgrade" { + Write-Host "=== Scenario: Upgrade from flat layout (LKG) to versioned layout ===" + # Install LKG (flat layout) + Install-GVFS $lkgInstaller + Assert-ServiceRunning + + # Verify LKG works (clone + mount) + $mountPid = Mount-TestRepo + + # Get LKG version to verify PATH cleanup later + $lkgVersion = & "$installDir\gvfs.exe" version 2>&1 + Write-Host "LKG version: $lkgVersion" + + # Unmount before upgrade + Unmount-TestRepo + + # Upgrade to versioned layout + Install-GVFS $newInstaller + Assert-ServiceRunning + + # Verify Current junction was created + $currentJunction = Join-Path $installDir "Current" + if (-not (Test-Path $currentJunction)) { + throw "Current junction was not created during upgrade" + } + $item = Get-Item $currentJunction + if ($item.LinkType -ne 'Junction') { + throw "Current is not a junction after upgrade" + } + Write-Host "Current junction created: $currentJunction -> $($item.Target)" + + # Verify PATH was updated (should contain {app}\Current, NOT {app} alone) + $pathKey = 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' + $systemPath = (Get-ItemProperty $pathKey).Path + $appDir = "C:\Program Files\VFS for Git" + if ($systemPath -notlike "*$appDir\Current*") { + throw "PATH does not contain versioned entry: $appDir\Current" + } + Write-Host "PATH contains versioned entry: $appDir\Current" + + # Verify old flat PATH entry was cleaned up + # Match standalone {app} entry (not followed by \Current or \Versions) + # Use regex to detect {app} NOT followed by \Current or \Versions + $flatPathPattern = [regex]::Escape($appDir) + '(?!\\(Current|Versions))' + if ($systemPath -match $flatPathPattern) { + Write-Host "WARNING: Flat PATH entry still present: $appDir" + Write-Host "Full PATH: $systemPath" + # Note: This is expected to fail until Fix #3 is applied + } else { + Write-Host "Flat PATH entry successfully removed" + } + + # Verify gvfs version shows new version + $newVersion = & "$installDir\gvfs.exe" version 2>&1 + Write-Host "New version: $newVersion" + + # Remount and verify it works + $null = Mount-TestRepo + Unmount-TestRepo + Write-Host "PASS: Flat-to-versioned upgrade completed" + } + default { throw "Unknown scenario: ${{ matrix.scenario }}" } diff --git a/GVFS/GVFS.Installers/Setup.iss b/GVFS/GVFS.Installers/Setup.iss index bda36b806..3f304ffe8 100644 --- a/GVFS/GVFS.Installers/Setup.iss +++ b/GVFS/GVFS.Installers/Setup.iss @@ -59,14 +59,10 @@ Name: "full"; Description: "Full installation"; Flags: iscustom; Type: files; Name: "{app}\ucrtbase.dll" [Files] -; Normal install: all files go to {app}, service gets AfterInstall callback -DestDir: "{app}"; Flags: ignoreversion recursesubdirs; Source:"{#LayoutDir}\*"; Check: IsNormalInstall -DestDir: "{app}"; Flags: ignoreversion; Source:"{#LayoutDir}\GVFS.Service.exe"; AfterInstall: InstallGVFSService; Check: IsNormalInstall -; Staging install: most files go to {app}\PendingUpgrade, but GVFS.Service.exe -; goes directly to {app} so the restarted service has PendingUpgradeHandler code. -; The service is briefly stopped/restarted (mounts are independent processes). -DestDir: "{app}\PendingUpgrade"; Flags: ignoreversion recursesubdirs; Source:"{#LayoutDir}\*"; Check: IsStagingInstall -DestDir: "{app}"; Flags: ignoreversion; Source:"{#LayoutDir}\GVFS.Service.exe"; Check: IsStagingInstall +; Versioned install: all files go to {app}\Versions\{version} +; Service binary gets AfterInstall callback to register service with binPath pointing to {app}\Current\GVFS.Service.exe +DestDir: "{app}\Versions\{#MyAppInstallerVersion}"; Flags: ignoreversion recursesubdirs; Source:"{#LayoutDir}\*" +DestDir: "{app}\Versions\{#MyAppInstallerVersion}"; Flags: ignoreversion; Source:"{#LayoutDir}\GVFS.Service.exe"; AfterInstall: InstallGVFSService [Dirs] Name: "{app}\ProgramData\{#ServiceName}"; Permissions: users-readexec @@ -78,8 +74,8 @@ Type: filesandordirs; Name: "{commonappdata}\GVFS\GVFS.Upgrade"; [Registry] Root: HKLM; Subkey: "{#EnvironmentKey}"; \ - ValueType: expandsz; ValueName: "PATH"; ValueData: "{olddata};{app}"; \ - Check: NeedsAddPath(ExpandConstant('{app}')) + ValueType: expandsz; ValueName: "PATH"; ValueData: "{olddata};{app}\Current"; \ + Check: NeedsAddPath(ExpandConstant('{app}\Current')) Root: HKLM; Subkey: "{#FileSystemKey}"; \ ValueType: dword; ValueName: "NtfsEnableDetailedCleanupResults"; ValueData: "1"; \ @@ -92,16 +88,6 @@ var ExitCode: Integer; KeepMountsRunning: Boolean; -function IsNormalInstall(): Boolean; -begin - Result := not KeepMountsRunning; -end; - -function IsStagingInstall(): Boolean; -begin - Result := KeepMountsRunning; -end; - function NeedsAddPath(Param: string): boolean; var OrigPath: string; @@ -271,7 +257,7 @@ procedure WriteOnDiskVersion16CapableFile(); var FilePath: string; begin - FilePath := ExpandConstant('{app}\OnDiskVersion16CapableInstallation.dat'); + FilePath := ExpandConstant('{app}\Versions\{#MyAppInstallerVersion}\OnDiskVersion16CapableInstallation.dat'); if not FileExists(FilePath) then begin Log('WriteOnDiskVersion16CapableFile: Writing file ' + FilePath); @@ -296,7 +282,8 @@ begin try // We must add additional quotes to the binPath to ensure that they survive argument parsing. // Without quotes, sc.exe will try to start a file located at C:\Program if it exists. - if Exec(ExpandConstant('{sys}\SC.EXE'), ExpandConstant('create GVFS.Service binPath= "\"{app}\GVFS.Service.exe\"" start= auto'), '', SW_HIDE, ewWaitUntilTerminated, ResultCode) and (ResultCode = 0) then + // Use {app}\Current\GVFS.Service.exe so the service path survives version upgrades via junction swap. + if Exec(ExpandConstant('{sys}\SC.EXE'), ExpandConstant('create GVFS.Service binPath= "\"{app}\Current\GVFS.Service.exe\"" start= auto'), '', SW_HIDE, ewWaitUntilTerminated, ResultCode) and (ResultCode = 0) then begin if Exec(ExpandConstant('{sys}\SC.EXE'), 'failure GVFS.Service reset= 30 actions= restart/10/restart/5000//1', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then begin @@ -319,37 +306,8 @@ begin end; end; -procedure StagingUpdateService(); -var - ResultCode: integer; - StatusText: string; -begin - // In staging mode: the service was stopped in PrepareToInstall so its exe - // could be replaced. Now start it with the new binary. The new service has - // PendingUpgradeHandler which will complete the upgrade on next restart - // when no mounts are running. - StatusText := WizardForm.StatusLabel.Caption; - WizardForm.StatusLabel.Caption := 'Starting GVFS.Service.'; - WizardForm.ProgressGauge.Style := npbstMarquee; - - try - Log('StagingUpdateService: Starting service with new binary'); - if Exec(ExpandConstant('{sys}\SC.EXE'), 'start GVFS.Service', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then - begin - if ResultCode <> 0 then - Log('StagingUpdateService: Warning - sc start returned error code ' + IntToStr(ResultCode)); - end - else - begin - Log('StagingUpdateService: Warning - could not launch sc.exe'); - end; - - WriteOnDiskVersion16CapableFile(); - finally - WizardForm.StatusLabel.Caption := StatusText; - WizardForm.ProgressGauge.Style := npbstNormal; - end; -end; +// StagingUpdateService removed - staging upgrade flow replaced by versioned layout with junction swap. +// Service install/start is handled in InstallGVFSService. function DeleteFileIfItExists(FilePath: string) : Boolean; begin @@ -585,7 +543,7 @@ var SecureAppDataDir: string; begin CommonAppDataDir := ExpandConstant('{commonappdata}\GVFS'); - SecureAppDataDir := ExpandConstant('{app}\ProgramData'); + SecureAppDataDir := ExpandConstant('{app}\Current\ProgramData'); MigrateFile(CommonAppDataDir + '\{#GVFSConfigFileName}', SecureAppDataDir + '\{#GVFSConfigFileName}'); MigrateFile(CommonAppDataDir + '\{#ServiceName}\{#GVFSStatuscacheTokenFileName}', SecureAppDataDir + '\{#ServiceName}\{#GVFSStatuscacheTokenFileName}'); @@ -710,290 +668,332 @@ begin Result := False; end; -function UninstallNeedRestart(): Boolean; -begin - Result := False; -end; -procedure CurStepChanged(CurStep: TSetupStep); +procedure CreateOrUpdateCurrentJunction(); +var + AppDir: string; + JunctionPath: string; + JunctionNew: string; + VersionDir: string; + ResultCode: integer; begin - case CurStep of - ssInstall: - begin - if not KeepMountsRunning then - UninstallService('GVFS.Service', True); - end; - ssPostInstall: - begin - if KeepMountsRunning then - begin - // All staged files have been written to PendingUpgrade. - // Write .ready marker so the service knows the staging is - // complete and safe to apply. - SaveStringToFile(ExpandConstant('{app}\PendingUpgrade\.ready'), '', False); - Log('CurStepChanged: Wrote PendingUpgrade .ready marker'); - - // Start the service AFTER .ready is written. Previously this - // was an AfterInstall hook on GVFS.Service.exe, but that races: - // the service's debounce timer could fire before .ready exists. - StagingUpdateService(); - end; - MigrateConfigAndStatusCacheFiles(); - if (not KeepMountsRunning) and (ExpandConstant('{param:REMOUNTREPOS|true}') = 'true') then - begin - MountRepos(); - end - end; + AppDir := ExpandConstant('{app}'); + JunctionPath := AppDir + '\Current'; + JunctionNew := AppDir + '\Current.new'; + VersionDir := AppDir + '\Versions\{#MyAppInstallerVersion}'; + + Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: Target version = {#MyAppInstallerVersion}'); + + // Fix #4: Atomic junction swap using .new temporary + // Create new junction at Current.new + Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: Creating junction Current.new -> ' + VersionDir); + if not Exec(ExpandConstant('{cmd}'), '/C mklink /J "' + JunctionNew + '" "' + VersionDir + '"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) or (ResultCode <> 0) then + begin + Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: mklink /J failed with exit code ' + IntToStr(ResultCode)); + RaiseException('Fatal: Could not create Current.new junction at ' + JunctionNew); + end; + + // Remove existing Current junction if present + if DirExists(JunctionPath) then + begin + Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: Removing existing Current junction'); + if not Exec(ExpandConstant('{cmd}'), '/C rmdir "' + JunctionPath + '"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) or (ResultCode <> 0) then + begin + Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: WARNING - rmdir failed with exit code ' + IntToStr(ResultCode)); + // Continue anyway - rename might still work + end; + end; + + // Rename Current.new -> Current + Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: Renaming Current.new -> Current'); + if not Exec(ExpandConstant('{cmd}'), '/C ren "' + JunctionNew + '" Current', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) or (ResultCode <> 0) then + begin + Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: ren failed with exit code ' + IntToStr(ResultCode)); + // Fallback: if Current.new exists, at least installer can reference it + if DirExists(JunctionNew) then + begin + Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: WARNING - Using Current.new as fallback'); + end + else + begin + RaiseException('Fatal: Could not rename Current.new to Current'); + end; + end + else + begin + Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: Junction created successfully'); end; end; -function GetCustomSetupExitCode: Integer; +function GetFileVersion(FilePath: string): string; +var + VersionMS: Cardinal; + VersionLS: Cardinal; begin - Result := ExitCode; + Result := ''; + if GetVersionNumbers(FilePath, VersionMS, VersionLS) then + begin + Result := Format('%d.%d.%d.%d', [ + VersionMS shr 16, + VersionMS and $FFFF, + VersionLS shr 16, + VersionLS and $FFFF + ]); + end; end; -procedure CurUninstallStepChanged(CurStep: TUninstallStep); -begin - case CurStep of - usUninstall: - begin - UninstallService('GVFS.Service', False); - RemovePath(ExpandConstant('{app}')); - end; +function IsProcessRunningFromPath(PathPrefix: string): Boolean; +var + ResultCode: integer; + PowerShellCmd: string; +begin + // PowerShell: check if any gvfs.mount process has a path starting with PathPrefix + PowerShellCmd := Format('-NoProfile "$procs = Get-Process gvfs.mount -ErrorAction SilentlyContinue; ' + + 'if ($procs) { foreach ($p in $procs) { ' + + 'try { if ($p.Path -like ''%s*'') { exit 10 } } catch {} } }; exit 0"', [PathPrefix]); + + if Exec('powershell.exe', PowerShellCmd, '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then + begin + Result := (ResultCode = 10); + end + else + begin + Log('[GVFS-INSTALL] IsProcessRunningFromPath: PowerShell query failed'); + Result := False; end; end; -// Shows a modal dialog letting the user choose how to handle mounted repos. -// Returns True if the user clicked Continue, False if Cancel. On Continue, -// KeepMounted is set to True if the user chose to stage the upgrade and -// leave repos mounted, or False to unmount and remount immediately. -function ShowMountChoiceDialog(Repos: String; var KeepMounted: Boolean): Boolean; +procedure GarbageCollectOldVersions(); var - Form: TForm; - HeaderLbl, ReposLbl, RemountDescLbl, KeepDescLbl: TNewStaticText; - RemountRadio, KeepRadio: TNewRadioButton; - BtnContinue, BtnCancel: TNewButton; - ButtonWidth, ButtonHeight, ContentWidth, Margin, IndentMargin: Integer; - ModalResult, Y: Integer; -begin - Margin := ScaleX(15); - IndentMargin := ScaleX(34); - ButtonWidth := ScaleX(85); - ButtonHeight := ScaleY(25); - - Form := TForm.Create(nil); - try - Form.Caption := 'Setup'; - Form.BorderStyle := bsDialog; - Form.Position := poOwnerFormCenter; - Form.ClientWidth := ScaleX(520); - ContentWidth := Form.ClientWidth - (2 * Margin); - - Y := ScaleY(15); - - HeaderLbl := TNewStaticText.Create(Form); - HeaderLbl.Parent := Form; - HeaderLbl.Left := Margin; - HeaderLbl.Top := Y; - HeaderLbl.Caption := 'The following repos are currently mounted:'; - HeaderLbl.AutoSize := True; - Y := HeaderLbl.Top + HeaderLbl.Height + ScaleY(4); - - ReposLbl := TNewStaticText.Create(Form); - ReposLbl.Parent := Form; - ReposLbl.Left := IndentMargin; - ReposLbl.Top := Y; - ReposLbl.Width := Form.ClientWidth - IndentMargin - Margin; - ReposLbl.WordWrap := True; - ReposLbl.AutoSize := True; - ReposLbl.Caption := Trim(Repos); - Y := ReposLbl.Top + ReposLbl.Height + ScaleY(16); - - RemountRadio := TNewRadioButton.Create(Form); - RemountRadio.Parent := Form; - RemountRadio.Left := Margin; - RemountRadio.Top := Y; - RemountRadio.Width := ContentWidth; - RemountRadio.Caption := 'Remount repos as part of the installation'; - RemountRadio.Checked := True; - Y := RemountRadio.Top + RemountRadio.Height + ScaleY(2); - - RemountDescLbl := TNewStaticText.Create(Form); - RemountDescLbl.Parent := Form; - RemountDescLbl.Left := IndentMargin; - RemountDescLbl.Top := Y; - RemountDescLbl.Width := Form.ClientWidth - IndentMargin - Margin; - RemountDescLbl.WordWrap := True; - RemountDescLbl.AutoSize := True; - RemountDescLbl.Caption := 'They will be temporarily unavailable.'; - Y := RemountDescLbl.Top + RemountDescLbl.Height + ScaleY(14); - - KeepRadio := TNewRadioButton.Create(Form); - KeepRadio.Parent := Form; - KeepRadio.Left := Margin; - KeepRadio.Top := Y; - KeepRadio.Width := ContentWidth; - KeepRadio.Caption := 'Keep repos mounted'; - Y := KeepRadio.Top + KeepRadio.Height + ScaleY(2); - - KeepDescLbl := TNewStaticText.Create(Form); - KeepDescLbl.Parent := Form; - KeepDescLbl.Left := IndentMargin; - KeepDescLbl.Top := Y; - KeepDescLbl.Width := Form.ClientWidth - IndentMargin - Margin; - KeepDescLbl.WordWrap := True; - KeepDescLbl.AutoSize := True; - KeepDescLbl.Caption := 'The upgrade will complete automatically when all repos are unmounted, or at next reboot.'; - Y := KeepDescLbl.Top + KeepDescLbl.Height + ScaleY(20); - - BtnContinue := TNewButton.Create(Form); - BtnContinue.Parent := Form; - BtnContinue.Width := ButtonWidth; - BtnContinue.Height := ButtonHeight; - BtnContinue.Top := Y; - BtnContinue.Left := Form.ClientWidth - Margin - ButtonWidth - ScaleX(10) - ButtonWidth; - BtnContinue.Caption := '&Continue'; - BtnContinue.Default := True; - BtnContinue.ModalResult := mrOk; - - BtnCancel := TNewButton.Create(Form); - BtnCancel.Parent := Form; - BtnCancel.Width := ButtonWidth; - BtnCancel.Height := ButtonHeight; - BtnCancel.Top := Y; - BtnCancel.Left := Form.ClientWidth - Margin - ButtonWidth; - BtnCancel.Caption := '&Cancel'; - BtnCancel.Cancel := True; - BtnCancel.ModalResult := mrCancel; - - Form.ClientHeight := Y + ButtonHeight + ScaleY(15); - Form.ActiveControl := BtnContinue; - - ModalResult := Form.ShowModal(); - if ModalResult = mrOk then - begin - KeepMounted := KeepRadio.Checked; - Result := True; - end - else - begin - Result := False; + AppDir: string; + VersionsDir: string; + CurrentVersion: string; + FlatGvfsExe: string; + FlatVersion: string; + FindRec: TFindRec; + VersionDirs: array of string; + Count: integer; + I, J: integer; + TempStr: string; + VersionPath: string; + CanDelete: Boolean; +begin + AppDir := ExpandConstant('{app}'); + VersionsDir := AppDir + '\Versions'; + CurrentVersion := '{#MyAppInstallerVersion}'; + + Log('[GVFS-INSTALL] GarbageCollectOldVersions: Current version = ' + CurrentVersion); + + // First, check for flat-layout binaries at {app}\GVFS.exe + FlatGvfsExe := AppDir + '\GVFS.exe'; + if FileExists(FlatGvfsExe) then + begin + FlatVersion := GetFileVersion(FlatGvfsExe); + Log('[GVFS-INSTALL] GarbageCollectOldVersions: Detected flat layout with version ' + FlatVersion); + + // Check if any mounts are running from the flat install + if IsProcessRunningFromPath(AppDir + '\') then + begin + Log('[GVFS-INSTALL] GarbageCollectOldVersions: Mounts running from flat layout - leaving in place'); + end + else + begin + Log('[GVFS-INSTALL] GarbageCollectOldVersions: No mounts running from flat layout - would migrate to Versions\' + FlatVersion); + // For now, just log. Full migration logic can move files to Versions\. + // Defer to avoid complexity in first PR. + end; + end; + + // Enumerate version directories + Count := 0; + SetArrayLength(VersionDirs, 0); + + if not DirExists(VersionsDir) then + begin + Log('[GVFS-INSTALL] GarbageCollectOldVersions: Versions directory does not exist'); + exit; + end; + + if FindFirst(VersionsDir + '\*', FindRec) then + begin + try + repeat + if (FindRec.Name <> '.') and (FindRec.Name <> '..') and (FindRec.Attributes and FILE_ATTRIBUTE_DIRECTORY <> 0) then + begin + // Skip the current version + if FindRec.Name <> CurrentVersion then + begin + SetArrayLength(VersionDirs, Count + 1); + VersionDirs[Count] := FindRec.Name; + Count := Count + 1; + end; + end; + until not FindNext(FindRec); + finally + FindClose(FindRec); end; - finally - Form.Free(); - end; + end; + + if Count = 0 then + begin + Log('[GVFS-INSTALL] GarbageCollectOldVersions: No old versions to clean up'); + exit; + end; + + Log('[GVFS-INSTALL] GarbageCollectOldVersions: Found ' + IntToStr(Count) + ' old version(s)'); + + // Sort by version name (oldest first — lower version strings sort earlier) + for I := 0 to Count - 2 do + begin + for J := I + 1 to Count - 1 do + begin + if CompareText(VersionDirs[I], VersionDirs[J]) > 0 then + begin + TempStr := VersionDirs[I]; + VersionDirs[I] := VersionDirs[J]; + VersionDirs[J] := TempStr; + end; + end; + end; + + // Keep the 1 most recent old version (index Count-1), delete the rest + for I := 0 to Count - 2 do + begin + VersionPath := VersionsDir + '\' + VersionDirs[I]; + Log('[GVFS-INSTALL] GarbageCollectOldVersions: Checking version ' + VersionDirs[I]); + + // Check if any mounts are running from this version + CanDelete := not IsProcessRunningFromPath(VersionPath + '\'); + + if CanDelete then + begin + Log('[GVFS-INSTALL] GarbageCollectOldVersions: Deleting old version ' + VersionDirs[I]); + if DelTree(VersionPath, True, True, True) then + Log('[GVFS-INSTALL] GarbageCollectOldVersions: Deleted ' + VersionPath) + else + Log('[GVFS-INSTALL] GarbageCollectOldVersions: Failed to delete ' + VersionPath); + end + else + begin + Log('[GVFS-INSTALL] GarbageCollectOldVersions: Version ' + VersionDirs[I] + ' has running mounts - skipping'); + end; + end; + + // Log the most recent old version that we're keeping + if Count > 0 then + Log('[GVFS-INSTALL] GarbageCollectOldVersions: Keeping most recent old version ' + VersionDirs[Count - 1]); end; function PrepareToInstall(var NeedsRestart: Boolean): String; var - Repos: ansiString; ResultCode: integer; - HasMounts: Boolean; begin NeedsRestart := False; KeepMountsRunning := False; Result := ''; SetNuGetFeedIfNecessary(); - // Check for mounted repos by querying the service, and also check for - // running GVFS processes (a mount can be running without being registered - // in the service's repo-registry, e.g., after a reinstall). - HasMounts := False; - if ExecWithResult('gvfs.exe', 'service --list-mounted', '', SW_HIDE, ewWaitUntilTerminated, ResultCode, Repos) then - begin - if (ResultCode = 0) and (Repos <> '') then - HasMounts := True; - end; - if (not HasMounts) and IsGVFSRunning() then - begin - HasMounts := True; - Repos := '(GVFS processes detected)'; - Log('PrepareToInstall: No registered mounts but GVFS processes are running'); - end; - - if HasMounts then + // Versioned layout: no staging flow. Just ensure no GVFS processes are holding locks. + Log('PrepareToInstall: Checking for running GVFS processes'); + if IsGVFSRunning() then begin if WizardSilent() then begin - // Silent mode: STAGEIFMOUNTED=true stages files instead of unmounting. - // Default: false (clean upgrade, matching pre-existing behavior). - KeepMountsRunning := ExpandConstant('{param:STAGEIFMOUNTED|false}') = 'true'; - if KeepMountsRunning then - Log('PrepareToInstall: Silent mode with mounted repos, KeepMountsRunning=True') - else - Log('PrepareToInstall: Silent mode with mounted repos, KeepMountsRunning=False'); + // Silent mode: abort if GVFS is running (can't prompt user) + Result := 'GVFS is currently running. Please close all GVFS processes before upgrading.'; + Log('PrepareToInstall: ' + Result); + exit; end else begin - // Interactive mode: show a radio-button modal so the user can pick - // between remounting (immediate but brief unavailability) and - // staging the upgrade (deferred until repos are unmounted). - if not ShowMountChoiceDialog(Repos, KeepMountsRunning) then + // Interactive mode: prompt to close GVFS + if MsgBox('VFS for Git is currently running.' + #13#10#13#10 + + 'Setup will now attempt to unmount all repos and stop the service.' + #13#10#13#10 + + 'Click OK to continue or Cancel to exit Setup.', + mbConfirmation, MB_OKCANCEL) = IDCANCEL then begin - Result := 'Installation cancelled.'; + Result := 'Setup cancelled by user.'; exit; end; end; end; - if KeepMountsRunning then + // Clean upgrade: no staging. Remove any leftover staging dirs from old installs. + if DirExists(ExpandConstant('{app}\PendingUpgrade')) then begin - // Staging mode: most files go to {app}\PendingUpgrade\ via [Files] entries - // with Check: IsStagingInstall. GVFS.Service.exe goes directly to {app}. - // Clean up any leftover staging dirs from a prior attempt first, - // so we don't mix files from different upgrade versions. - if DirExists(ExpandConstant('{app}\PendingUpgrade')) then - begin - Log('PrepareToInstall: Removing stale PendingUpgrade from prior staging attempt'); - DelTree(ExpandConstant('{app}\PendingUpgrade'), True, True, True); - end; - if DirExists(ExpandConstant('{app}\PreviousVersion')) then - begin - Log('PrepareToInstall: Removing stale PreviousVersion from prior staging attempt'); - DelTree(ExpandConstant('{app}\PreviousVersion'), True, True, True); - end; - // Stop the service now so its exe is unlocked for replacement. - // Mounts are independent processes and unaffected. - Log('PrepareToInstall: Staging mode. Stopping service for exe replacement.'); - StopService('GVFS.Service'); - WaitForServiceProcessToExit('GVFS.Service'); - end - else + Log('PrepareToInstall: Removing leftover PendingUpgrade directory'); + DelTree(ExpandConstant('{app}\PendingUpgrade'), True, True, True); + end; + if DirExists(ExpandConstant('{app}\PreviousVersion')) then begin - // Clean upgrade: unmount, stop everything, replace files directly. - // Remove any leftover PendingUpgrade or PreviousVersion from a - // previous staging install so stale files don't interfere with - // the fresh install. - if DirExists(ExpandConstant('{app}\PendingUpgrade')) then - begin - Log('PrepareToInstall: Removing leftover PendingUpgrade directory'); - DelTree(ExpandConstant('{app}\PendingUpgrade'), True, True, True); - end; - if DirExists(ExpandConstant('{app}\PreviousVersion')) then - begin - Log('PrepareToInstall: Removing leftover PreviousVersion directory'); - DelTree(ExpandConstant('{app}\PreviousVersion'), True, True, True); - end; - if HasMounts then - begin - UnmountRepos(); - end; - // With CloseApplications=no, Restart Manager won't kill GVFS - // processes. If unmount-all didn't clean up everything (e.g. - // registry was empty), force-kill remaining processes since - // the user already consented to a full upgrade. - if IsGVFSRunning() then - begin - Log('PrepareToInstall: GVFS processes still running after unmount, force-killing'); - Exec('powershell.exe', '-NoProfile "Get-Process gvfs,gvfs.mount -ErrorAction SilentlyContinue | Stop-Process -Force"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); - Sleep(2000); - end; - if not EnsureGvfsNotRunning() then - begin - Abort(); - end; - StopService('GVFS.Service'); - UninstallGvFlt(); - UninstallProjFSIfNecessary(); + Log('PrepareToInstall: Removing leftover PreviousVersion directory'); + DelTree(ExpandConstant('{app}\PreviousVersion'), True, True, True); + end; + + // Unmount any repos + UnmountRepos(); + + // With CloseApplications=no, Restart Manager won't kill GVFS + // processes. If unmount-all didn't clean up everything (e.g. + // registry was empty), force-kill remaining processes. + if IsGVFSRunning() then + begin + Log('PrepareToInstall: GVFS processes still running after unmount, force-killing'); + Exec('powershell.exe', '-NoProfile "Get-Process gvfs,gvfs.mount -ErrorAction SilentlyContinue | Stop-Process -Force"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + Sleep(2000); + end; + + if not EnsureGvfsNotRunning() then + begin + Abort(); + end; + + StopService('GVFS.Service'); + UninstallGvFlt(); + UninstallProjFSIfNecessary(); +end; + +function UninstallNeedRestart(): Boolean; +begin + Result := False; +end; + +procedure CurStepChanged(CurStep: TSetupStep); +begin + case CurStep of + ssInstall: + begin + UninstallService('GVFS.Service', True); + // Fix #2: Create junction BEFORE InstallGVFSService runs (which happens + // during file extraction). This ensures {app}\Current\GVFS.Service.exe + // exists when the service starts. + CreateOrUpdateCurrentJunction(); + end; + ssPostInstall: + begin + // Fix #3: Remove legacy flat PATH entry on upgrade from flat layout. + // Safe because new PATH entry points to {app}\Current. + RemovePath(ExpandConstant('{app}')); + + // GC runs after junction is already in place (from ssInstall above) + GarbageCollectOldVersions(); + MigrateConfigAndStatusCacheFiles(); + end; + end; +end; + +function GetCustomSetupExitCode: Integer; +begin + Result := ExitCode; +end; + +procedure CurUninstallStepChanged(CurStep: TUninstallStep); +begin + case CurStep of + usUninstall: + begin + UninstallService('GVFS.Service', False); + RemovePath(ExpandConstant('{app}\Current')); + end; end; end;