Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions Sources/ContainerCommands/Image/ImageCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ extension Application {
ImagePush.self,
ImageSave.self,
ImageTag.self,
ImageTree.self,
],
aliases: ["i"]
)
Expand Down
191 changes: 191 additions & 0 deletions Sources/ContainerCommands/Image/ImageTree.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//

import ArgumentParser

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ContainerizationError - ContainerPersistence - ContainerPlugin are all unused imports

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, these are redundancies that I inadvertently overlooked. Furthermore, I encountered an additional bottleneck during performance optimization and testing.
Should you find merit in this feature proposal, please consider actively contributing to its development.

As an active container user I felt this is a needed requirement for the project
@gnavadev

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a nice QoL improvement. However, I wonder if something like this would be better as a plugin or in a separate repository, maybe something like apple-container-tools? What do you think @jglogan ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First of all, thank you both for the valuable discussion.@gnavadev and @jglogan

@gnavadev, thank you for the insightful suggestions and direction. I agree that this should be developed as a separate utility. When working with more complex workflows, it becomes difficult to keep track of active containers, and having a dedicated utility would make that much more manageable. It also aligns well with Apple's approach of providing focused, user-friendly utilities.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @gnavadev for the thorough review and great suggestions! I’ve updated the branch with the following fixes:

Unused Imports: Removed ContainerizationError, ContainerPersistence, and ContainerPlugin.
Size Aggregation: Switched to using reduce to sum the size across all variants.
Tree-Building Logic & Performance:
Fixed the sorting mismatch by using min()/max() across all variants' diffID counts to align with the isPrefixOfAny matching logic.
Hoisted the candidate list generation out of the inner loop to avoid redundant flatMap/sorting operations on every iteration.

import ContainerAPIClient
import ContainerPersistence
import ContainerPlugin
import ContainerResource
import Containerization
import ContainerizationError
import ContainerizationOCI
import Foundation

extension Application {
public struct ImageTree: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "tree",
abstract: "Show images in a tree view based on parent-child relationships",
aliases: ["tr"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is likely a lesser used command, and it only saves two characters.

)

@Flag(name: .shortAndLong, help: "Show image sizes")
var size = false

@OptionGroup
public var logOptions: Flags.Logging

public mutating func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()

var images = try await ClientImage.list().filter { img in
!Utility.isInfraImage(name: img.reference, builderImage: containerSystemConfig.build.image, initImage: containerSystemConfig.vminit.image)
}
images.sort { $0.reference < $1.reference }

let resources = try await Self.buildResources(images: images, containerSystemConfig: containerSystemConfig)

// Build tree
let rootNodes = buildTree(from: resources)

// Print tree
if rootNodes.isEmpty {
return
}

for (index, node) in rootNodes.enumerated() {
// Determine root print style. The root doesn't have the ├── marker
printRootNode(node, isLast: index == rootNodes.count - 1)
}
}

private static func buildResources(images: [ClientImage], containerSystemConfig: ContainerSystemConfig) async throws -> [ImageResource] {
var resources: [ImageResource] = []
for image in images {
resources.append(
try await image.toImageResource(containerSystemConfig: containerSystemConfig)
)
}
return resources
}

private class Node {
let resource: ImageResource
let allDiffIDs: [[String]]
let displaySize: String
var children: [Node] = []

init(resource: ImageResource, allDiffIDs: [[String]], displaySize: String) {
self.resource = resource
self.allDiffIDs = allDiffIDs
self.displaySize = displaySize
}
}

private func buildTree(from resources: [ImageResource]) -> [Node] {
let formatter = ByteCountFormatter()
var nodes: [Node] = []

for resource in resources {
let allDiffIDs = resource.variants.map { $0.config.rootfs.diffIDs }
let sizeStr = resource.variants.first.map { formatter.string(fromByteCount: $0.size) } ?? "0 MB"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be a good use case for reduce

nodes.append(Node(resource: resource, allDiffIDs: allDiffIDs, displaySize: sizeStr))
}

// Sort nodes by number of diffIDs (parents have fewer diffIDs)
nodes.sort { ($0.allDiffIDs.first?.count ?? 0) < ($1.allDiffIDs.first?.count ?? 0) }

var roots: [Node] = []

for node in nodes {
var foundParent = false
// Find a parent: a node with maximum diffIDs that is a strict prefix of current node's diffIDs
for potentialParent in roots.flatMap({ getAllNodes(in: $0) }).sorted(by: { ($0.allDiffIDs.first?.count ?? 0) > ($1.allDiffIDs.first?.count ?? 0) }) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some questions that I have in mind for this:
1 - You are sorting the nodes based only on the first variant's DiffIDs ($0.allDiffIDs.first), but assigning parents using isPrefixOfAny, which loops through all variants. Doesn't this mismatch break the tree's ordering guarantee?
2 - You are calling flatMap and .sorted() inside the for node in nodes loop. This means you are unpacking the entire tree into a flat list and re-sorting it from scratch on every single iteration, which will severely bottleneck performance.

if isPrefixOfAny(potentialParent.allDiffIDs, of: node.allDiffIDs) {
potentialParent.children.append(node)
foundParent = true
break
}
}

if !foundParent {
roots.append(node)
}
}

// Sort roots and children alphabetically
roots.sort { $0.resource.displayReference < $1.resource.displayReference }
for root in roots {
sortChildren(of: root)
}

return roots
}

private func getAllNodes(in root: Node) -> [Node] {
var result = [root]
for child in root.children {
result.append(contentsOf: getAllNodes(in: child))
}
return result
}

private func sortChildren(of node: Node) {
node.children.sort { $0.resource.displayReference < $1.resource.displayReference }
for child in node.children {
sortChildren(of: child)
}
}

private func isPrefixOfAny(_ parentDiffIDsList: [[String]], of childDiffIDsList: [[String]]) -> Bool {
for parentDiffIDs in parentDiffIDsList {
for childDiffIDs in childDiffIDsList {
if isPrefix(parentDiffIDs, of: childDiffIDs) {
return true
}
}
}
return false
}

private func isPrefix(_ prefix: [String], of full: [String]) -> Bool {
guard prefix.count < full.count && prefix.count > 0 else { return false }
for i in 0..<prefix.count {
if prefix[i] != full[i] {
return false
}
}
return true
}

private func printRootNode(_ node: Node, isLast: Bool) {
var line = node.resource.displayReference
if size {
line += " (\(node.displaySize))"
}
print(line)

for (index, child) in node.children.enumerated() {
printNode(child, prefix: "", isLast: index == node.children.count - 1)
}
}

private func printNode(_ node: Node, prefix: String, isLast: Bool) {
let marker = isLast ? "└── " : "├── "
var line = prefix + marker + node.resource.displayReference
if size {
line += " (\(node.displaySize))"
}
print(line)

let childPrefix = prefix + (isLast ? " " : "│ ")
for (index, child) in node.children.enumerated() {
printNode(child, prefix: childPrefix, isLast: index == node.children.count - 1)
}
}
}
}