From 8d3e2f5fd2cf4ba33a4d61063ccae11335f52892 Mon Sep 17 00:00:00 2001 From: dev-kvt Date: Fri, 24 Jul 2026 00:05:48 +0530 Subject: [PATCH 1/3] feat: Add container image tree command --- .../Image/ImageCommand.swift | 1 + .../ContainerCommands/Image/ImageTree.swift | 191 ++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 Sources/ContainerCommands/Image/ImageTree.swift diff --git a/Sources/ContainerCommands/Image/ImageCommand.swift b/Sources/ContainerCommands/Image/ImageCommand.swift index 9c94bd46a..08da41efd 100644 --- a/Sources/ContainerCommands/Image/ImageCommand.swift +++ b/Sources/ContainerCommands/Image/ImageCommand.swift @@ -34,6 +34,7 @@ extension Application { ImagePush.self, ImageSave.self, ImageTag.self, + ImageTree.self, ], aliases: ["i"] ) diff --git a/Sources/ContainerCommands/Image/ImageTree.swift b/Sources/ContainerCommands/Image/ImageTree.swift new file mode 100644 index 000000000..8dbfe2eca --- /dev/null +++ b/Sources/ContainerCommands/Image/ImageTree.swift @@ -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 +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"] + ) + + @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" + 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) }) { + 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.. Date: Fri, 24 Jul 2026 01:49:50 +0530 Subject: [PATCH 2/3] Refactor: Remove unused isLast argument from printRootNode --- Sources/ContainerCommands/Image/ImageTree.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/ContainerCommands/Image/ImageTree.swift b/Sources/ContainerCommands/Image/ImageTree.swift index 8dbfe2eca..ecb94363c 100644 --- a/Sources/ContainerCommands/Image/ImageTree.swift +++ b/Sources/ContainerCommands/Image/ImageTree.swift @@ -57,9 +57,9 @@ extension Application { return } - for (index, node) in rootNodes.enumerated() { + for node in rootNodes { // Determine root print style. The root doesn't have the ├── marker - printRootNode(node, isLast: index == rootNodes.count - 1) + printRootNode(node) } } @@ -162,7 +162,7 @@ extension Application { return true } - private func printRootNode(_ node: Node, isLast: Bool) { + private func printRootNode(_ node: Node) { var line = node.resource.displayReference if size { line += " (\(node.displaySize))" From b4ed82ece0d280cbe6ff35c9780fa32b5ca7f664 Mon Sep 17 00:00:00 2001 From: dev-kvt Date: Fri, 24 Jul 2026 23:09:20 +0530 Subject: [PATCH 3/3] fix: refine ImageTree implementation based on review feedback - Removed unused imports - Used reduce for size calculation across all variants - Fixed tree-building to sort consistently across variants and avoid O(n^2*logn) sorting overhead --- .../ContainerCommands/Image/ImageTree.swift | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/Sources/ContainerCommands/Image/ImageTree.swift b/Sources/ContainerCommands/Image/ImageTree.swift index ecb94363c..0035d0d20 100644 --- a/Sources/ContainerCommands/Image/ImageTree.swift +++ b/Sources/ContainerCommands/Image/ImageTree.swift @@ -16,11 +16,8 @@ import ArgumentParser import ContainerAPIClient -import ContainerPersistence -import ContainerPlugin import ContainerResource import Containerization -import ContainerizationError import ContainerizationOCI import Foundation @@ -92,19 +89,31 @@ extension Application { 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" + let totalSize = resource.variants.reduce(Int64(0)) { $0 + $1.size } + let sizeStr = formatter.string(fromByteCount: totalSize) 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) } + // Sort nodes by the minimum diffID count across all variants (parents have fewer layers) + nodes.sort { lhs, rhs in + let lhsMin = lhs.allDiffIDs.map(\.count).min() ?? 0 + let rhsMin = rhs.allDiffIDs.map(\.count).min() ?? 0 + return lhsMin < rhsMin + } 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) }) { + // Build a flat list of all placed nodes, sorted by most layers first (deepest match wins) + let candidates = roots.flatMap { getAllNodes(in: $0) } + .sorted { lhs, rhs in + let lhsMax = lhs.allDiffIDs.map(\.count).max() ?? 0 + let rhsMax = rhs.allDiffIDs.map(\.count).max() ?? 0 + return lhsMax > rhsMax + } + + for potentialParent in candidates { if isPrefixOfAny(potentialParent.allDiffIDs, of: node.allDiffIDs) { potentialParent.children.append(node) foundParent = true