Skip to content
Merged
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
196 changes: 186 additions & 10 deletions cli/dispatcher/internal/dispatcher/dispatcher_v2_detect.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,20 @@ import (
const (
dispatcherPackagesManifestRelativePath = "Packages/manifest.json"
dispatcherPackagesLockRelativePath = "Packages/packages-lock.json"
dispatcherPackagesRelativePath = "Packages"
dispatcherPackageJSONFileName = "package.json"
dispatcherV2MajorVersion = "2"
dispatcherPackageLockSourceGit = "git"
dispatcherFileDependencyPrefix = "file:"
)

type dispatcherV2Project struct {
IsV2 bool
PackageVersion string
PackageVersionCandidates []string
// AmbiguousEmbedded is set when PackageVersionCandidates came from duplicate embedded package
// directories rather than PackageCache, since the two ambiguities need different recovery guidance.
AmbiguousEmbedded bool
}

type dispatcherPackagesManifest struct {
Expand All @@ -38,21 +43,27 @@ type dispatcherPackagesLock struct {
type dispatcherPackageLockEntry struct {
Version string `json:"version"`
Source string `json:"source"`
Hash string `json:"hash"`
}

type dispatcherPackageJSON struct {
Name string `json:"name"`
Version string `json:"version"`
}

// detectV2DispatcherProject identifies V2 projects from Unity's currently resolved package state.
// Why: a resolved V2 package must take precedence over stale V3 pins that can remain after a downgrade.
// Why disk vs. lock priority: authority follows where Unity actually loads the package entity from.
// For registry/git dependencies Unity loads a PackageCache copy, so the lock's resolved semver is
// authoritative; for file:/embedded packages Unity loads the on-disk directory directly, so that
// directory's own package.json is authoritative there, even when the lock has no usable version for it.
func detectV2DispatcherProject(projectRoot string) (dispatcherV2Project, error) {
manifestDependency, hasPackage, err := dispatcherProjectUnityPackageDependency(projectRoot)
if err != nil {
return dispatcherV2Project{}, err
}
if !hasPackage {
return dispatcherV2Project{}, nil
return detectV2DispatcherEmbeddedProject(projectRoot)
}

lockEntry, found, err := dispatcherPackageLockEntryForProject(projectRoot)
Expand All @@ -66,17 +77,32 @@ func detectV2DispatcherProject(projectRoot string) (dispatcherV2Project, error)
}
return dispatcherV2Project{IsV2: true, PackageVersion: packageVersion}, nil
}

if isDispatcherFileDependency(manifestDependency) {
return detectV2DispatcherFileDependencyProject(projectRoot, manifestDependency)
}

if found && lockEntry.Source != dispatcherPackageLockSourceGit {
return dispatcherV2Project{}, nil
}
if !found && !isDispatcherGitPackageDependency(manifestDependency) {
return dispatcherV2Project{}, nil
}

packageVersions, err := dispatcherPackageCacheVersions(projectRoot)
packageCacheEntries, err := dispatcherPackageCacheEntries(projectRoot)
if err != nil {
return dispatcherV2Project{}, err
}
if found && lockEntry.Source == dispatcherPackageLockSourceGit {
if version, ok := dispatcherPackageCacheVersionForHash(packageCacheEntries, lockEntry.Hash); ok {
if !isDispatcherV2PackageVersion(version) {
return dispatcherV2Project{}, nil
}
return dispatcherV2Project{IsV2: true, PackageVersion: version}, nil
}
}

packageVersions := dispatcherDistinctPackageCacheVersions(packageCacheEntries)
if len(packageVersions) == 0 {
return dispatcherV2Project{}, nil
}
Expand All @@ -95,6 +121,110 @@ func detectV2DispatcherProject(projectRoot string) (dispatcherV2Project, error)
return dispatcherV2Project{IsV2: true, PackageVersionCandidates: packageVersions}, nil
}

// isDispatcherFileDependency reports whether a manifest dependency value is a file: reference,
// covering both local checkouts outside Packages/ and embedded packages referenced by relative path.
func isDispatcherFileDependency(dependency json.RawMessage) bool {
value := ""
if err := json.Unmarshal(dependency, &value); err != nil {
return false
}
return strings.HasPrefix(strings.TrimSpace(value), dispatcherFileDependencyPrefix)
}

// detectV2DispatcherFileDependencyProject reads the package.json at a file: dependency's target
// directly, since file: sources have no reliable semver in packages-lock.json or PackageCache.
func detectV2DispatcherFileDependencyProject(projectRoot string, dependency json.RawMessage) (dispatcherV2Project, error) {
value := ""
if err := json.Unmarshal(dependency, &value); err != nil {
return dispatcherV2Project{}, nil
}
targetDirectory, ok := resolveDispatcherFileDependencyTarget(projectRoot, value)
if !ok {
return dispatcherV2Project{}, nil
}
return detectV2DispatcherPackageAtPath(filepath.Join(targetDirectory, dispatcherPackageJSONFileName))
}

// resolveDispatcherFileDependencyTarget resolves a manifest file: dependency value to an absolute
// directory. Relative values are resolved against Packages/, matching Unity's own resolution base.
// Backslashes are normalized before joining so Windows-authored manifest values still resolve on any OS.
func resolveDispatcherFileDependencyTarget(projectRoot string, dependencyValue string) (string, bool) {
trimmed := strings.TrimSpace(dependencyValue)
if !strings.HasPrefix(trimmed, dispatcherFileDependencyPrefix) {
return "", false
}
rawPath := strings.TrimPrefix(trimmed, dispatcherFileDependencyPrefix)
normalizedPath := filepath.FromSlash(strings.ReplaceAll(rawPath, "\\", "/"))
if normalizedPath == "" {
return "", false
}
if filepath.IsAbs(normalizedPath) {
return filepath.Clean(normalizedPath), true
}
packagesDirectory := filepath.Join(projectRoot, dispatcherPackagesRelativePath)
return filepath.Clean(filepath.Join(packagesDirectory, normalizedPath)), true
}

// detectV2DispatcherEmbeddedProject scans Packages/ for embedded packages, which never appear in
// manifest.json dependencies and are therefore invisible to the manifest-driven detection above.
func detectV2DispatcherEmbeddedProject(projectRoot string) (dispatcherV2Project, error) {
packagesDirectory := filepath.Join(projectRoot, dispatcherPackagesRelativePath)
entries, err := os.ReadDir(packagesDirectory)
if errors.Is(err, os.ErrNotExist) {
return dispatcherV2Project{}, nil
}
if err != nil {
return dispatcherV2Project{}, err
}

versions := map[string]struct{}{}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
packagePath := filepath.Join(packagesDirectory, entry.Name(), dispatcherPackageJSONFileName)
project, err := detectV2DispatcherPackageAtPath(packagePath)
if err != nil {
return dispatcherV2Project{}, err
}
if project.IsV2 {
versions[project.PackageVersion] = struct{}{}
}
}

switch len(versions) {
case 0:
return dispatcherV2Project{}, nil
case 1:
for version := range versions {
return dispatcherV2Project{IsV2: true, PackageVersion: version}, nil
}
}
candidates := make([]string, 0, len(versions))
for version := range versions {
candidates = append(candidates, version)
}
sort.Strings(candidates)
return dispatcherV2Project{IsV2: true, PackageVersionCandidates: candidates, AmbiguousEmbedded: true}, nil
}

// detectV2DispatcherPackageAtPath reads a package.json directly and reports whether it is the V2
// Unity package by name, matching by content rather than by directory naming convention.
func detectV2DispatcherPackageAtPath(packagePath string) (dispatcherV2Project, error) {
packageInfo, err := readDispatcherPackageInfo(packagePath)
if err != nil {
return dispatcherV2Project{}, nil
}
if packageInfo.Name != dispatcherUnityPackageName {
return dispatcherV2Project{}, nil
}
version := strings.TrimSpace(packageInfo.Version)
if !isDispatcherV2PackageVersion(version) {
return dispatcherV2Project{}, nil
}
return dispatcherV2Project{IsV2: true, PackageVersion: version}, nil
}

func dispatcherProjectUnityPackageDependency(projectRoot string) (json.RawMessage, bool, error) {
manifestPath := filepath.Join(projectRoot, filepath.FromSlash(dispatcherPackagesManifestRelativePath))
content, err := os.ReadFile(manifestPath)
Expand Down Expand Up @@ -135,7 +265,14 @@ func isDispatcherGitPackageDependency(dependency json.RawMessage) bool {
}
}

func dispatcherPackageCacheVersions(projectRoot string) ([]string, error) {
// dispatcherPackageCacheEntry pairs a PackageCache directory's hash suffix with its resolved
// package version, so a lock's git hash can be matched to the exact generation it produced.
type dispatcherPackageCacheEntry struct {
DirectorySuffix string
Version string
}

func dispatcherPackageCacheEntries(projectRoot string) ([]dispatcherPackageCacheEntry, error) {
cacheDirectory := filepath.Join(projectRoot, "Library", "PackageCache")
entries, err := os.ReadDir(cacheDirectory)
if errors.Is(err, os.ErrNotExist) {
Expand All @@ -146,7 +283,7 @@ func dispatcherPackageCacheVersions(projectRoot string) ([]string, error) {
}

prefix := dispatcherUnityPackageName + "@"
versions := map[string]struct{}{}
result := make([]dispatcherPackageCacheEntry, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() || !strings.HasPrefix(entry.Name(), prefix) {
continue
Expand All @@ -156,16 +293,47 @@ func dispatcherPackageCacheVersions(projectRoot string) ([]string, error) {
if err != nil {
continue
}
if sharedversion.IsValid(strings.TrimSpace(version)) {
versions[strings.TrimSpace(version)] = struct{}{}
trimmedVersion := strings.TrimSpace(version)
if !sharedversion.IsValid(trimmedVersion) {
continue
}
result = append(result, dispatcherPackageCacheEntry{
DirectorySuffix: strings.TrimPrefix(entry.Name(), prefix),
Version: trimmedVersion,
})
}
return result, nil
}

func dispatcherDistinctPackageCacheVersions(entries []dispatcherPackageCacheEntry) []string {
versions := map[string]struct{}{}
for _, entry := range entries {
versions[entry.Version] = struct{}{}
}
result := make([]string, 0, len(versions))
for version := range versions {
result = append(result, version)
}
sort.Strings(result)
return result, nil
return result
}

// dispatcherPackageCacheVersionForHash disambiguates multiple cached git package generations by
// matching packages-lock.json's resolved commit hash against each PackageCache directory's suffix.
// Why prefix match, not a fixed length: Unity truncates the hash to a directory suffix whose length
// can differ across Editor versions, so the suffix is compared as a leading prefix of the full hash
// rather than a hardcoded character count.
func dispatcherPackageCacheVersionForHash(entries []dispatcherPackageCacheEntry, hash string) (string, bool) {
trimmedHash := strings.TrimSpace(hash)
if trimmedHash == "" {
return "", false
}
for _, entry := range entries {
if entry.DirectorySuffix != "" && strings.HasPrefix(trimmedHash, entry.DirectorySuffix) {
return entry.Version, true
}
}
return "", false
}

func dispatcherPackageLockEntryForProject(projectRoot string) (dispatcherPackageLockEntry, bool, error) {
Expand All @@ -190,15 +358,23 @@ func dispatcherPackageLockEntryForProject(projectRoot string) (dispatcherPackage
}

func readDispatcherPackageVersion(packagePath string) (string, error) {
content, err := os.ReadFile(packagePath)
packageInfo, err := readDispatcherPackageInfo(packagePath)
if err != nil {
return "", err
}
return packageInfo.Version, nil
}

func readDispatcherPackageInfo(packagePath string) (dispatcherPackageJSON, error) {
content, err := os.ReadFile(packagePath)
if err != nil {
return dispatcherPackageJSON{}, err
}
packageInfo := dispatcherPackageJSON{}
if err := json.Unmarshal(content, &packageInfo); err != nil {
return "", fmt.Errorf("parse %s: %w", packagePath, err)
return dispatcherPackageJSON{}, fmt.Errorf("parse %s: %w", packagePath, err)
}
return packageInfo.Version, nil
return packageInfo, nil
}

func isDispatcherV2PackageVersion(version string) bool {
Expand Down
Loading
Loading