diff --git a/Sources/MintCLI/Commands/UninstallCommand.swift b/Sources/MintCLI/Commands/UninstallCommand.swift index 3d7cf7a..1151006 100644 --- a/Sources/MintCLI/Commands/UninstallCommand.swift +++ b/Sources/MintCLI/Commands/UninstallCommand.swift @@ -5,13 +5,14 @@ import SwiftCLI class UninstallCommand: MintCommand { @Param var package: String + @Key("-V", "--version", description: "Specify the version to uninstall") var version: String? init(mint: Mint) { - super.init(mint: mint, name: "uninstall", description: "Uninstall a package by name") + super.init(mint: mint, name: "uninstall", description: "Uninstall a package or a specific version by name") } override func execute() throws { try super.execute() - try mint.uninstall(name: package) + try mint.uninstall(name: package, version: version) } } diff --git a/Sources/MintKit/Cache.swift b/Sources/MintKit/Cache.swift index e9b0359..485f6de 100644 --- a/Sources/MintKit/Cache.swift +++ b/Sources/MintKit/Cache.swift @@ -28,6 +28,10 @@ struct Cache: Hashable { let packages: [PackageInfo] init(path: Path, metadata: Mint.Metadata, linkedExecutables: [Path]) throws { + guard path.exists else { + packages = [] + return + } packages = try path.children() .filter { $0.isDirectory && !$0.lastComponent.hasPrefix(".") } .map { originPath in diff --git a/Sources/MintKit/Mint.swift b/Sources/MintKit/Mint.swift index 7e5c791..30bb129 100644 --- a/Sources/MintKit/Mint.swift +++ b/Sources/MintKit/Mint.swift @@ -515,6 +515,16 @@ public class Mint { } } } + + /// Checks if a string is likely a Git SHA commit hash (heuristic: at least 7 hex characters). + /// - Parameter value: The string to check + /// - Returns: `true` if the string appears to be a SHA, `false` otherwise + private func isLikelySHA(_ value: String) -> Bool { + let minSHALength = 7 + let hexSet = CharacterSet(charactersIn: "0123456789abcdefABCDEF") + return value.count >= minSHALength && + value.unicodeScalars.allSatisfy { hexSet.contains($0) } + } public func bootstrap(link: Bool = false, overwrite: Bool? = nil) throws { @@ -584,7 +594,11 @@ public class Mint { } } - public func uninstall(name: String) throws { + /// Uninstall a package entirely or a specific installed version. + /// - Parameters: + /// - name: The package name (or a case-insensitive substring of the git repo) + /// - version: Optional specific version to uninstall. If nil, all installed versions are removed. + public func uninstall(name: String, version: String? = nil) throws { // find packages var metadata = try readMetadata() @@ -592,7 +606,7 @@ public class Mint { let cache = try Cache(path: packagesPath, metadata: metadata, linkedExecutables: linkedExecutables) let packages = cache.packages.filter { $0.gitRepo.lowercased().contains(name.lowercased()) } - // remove package + // select package to operate on let package: Cache.PackageInfo switch packages.count { case 0: @@ -605,30 +619,101 @@ public class Mint { package = packages.first { $0.gitRepo == option }! } - // get resources across all installed versions + // determine which version dirs to delete + let versionDirsToDelete: [Cache.VersionDir] + if let version = version { + let exactMatches = package.versionDirs.filter { $0.version == version } + + if !exactMatches.isEmpty { + versionDirsToDelete = exactMatches + } else if isLikelySHA(version) { + let shaMatches = package.versionDirs.filter { $0.version.hasPrefix(version) } + + if shaMatches.isEmpty { + errorOutput("SHA version '\(version)' for package \(package.name) was not found".red) + return + } + + // Warn if multiple versions match the SHA prefix to prevent accidental deletion + if shaMatches.count > 1 { + let matchingVersions = shaMatches.map { $0.version }.joined(separator: ", ") + errorOutput("SHA prefix '\(version)' matches multiple versions: \(matchingVersions)".yellow) + errorOutput("Please provide a longer SHA prefix or the full SHA to uniquely identify the version.".yellow) + return + } + + versionDirsToDelete = shaMatches + } else { + errorOutput("Version '\(version)' for package \(package.name) was not found".red) + return + } + } else { + // no version specified → uninstall all versions + versionDirsToDelete = package.versionDirs + } + + // get resources for the versions we will remove let resources = Set( - try package.versionDirs + try versionDirsToDelete .map { try getResources(from: $0.path) } .flatMap { $0 } ) - try package.path.delete() - output("\(package.name) was uninstalled") + // check if any version directories will remain after deletion (before deleting) + let buildPath = package.path + "build" + var remainingVersionDirs: [String] = [] + let versionsToDelete = Set(versionDirsToDelete.map { $0.version }) + if buildPath.exists { + do { + remainingVersionDirs = try buildPath.children() + .filter { $0.isDirectory && !$0.lastComponent.hasPrefix(".") } + .map { $0.lastComponent } + .filter { !versionsToDelete.contains($0) } + } catch { + errorOutput("Failed to read build path '\(buildPath)': \(error)".red) + return + } + } + let removedAllVersions = remainingVersionDirs.isEmpty - // remove metadata - metadata.packages[package.gitRepo] = nil - try writeMetadata(metadata) + // delete the selected version directories + for vd in versionDirsToDelete { + try vd.path.delete() + } + + if removedAllVersions { + // fully removed package; ensure package path cleanup and metadata update + try package.path.delete() + output("\(package.name) was uninstalled") + // remove metadata entry + metadata.packages[package.gitRepo] = nil + try writeMetadata(metadata) + } else { + // only specific version(s) removed + // metadata remains unchanged because package still has installed versions + let removedVersionsList = versionDirsToDelete.map { $0.version }.joined(separator: ", ") + output("\(package.name) (\(removedVersionsList)) was uninstalled") + } - // remove link - for executable in Set(package.versionDirs.flatMap { $0.executables }) where executable.linked { + // remove links for executables belonging to removed versions + for executable in Set(versionDirsToDelete.flatMap { $0.executables }) where executable.linked { let installPath = linkPath + executable.name try installPath.delete() } - // remove all resource artifact links + // remove resource artifact links related only to removed versions for resource in resources { - let installPath = linkPath + resource.lastComponent - try installPath.delete() + let resourceName = resource.lastComponent + + let stillUsed = remainingVersionDirs.contains { versionDir in + let candidatePath = buildPath + versionDir + resourceName + return candidatePath.exists + } + + if !stillUsed { + let installPath = linkPath + resourceName + try installPath.delete() + } } } } diff --git a/Tests/MintTests/MintTests.swift b/Tests/MintTests/MintTests.swift index 4eee085..beaef7c 100644 --- a/Tests/MintTests/MintTests.swift +++ b/Tests/MintTests/MintTests.swift @@ -308,4 +308,162 @@ class MintTests: XCTestCase { XCTAssertEqual(try mint.listPackages(), [:]) XCTAssertEqual(try mint.readMetadata().packages, [:]) } + + func testUninstallSpecificVersionRemovesOnlyThatVersionAndLinks() throws { + let globalPath = mint.linkPath + testCommand + // Use versions that exist in the test fixtures/repo + let packageOne = PackageReference(repo: testRepo, version: testVersion) + let packageTwo = PackageReference(repo: testRepo, version: latestVersion) + + // install two versions + try mint.install(package: packageOne, link: true) + try mint.install(package: packageTwo, link: true) + + // check everything expected is there + // installing and linking the newer version should update the symlink + XCTAssertTrue(globalPath.exists) + XCTAssertEqual(mint.getLinkedExecutables(), [expectedExecutablePath(latestVersion)]) + XCTAssertEqual(try mint.listPackages(), [fullTestRepo: [testVersion, latestVersion]]) + XCTAssertEqual(try mint.readMetadata().packages, [fullTestRepo: testPackageDir]) + + // Perform uninstall for specific version + try mint.uninstall(name: testRepo, version: testVersion) + + // Assert: older version removed, newer version still present + XCTAssertFalse((mintPath + "packages" + testPackageDir + "build" + testVersion).exists, "Requested version should be removed") + XCTAssertTrue((mintPath + "packages" + testPackageDir + "build" + latestVersion).exists, "Other versions should remain") + + // Symlink should still exist and point to the remaining (newer) version + XCTAssertTrue(globalPath.exists) + XCTAssertEqual(mint.getLinkedExecutables(), [expectedExecutablePath(latestVersion)]) + + // Metadata should still contain the package because a version remains + XCTAssertEqual(try mint.readMetadata().packages, [fullTestRepo: testPackageDir]) + + // Verify package list is updated correctly after uninstalling specific version + XCTAssertEqual(try mint.listPackages(), [fullTestRepo: [latestVersion]]) + } + + func testUninstallSpecificVersionRemovesLastVersion() throws { + let globalPath = mint.linkPath + testCommand + let package = PackageReference(repo: testRepo, version: testVersion) + + // install version + try mint.install(package: package, link: true) + + // verify installed + XCTAssertTrue(globalPath.exists) + XCTAssertEqual(try mint.listPackages(), [fullTestRepo: [testVersion]]) + XCTAssertEqual(try mint.readMetadata().packages, [fullTestRepo: testPackageDir]) + + // uninstall the only version + try mint.uninstall(name: testRepo, version: testVersion) + + // verify package is completely removed + XCTAssertFalse(globalPath.exists) + XCTAssertEqual(mint.getLinkedExecutables(), []) + XCTAssertEqual(try mint.listPackages(), [:]) + XCTAssertEqual(try mint.readMetadata().packages, [:]) + } + + func testUninstallShaVersion() throws { + let shaVersion = "c3cf95c" + let package = PackageReference(repo: testRepo, version: shaVersion) + + // install SHA version (only this version) + try mint.install(package: package) + + // verify installed + XCTAssertTrue((mintPath + "packages" + testPackageDir + "build" + shaVersion).exists) + let installedPackages = try mint.listPackages() + XCTAssertEqual(installedPackages[fullTestRepo], [shaVersion]) + + // uninstall using SHA prefix (removes the only version, so package should be completely removed) + try mint.uninstall(name: testRepo, version: shaVersion) + + // verify package is completely removed + XCTAssertFalse((mintPath + "packages" + testPackageDir + "build" + shaVersion).exists) + let remainingPackages = try mint.listPackages() + XCTAssertEqual(remainingPackages, [:]) + } + + func testUninstallInvalidVersion() throws { + let package = PackageReference(repo: testRepo, version: testVersion) + + // install a version + try mint.install(package: package) + + // verify installed + let packagesBefore = try mint.listPackages() + XCTAssertEqual(packagesBefore[fullTestRepo], [testVersion]) + + // try to uninstall non-existent version + try mint.uninstall(name: testRepo, version: "99.99.99") + + // verify nothing was removed (error should have prevented deletion) + let packagesAfter = try mint.listPackages() + XCTAssertEqual(packagesAfter[fullTestRepo], [testVersion]) + XCTAssertTrue((mintPath + "packages" + testPackageDir + "build" + testVersion).exists) + } + + func testUninstallInvalidSha() throws { + let package = PackageReference(repo: testRepo, version: testVersion) + + // install a version + try mint.install(package: package) + + // verify installed + let packagesBefore = try mint.listPackages() + XCTAssertEqual(packagesBefore[fullTestRepo], [testVersion]) + + // try to uninstall non-existent SHA + try mint.uninstall(name: testRepo, version: "abc1234") + + // verify nothing was removed (error should have prevented deletion) + let packagesAfter = try mint.listPackages() + XCTAssertEqual(packagesAfter[fullTestRepo], [testVersion]) + XCTAssertTrue((mintPath + "packages" + testPackageDir + "build" + testVersion).exists) + } + + func testUninstallWhenNoVersionsInstalled() throws { + // try to uninstall a version when package is not installed at all + try mint.uninstall(name: testRepo, version: testVersion) + + // verify no error state (function should return gracefully) + XCTAssertEqual(try mint.listPackages(), [:]) + } + + func testUninstallAmbiguousShaPrefix() throws { + // Install a SHA version + let shaVersion1 = "c3cf95c" + try mint.install(package: PackageReference(repo: testRepo, version: shaVersion1)) + + // Create setup: two versions with same SHA prefix (neither exact match for the prefix) + let buildPath = mintPath + "packages" + testPackageDir + "build" + let firstVersionPath = buildPath + shaVersion1 + let longerShaVersion1 = "c3cf95c0" + let longerFirstVersionPath = buildPath + longerShaVersion1 + let shaVersion2 = "c3cf95c1234567890abcdef" + let secondVersionPath = buildPath + shaVersion2 + + // Rename first version to be longer (so prefix isn't exact match) + try firstVersionPath.move(longerFirstVersionPath) + + // Create second version with same prefix + try secondVersionPath.mkpath() + let executable = try longerFirstVersionPath.children().first(where: { $0.isFile && $0.extension == nil }) + if let executable = executable { + try executable.copy(secondVersionPath + executable.lastComponent) + } + + // Try to uninstall with prefix that matches both versions + try mint.uninstall(name: testRepo, version: "c3cf95c") + + // Verify no versions were deleted (ambiguous prefix should prevent deletion) + XCTAssertTrue(longerFirstVersionPath.exists) + XCTAssertTrue(secondVersionPath.exists) + + // Cleanup + try? secondVersionPath.delete() + } }