Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions .github/workflows/upgrade-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 }}"
}
Expand Down
37 changes: 37 additions & 0 deletions GVFS/GVFS.Common/IScheduledTaskInvoker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System.Collections.Generic;

namespace GVFS.Common
{
/// <summary>
/// Abstracts the Windows Task Scheduler operations needed by
/// <see cref="LogonTaskRegistration"/>. Production callers use
/// <see cref="SchTasksScheduledTaskInvoker"/>; tests pass a mock so
/// they can exercise <see cref="LogonTaskRegistration"/>'s logic
/// without actually touching the Task Scheduler on the test machine.
/// </summary>
public interface IScheduledTaskInvoker
{
/// <summary>
/// Register the task at <paramref name="taskPath"/> from the given
/// XML, overwriting any existing task at that path. Returns
/// <c>true</c> on success.
/// </summary>
bool TryRegisterFromXml(string taskPath, string xml, out string errorMessage);

/// <summary>
/// Read back the registered XML for the task at
/// <paramref name="taskPath"/>. Returns <c>true</c> with the XML
/// when the task exists; returns <c>false</c> with a populated
/// <paramref name="errorMessage"/> when it does not.
/// </summary>
bool TryQueryXml(string taskPath, out string xml, out string errorMessage);

/// <summary>
/// Unregister the task at <paramref name="taskPath"/>. Returns
/// <c>true</c> if the task was unregistered OR was not registered
/// to begin with (idempotent). Returns <c>false</c> only on a hard
/// failure (e.g., permission denied).
/// </summary>
bool TryUnregister(string taskPath, out string errorMessage);
}
}
64 changes: 64 additions & 0 deletions GVFS/GVFS.Common/LocalRepoRegistration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
using System.Text.Json;
using System.Text.Json.Serialization;

namespace GVFS.Common
{
/// <summary>
/// One entry in the user-level repo registry on disk. Field set and
/// JSON shape MUST match GVFS.Service.RepoRegistration so that the
/// user-level registry file (written by <see cref="LocalRepoRegistry"/>)
/// is wire-compatible with any registry the legacy service has written
/// in the past. If a new field is added here, the same field must also
/// be added to GVFS.Service.RepoRegistration (and vice versa) along
/// with a registry-format-version bump.
/// </summary>
public class LocalRepoRegistration
{
public LocalRepoRegistration()
{
}

public LocalRepoRegistration(string enlistmentRoot, string ownerSID)
{
this.EnlistmentRoot = enlistmentRoot;
this.OwnerSID = ownerSID;
this.IsActive = true;
}

public string EnlistmentRoot { get; set; }
public string OwnerSID { get; set; }
public bool IsActive { get; set; }

// Uses LocalRepoRegistrationJsonContext (assembly-local source generator)
// rather than GVFSJsonContext. The service-side RepoRegistration uses
// its own ServiceJsonContext for the same reason — neither type can be
// registered in GVFSJsonContext because GVFSJsonContext lives in
// GVFS.Common and the service-side type lives in GVFS.Service (wrong
// dependency direction). Keeping symmetric local contexts here means
// the on-disk JSON shape is governed by identical source-gen behavior
// on both sides.
public static LocalRepoRegistration FromJson(string json)
{
return JsonSerializer.Deserialize(json, LocalRepoRegistrationJsonContext.Default.LocalRepoRegistration);
}

public string ToJson()
{
return JsonSerializer.Serialize(this, LocalRepoRegistrationJsonContext.Default.LocalRepoRegistration);
}

public override string ToString()
{
return string.Format(
"({0} - {1}) {2}",
this.IsActive ? "Active" : "Inactive",
this.OwnerSID,
this.EnlistmentRoot);
}
}

[JsonSerializable(typeof(LocalRepoRegistration))]
internal partial class LocalRepoRegistrationJsonContext : JsonSerializerContext
{
}
}
Loading
Loading