Skip to content
5 changes: 3 additions & 2 deletions Sources/MintCLI/Commands/UninstallCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
100 changes: 86 additions & 14 deletions Sources/MintKit/Mint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,13 @@ public class Mint {
}
}
}

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 {

Expand Down Expand Up @@ -584,15 +591,19 @@ 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()
let linkedExecutables = getLinkedExecutables()
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:
Expand All @@ -605,30 +616,91 @@ 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("Version '\(version)' for package \(package.name) was not found".red)
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")
// delete the selected version directories
for vd in versionDirsToDelete {
try vd.path.delete()
}

// remove metadata
metadata.packages[package.gitRepo] = nil
try writeMetadata(metadata)
// check if any version directories remain under build path
let buildPath = package.path + "build"
var remainingVersionDirs: [String] = []
if buildPath.exists {
do {
remainingVersionDirs = try buildPath.children()
.filter { $0.isDirectory && !$0.lastComponent.hasPrefix(".") }
.map { $0.lastComponent }
} catch {
errorOutput("Failed to read build path '\(buildPath)': \(error)".red)
return
}
}
let removedAllVersions = remainingVersionDirs.isEmpty

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
let removedVersionsList = versionDirsToDelete.map { $0.version }.joined(separator: ", ")
output("\(package.name) (\(removedVersionsList)) was uninstalled")
// metadata remains unchanged because package still has installed versions
}

// 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()
}
}
}
}
32 changes: 32 additions & 0 deletions Tests/MintTests/MintTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -308,4 +308,36 @@ 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])
}
}