Skip to content
3 changes: 2 additions & 1 deletion 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")
}

override func execute() throws {
try super.execute()
try mint.uninstall(name: package)
try mint.uninstall(name: package, version: version)
}
}
89 changes: 75 additions & 14 deletions Sources/MintKit/Mint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -584,15 +584,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 +609,87 @@ 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 {
// try exact match first
let matches = package.versionDirs.filter { $0.version == version }
if matches.isEmpty {
// fallback: contains (helps when user passes just a short sha / partial)
let fuzzy = package.versionDirs.filter { $0.version.contains(version) }
if fuzzy.isEmpty {
errorOutput("Version '\(version)' for package \(package.name) was not found".red)
return
} else {
versionDirsToDelete = fuzzy
}
} else {
versionDirsToDelete = matches
}
} else {
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()
}
}
}
}
38 changes: 38 additions & 0 deletions Tests/MintTests/MintTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -308,4 +308,42 @@ 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
let metadataData = try (mintPath + "metadata.json").read()
if let meta = try JSONSerialization.jsonObject(with: metadataData, options: []) as? [String: Any],
let packages = meta["packages"] as? [String: String] {
XCTAssertEqual(packages[fullTestRepo], testPackageDir, "Metadata mapping should still exist when package has remaining versions")
} else {
XCTFail("metadata.json could not be parsed")
}
}
}