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)
}
}
4 changes: 4 additions & 0 deletions Sources/MintKit/Cache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
113 changes: 99 additions & 14 deletions Sources/MintKit/Mint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -584,15 +594,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 +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()
}
}
}
}
158 changes: 158 additions & 0 deletions Tests/MintTests/MintTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}