diff --git a/.claude/skills/swiftyshell.md b/.claude/skills/swiftyshell.md index 3a84c8a..fac5c37 100644 --- a/.claude/skills/swiftyshell.md +++ b/.claude/skills/swiftyshell.md @@ -21,8 +21,8 @@ Before writing any code, follow this decision tree: → Use `Command("\1", arguments: ...)` 3. Is this a file-system operation covered by a typed wrapper (`Ls`, `Cp`, `Mkdir`, `Chmod`, `Rm`, `Mv`, `Pwd`)? → Use the typed wrapper -4. Is this an archive operation (`zip` to create, `unzip` to extract or list)? - → Use `Zip` or `Unzip` +4. Is this an archive operation (`tar`, `zip`, or `unzip`)? + → Use `Tar`, `Zip`, or `Unzip` 5. Is this a `grep` or `jq` operation? → Use `Grep` or `Jq` 6. Is this a Homebrew operation (`brew install`, `brew upgrade`, `brew list`, ...)? @@ -636,9 +636,68 @@ public struct Pwd: RunnableCommandFamily { } ``` -#### Archives (Zip / Unzip) +#### Archives (Tar / Zip / Unzip) ```swift +public enum TarCompression: Sendable, Equatable, Hashable { + case gzip + case bzip2 + case xz + case auto + case custom(String) +} + +public enum TarOperation: String, Sendable, Equatable, Hashable { + case create + case extract + case list + case append + case update +} + +public struct Tar: RunnableCommandFamily { + public init(context: ShellContext = .init()) + public func create() -> Self + public func extract() -> Self + public func list() -> Self + public func append() -> Self + public func update() -> Self + public func operation(_ value: TarOperation) -> Self + public func file(_ path: String) -> Self + public func path(_ value: String) -> Self + public func paths(_ values: [String]) -> Self + public func directory(_ path: String) -> Self + public func gzip() -> Self + public func bzip2() -> Self + public func xz() -> Self + public func autoCompress() -> Self + public func compression(_ value: TarCompression) -> Self + public func exclude(_ pattern: String) -> Self + public func excludes(_ patterns: [String]) -> Self + public func excludeFrom(_ path: String) -> Self + public func filesFrom(_ path: String) -> Self + public func nullTerminatedFiles(_ enabled: Bool = true) -> Self + public func stripComponents(_ count: Int) -> Self + public func toStdout(_ enabled: Bool = true) -> Self + public func verbose(_ enabled: Bool = true) -> Self + public func verify(_ enabled: Bool = true) -> Self + public func removeFilesAfterAdding(_ enabled: Bool = true) -> Self + public func dereferenceSymlinks(_ enabled: Bool = true) -> Self + public func absoluteNames(_ enabled: Bool = true) -> Self + public func preservePermissions(_ enabled: Bool = true) -> Self + public func sameOwner(_ enabled: Bool = true) -> Self + public func noSameOwner(_ enabled: Bool = true) -> Self + public func keepOldFiles(_ enabled: Bool = true) -> Self + public func skipOldFiles(_ enabled: Bool = true) -> Self + public func overwrite(_ enabled: Bool = true) -> Self + public func noRecursion(_ enabled: Bool = true) -> Self + public func oneFileSystem(_ enabled: Bool = true) -> Self + public func option(_ value: String) -> Self + public func options(_ values: [String]) -> Self + public func command() -> Command + public func run() async throws -> ShellOutput +} + public enum ZipCompressionLevel: Sendable, Equatable, Hashable { case store // -0 case fastest // -1 @@ -728,7 +787,7 @@ public struct Unzip: RunnableCommandFamily { } ``` -`Zip` and `Unzip` wrap Info-ZIP binaries and behave identically on macOS (preinstalled) and Linux (`apt install zip unzip`). `Unzip.entries()` returns a single-use ``Workflow`` that parses the standard `unzip -l` table — paths with embedded spaces are preserved, missing timestamps surface as `nil`. SwiftyShell does not feed stdin to spawned processes, so unattended extracts should pass `overwrite()` or `neverOverwrite()` to avoid `unzip`'s overwrite prompt hanging the call. +`Tar` wraps the platform tar implementation for portable create, extract, and list workflows. `Zip` and `Unzip` wrap Info-ZIP binaries and behave identically on macOS (preinstalled) and Linux (`apt install zip unzip`). `Unzip.entries()` returns a single-use ``Workflow`` that parses the standard `unzip -l` table — paths with embedded spaces are preserved, missing timestamps surface as `nil`. SwiftyShell does not feed stdin to spawned processes, so unattended extracts should pass `overwrite()` or `neverOverwrite()` to avoid `unzip`'s overwrite prompt hanging the call. #### Brew @@ -985,6 +1044,17 @@ try await Brew(context: context).install("ripgrep").run() // Homebrew — check outdated casks let outdated = try await Brew(context: context).outdated().greedy().run() +// Tar — create a gzip-compressed archive from a project directory +try await Tar(context: context) + .create() + .gzip() + .file("/tmp/source.tar.gz") + .directory("/path/to/project") + .exclude(".build") + .path("Sources") + .path("Package.swift") + .run() + // Zip — create a recursive archive at maximum compression try await Zip(context: context) .recursive() @@ -1218,7 +1288,7 @@ SwiftyShell uses [SwiftPM Package Traits](https://github.com/swiftlang/swift-evo Declared in `Package.swift`: -- **Per-family** — `Git`, `Brew`, `Grep`, `Ls`, `Cp`, `Mkdir`, `Chmod`, `Rm`, `Mv`, `Pwd`, `Jq`, `Zip`, `Unzip`. One trait per family directory; for `Common/`, one trait per file. +- **Per-family** — `Git`, `Brew`, `Grep`, `Ls`, `Cp`, `Mkdir`, `Chmod`, `Rm`, `Mv`, `Pwd`, `Jq`, `Tar`, `Zip`, `Unzip`. One trait per family directory; for `Common/`, one trait per file. - **Umbrellas** — `CommonUtilities` (every `Common/*` family), `All` (every command family). Consumers select families with `traits:` on `.package(...)`: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9a70d40..cbf9b9a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,7 +27,7 @@ jobs: - name: Compute affected traits id: compute run: | - FULL='["", "Git", "Brew", "Grep", "Fzf", "Rg", "Zip", "Unzip", "CommonUtilities", "All"]' + FULL='["", "Git", "Brew", "Grep", "Fzf", "Rg", "Tar", "Zip", "Unzip", "CommonUtilities", "All"]' CHANGED=$(git diff --name-only "origin/${{ github.base_ref }}...HEAD") echo "Changed files:" diff --git a/.github/workflows/reusable-ci.yml b/.github/workflows/reusable-ci.yml index 9c3c666..4800319 100644 --- a/.github/workflows/reusable-ci.yml +++ b/.github/workflows/reusable-ci.yml @@ -25,7 +25,7 @@ on: Defaults to the full matrix when omitted. required: false type: string - default: '["", "Git", "Brew", "Grep", "Fzf", "Rg", "Zip", "Unzip", "CommonUtilities", "All"]' + default: '["", "Git", "Brew", "Grep", "Fzf", "Rg", "Tar", "Zip", "Unzip", "CommonUtilities", "All"]' jobs: validate-traits: diff --git a/AGENTS.md b/AGENTS.md index d8529e4..0c799d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ Typed wrapper for the Homebrew package manager: `Brew` and `BrewSubcommand`. ### `Sources/SwiftyShell/Common/` -Typed wrappers for frequently used shell utilities: `Ls`, `Cp`, `Mkdir`, `Chmod`, `Rm`, `Mv`, `Pwd`, `Jq`, `JqArgument`, `Zip`, `ZipCompressionLevel`, `Unzip`, and `UnzipEntry`. Each follows the same fluent builder conventions as all other command families. +Typed wrappers for frequently used shell utilities: `Ls`, `Cp`, `Mkdir`, `Chmod`, `Rm`, `Mv`, `Pwd`, `Jq`, `JqArgument`, `Tar`, `TarOperation`, `TarCompression`, `Zip`, `ZipCompressionLevel`, `Unzip`, and `UnzipEntry`. Each follows the same fluent builder conventions as all other command families. ### `Sources/SwiftyShell/Internal/Execution/` @@ -161,7 +161,7 @@ SwiftyShell uses [SwiftPM Package Traits](https://github.com/swiftlang/swift-evo **Trait inventory (declared in `Package.swift`):** -- Per-family: `Git`, `Brew`, `Grep`, `Ls`, `Cp`, `Mkdir`, `Chmod`, `Rm`, `Mv`, `Pwd`, `Jq`, `Zip`, `Unzip` (one trait per family directory; for `Common/`, one trait per file). +- Per-family: `Git`, `Brew`, `Grep`, `Ls`, `Cp`, `Mkdir`, `Chmod`, `Rm`, `Mv`, `Pwd`, `Jq`, `Tar`, `Zip`, `Unzip` (one trait per family directory; for `Common/`, one trait per file). - Umbrellas: `CommonUtilities` (all `Common/*`), `All` (every family). **The wiring contract** — enforced by `Scripts/validate-traits.swift` and CI: diff --git a/Package.swift b/Package.swift index 3dbddd3..45e1d6a 100644 --- a/Package.swift +++ b/Package.swift @@ -29,13 +29,14 @@ let package = Package( .trait(name: "Mv", description: "Typed wrapper for mv."), .trait(name: "Pwd", description: "Typed wrapper for pwd."), .trait(name: "Jq", description: "Typed wrapper for jq."), + .trait(name: "Tar", description: "Typed wrapper for tar archives."), .trait(name: "Zip", description: "Typed wrapper for zip (Info-ZIP)."), .trait(name: "Unzip", description: "Typed wrapper for unzip (Info-ZIP)."), // Convenience umbrella that enables every Common/* utility family. .trait( name: "CommonUtilities", description: "Enables all common file/directory utility families.", - enabledTraits: ["Ls", "Cp", "Mkdir", "Chmod", "Rm", "Mv", "Pwd", "Jq", "Zip", "Unzip"] + enabledTraits: ["Ls", "Cp", "Mkdir", "Chmod", "Rm", "Mv", "Pwd", "Jq", "Tar", "Zip", "Unzip"] ), // Convenience umbrella that enables every command family. .trait( diff --git a/README.md b/README.md index a24f3ca..f7e13ae 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ SwiftyShell ships typed wrappers for common tools. Each family is gated behind a | `Mv` | `mv` | `Mv` | Force | | `Pwd` | `pwd` | `Pwd` | Physical and logical paths | | `Jq` | `jq` | `Jq` | Filter expressions, `--arg` bindings, raw output | +| `Tar` | `tar` | `Tar` | Portable tar archive creation, extraction, listing, compression | | `Zip` | `zip` | `Zip` | Info-ZIP archive creation, compression, recursion, exclusions | | `Unzip` | `unzip` | `Unzip` | Info-ZIP archive extraction and structured entry listing | diff --git a/Sources/SwiftyShell/Common/Tar.swift b/Sources/SwiftyShell/Common/Tar.swift new file mode 100644 index 0000000..c4fefa3 --- /dev/null +++ b/Sources/SwiftyShell/Common/Tar.swift @@ -0,0 +1,632 @@ +#if Tar +import Foundation + +/// The compression program used by ``Tar`` when reading or writing archives. +/// +/// Compression maps to the portable short flags accepted by BSD tar and GNU tar for common +/// formats. Use ``custom(_:)`` for less common programs that should be passed through +/// `--use-compress-program`. +/// +/// ```swift +/// try await Tar(context: context) +/// .create() +/// .gzip() +/// .file("release.tar.gz") +/// .path("build") +/// .run() +/// ``` +public enum TarCompression: Sendable, Equatable, Hashable { + /// Use gzip compression (`-z`). + case gzip + /// Use bzip2 compression (`-j`). + case bzip2 + /// Use xz compression (`-J`). + case xz + /// Ask tar to infer compression from the archive suffix (`-a`). + case auto + /// Use a custom compression program (`--use-compress-program `). + case custom(String) + + fileprivate var arguments: [String] { + switch self { + case .gzip: return ["-z"] + case .bzip2: return ["-j"] + case .xz: return ["-J"] + case .auto: return ["-a"] + case .custom(let program): return ["--use-compress-program", program] + } + } +} + +/// The primary tar operation represented by ``Tar``. +/// +/// Tar allows one primary operation per invocation. Calling operation helpers such as +/// ``Tar/create()`` or ``Tar/extract()`` replaces any previously selected operation. +public enum TarOperation: String, Sendable, Equatable, Hashable { + /// Create a new archive (`-c`). + case create = "-c" + /// Extract entries from an archive (`-x`). + case extract = "-x" + /// List archive entries (`-t`). + case list = "-t" + /// Append files to an existing archive (`-r`). + case append = "-r" + /// Update an existing archive with newer file copies (`-u`). + case update = "-u" +} + +/// A fluent wrapper for the `tar` archive command. +/// +/// ``Tar`` models the portable archive operations most useful for automation: create, extract, +/// list, append, update, compression, directory changes, filters, and overwrite behavior. The +/// builder produces argument-vector commands directly, so paths and patterns are never joined into +/// shell strings. +/// +/// ```swift +/// try await Tar(context: context) +/// .create() +/// .gzip() +/// .file("/tmp/source.tar.gz") +/// .directory("/path/to/project") +/// .exclude(".build") +/// .path("Sources") +/// .path("Package.swift") +/// .run() +/// ``` +public struct Tar: RunnableCommandFamily { + private let state: TarState + + /// The shell context used when running this command family. + /// + /// Forwarded from the embedded ``ToolConfiguration`` so commands built by ``command()`` and + /// invocations of ``run()`` share the same executor and defaults. + public var context: ShellContext { state.config.context } + + /// Creates a tar command family bound to a shell context. + /// + /// Configure an operation, archive file, paths, and any flags before calling ``run()`` or + /// ``command()``. + /// + /// - Parameter context: The shell context whose executor, search paths, environment, and + /// defaults will be used. Defaults to a freshly constructed ``ShellContext``. + public init(context: ShellContext = .init()) { + self.state = TarState(config: ToolConfiguration(context: context)) + } + + private init(state: TarState) { + self.state = state + } + + /// Returns a copy with updated shared tool configuration. + /// + /// Funnels the protocol-provided helpers (``executable(_:)``, ``env(_:_:)``, + /// ``workingDirectory(_:)``, ``timeout(_:)``, ``outputLimit(_:)``). + /// + /// - Parameter update: A pure function that receives the current ``ToolConfiguration`` and + /// returns the next one. + /// - Returns: A new ``Tar`` value with the updated configuration applied. + public func updatingConfiguration( + _ update: (ToolConfiguration) -> ToolConfiguration + ) -> Self { + copy(config: update(state.config)) + } + + /// Returns a copy that routes the built `tar` command's stdout to the given destination. + /// + /// Defaults to ``OutputDestination/capture``. Use this for list output or `toStdout()` + /// extraction. + /// + /// - Parameter destination: Where the executor should send the stdout stream. + /// - Returns: A new ``Tar`` value with the stdout destination applied. + public func settingStdoutDestination(_ destination: OutputDestination) -> Self { + copy(stdoutDestination: destination) + } + + /// Returns a copy that routes the built `tar` command's stderr to the given destination. + /// + /// Defaults to ``OutputDestination/capture``. Tar writes diagnostics and some progress output + /// on stderr depending on implementation and flags. + /// + /// - Parameter destination: Where the executor should send the stderr stream. + /// - Returns: A new ``Tar`` value with the stderr destination applied. + public func settingStderrDestination(_ destination: OutputDestination) -> Self { + copy(stderrDestination: destination) + } + + /// Returns a copy that creates a new archive (`-c`). + public func create() -> Self { operation(.create) } + + /// Returns a copy that extracts entries from an archive (`-x`). + public func extract() -> Self { operation(.extract) } + + /// Returns a copy that lists archive entries (`-t`). + public func list() -> Self { operation(.list) } + + /// Returns a copy that appends files to an existing archive (`-r`). + public func append() -> Self { operation(.append) } + + /// Returns a copy that updates archive entries when source files are newer (`-u`). + public func update() -> Self { operation(.update) } + + /// Returns a copy with the selected primary tar operation. + /// + /// - Parameter value: The operation to emit first in argv. + /// - Returns: A new ``Tar`` value with the operation applied. + public func operation(_ value: TarOperation) -> Self { + copy(operation: .some(value)) + } + + /// Returns a copy that sets the archive file (`-f `). + /// + /// Calling this multiple times keeps the last value. + /// + /// - Parameter path: The archive path to read or write. + /// - Returns: A new ``Tar`` value with the archive file set. + public func file(_ path: String) -> Self { + copy(archivePath: path) + } + + /// Returns a copy with one input or member path appended. + /// + /// For create-like operations this names files to archive. For extract/list operations this + /// narrows the archive members to operate on. + /// + /// - Parameter value: A filesystem path or archive member name. + /// - Returns: A new ``Tar`` value with the path appended. + public func path(_ value: String) -> Self { + copy(paths: state.paths + [value]) + } + + /// Returns a copy with multiple input or member paths appended. + /// + /// - Parameter values: Paths or member names to append in order. + /// - Returns: A new ``Tar`` value with the paths appended. + public func paths(_ values: [String]) -> Self { + copy(paths: state.paths + values) + } + + /// Returns a copy that changes tar's directory before processing following paths (`-C`). + /// + /// Multiple directories are emitted in call order. Tar treats `-C` as position-sensitive + /// during archive creation, so this builder emits directories before all paths. + /// + /// - Parameter path: Directory path to pass after `-C`. + /// - Returns: A new ``Tar`` value with the directory appended. + public func directory(_ path: String) -> Self { + copy(directories: state.directories + [path]) + } + + /// Returns a copy that enables gzip compression (`-z`). + public func gzip() -> Self { compression(.gzip) } + + /// Returns a copy that enables bzip2 compression (`-j`). + public func bzip2() -> Self { compression(.bzip2) } + + /// Returns a copy that enables xz compression (`-J`). + public func xz() -> Self { compression(.xz) } + + /// Returns a copy that asks tar to infer compression from the archive suffix (`-a`). + public func autoCompress() -> Self { compression(.auto) } + + /// Returns a copy with the selected compression behavior. + /// + /// - Parameter value: The compression mode to apply, replacing any previous mode. + /// - Returns: A new ``Tar`` value with the compression mode applied. + public func compression(_ value: TarCompression) -> Self { + copy(compression: .some(value)) + } + + /// Returns a copy that appends an exclude pattern (`--exclude `). + /// + /// - Parameter pattern: A tar pattern to exclude. + /// - Returns: A new ``Tar`` value with the exclude pattern appended. + public func exclude(_ pattern: String) -> Self { + copy(excludes: state.excludes + [pattern]) + } + + /// Returns a copy that appends multiple exclude patterns. + /// + /// - Parameter patterns: Exclude patterns to append in order. + /// - Returns: A new ``Tar`` value with the exclude patterns appended. + public func excludes(_ patterns: [String]) -> Self { + copy(excludes: state.excludes + patterns) + } + + /// Returns a copy that reads exclude patterns from a file (`-X `). + /// + /// - Parameter path: File containing newline-delimited tar patterns. + /// - Returns: A new ``Tar`` value with the exclude file appended. + public func excludeFrom(_ path: String) -> Self { + copy(excludeFiles: state.excludeFiles + [path]) + } + + /// Returns a copy that reads paths or members from a file (`-T `). + /// + /// - Parameter path: File containing path names. + /// - Returns: A new ``Tar`` value with the paths-from file appended. + public func filesFrom(_ path: String) -> Self { + copy(filesFrom: state.filesFrom + [path]) + } + + /// Returns a copy that treats `filesFrom` inputs as NUL-terminated (`--null`). + /// + /// - Parameter enabled: `true` to add `--null`. Defaults to `true`. + /// - Returns: A new ``Tar`` value with the flag applied. + public func nullTerminatedFiles(_ enabled: Bool = true) -> Self { + copy(usesNullTerminatedFiles: enabled) + } + + /// Returns a copy that strips leading path components during extraction (`--strip-components`). + /// + /// - Parameter count: Number of leading path components to remove. + /// - Returns: A new ``Tar`` value with the strip count set. + public func stripComponents(_ count: Int) -> Self { + copy(stripComponents: count) + } + + /// Returns a copy that extracts each member to stdout instead of the filesystem (`-O`). + /// + /// - Parameter enabled: `true` to add `-O`. Defaults to `true`. + /// - Returns: A new ``Tar`` value with the flag applied. + public func toStdout(_ enabled: Bool = true) -> Self { + copy(outputsToStdout: enabled) + } + + /// Returns a copy that enables verbose output (`-v`). + /// + /// - Parameter enabled: `true` to add `-v`. Defaults to `true`. + /// - Returns: A new ``Tar`` value with the flag applied. + public func verbose(_ enabled: Bool = true) -> Self { + copy(isVerbose: enabled) + } + + /// Returns a copy that verifies archive writes when creating archives (`-W`). + /// + /// - Parameter enabled: `true` to add `-W`. Defaults to `true`. + /// - Returns: A new ``Tar`` value with the flag applied. + public func verify(_ enabled: Bool = true) -> Self { + copy(verifiesWrites: enabled) + } + + /// Returns a copy that removes source files after adding them (`--remove-files`). + /// + /// - Parameter enabled: `true` to add `--remove-files`. Defaults to `true`. + /// - Returns: A new ``Tar`` value with the flag applied. + public func removeFilesAfterAdding(_ enabled: Bool = true) -> Self { + copy(removesFilesAfterAdding: enabled) + } + + /// Returns a copy that follows symlinks when archiving (`-h`). + /// + /// - Parameter enabled: `true` to add `-h`. Defaults to `true`. + /// - Returns: A new ``Tar`` value with the flag applied. + public func dereferenceSymlinks(_ enabled: Bool = true) -> Self { + copy(dereferencesSymlinks: enabled) + } + + /// Returns a copy that keeps absolute path names (`-P`). + /// + /// By default tar strips leading slashes for safety. Use this only when absolute archive + /// member names are intentional. + /// + /// - Parameter enabled: `true` to add `-P`. Defaults to `true`. + /// - Returns: A new ``Tar`` value with the flag applied. + public func absoluteNames(_ enabled: Bool = true) -> Self { + copy(usesAbsoluteNames: enabled) + } + + /// Returns a copy that preserves permissions when extracting (`-p`). + /// + /// - Parameter enabled: `true` to add `-p`. Defaults to `true`. + /// - Returns: A new ``Tar`` value with the flag applied. + public func preservePermissions(_ enabled: Bool = true) -> Self { + copy(preservesPermissions: enabled) + } + + /// Returns a copy that preserves owner when extracting (`--same-owner`). + /// + /// - Parameter enabled: `true` to add `--same-owner`. Defaults to `true`. + /// - Returns: A new ``Tar`` value with the flag applied. + public func sameOwner(_ enabled: Bool = true) -> Self { + copy(preservesOwner: enabled) + } + + /// Returns a copy that avoids preserving owner when extracting (`--no-same-owner`). + /// + /// - Parameter enabled: `true` to add `--no-same-owner`. Defaults to `true`. + /// - Returns: A new ``Tar`` value with the flag applied. + public func noSameOwner(_ enabled: Bool = true) -> Self { + copy(skipsOwnerPreservation: enabled) + } + + /// Returns a copy that does not overwrite existing files while extracting (`-k`). + /// + /// - Parameter enabled: `true` to add `-k`. Defaults to `true`. + /// - Returns: A new ``Tar`` value with the flag applied. + public func keepOldFiles(_ enabled: Bool = true) -> Self { + copy(keepsOldFiles: enabled) + } + + /// Returns a copy that skips existing files while extracting (`--skip-old-files`). + /// + /// - Parameter enabled: `true` to add `--skip-old-files`. Defaults to `true`. + /// - Returns: A new ``Tar`` value with the flag applied. + public func skipOldFiles(_ enabled: Bool = true) -> Self { + copy(skipsOldFiles: enabled) + } + + /// Returns a copy that overwrites existing files while extracting (`--overwrite`). + /// + /// - Parameter enabled: `true` to add `--overwrite`. Defaults to `true`. + /// - Returns: A new ``Tar`` value with the flag applied. + public func overwrite(_ enabled: Bool = true) -> Self { + copy(overwritesExistingFiles: enabled) + } + + /// Returns a copy that omits recursion when archiving directories (`--no-recursion`). + /// + /// - Parameter enabled: `true` to add `--no-recursion`. Defaults to `true`. + /// - Returns: A new ``Tar`` value with the flag applied. + public func noRecursion(_ enabled: Bool = true) -> Self { + copy(disablesRecursion: enabled) + } + + /// Returns a copy that prevents crossing filesystem boundaries (`--one-file-system`). + /// + /// - Parameter enabled: `true` to add `--one-file-system`. Defaults to `true`. + /// - Returns: A new ``Tar`` value with the flag applied. + public func oneFileSystem(_ enabled: Bool = true) -> Self { + copy(staysOnOneFileSystem: enabled) + } + + /// Returns a copy that appends a raw tar option. + /// + /// Use this for less common implementation-specific flags while keeping the rest of the + /// invocation typed. + /// + /// - Parameter value: A single argument to append before file and path operands. + /// - Returns: A new ``Tar`` value with the raw option appended. + public func option(_ value: String) -> Self { + copy(extraOptions: state.extraOptions + [value]) + } + + /// Returns a copy that appends raw tar options. + /// + /// - Parameter values: Arguments to append before file and path operands. + /// - Returns: A new ``Tar`` value with the raw options appended. + public func options(_ values: [String]) -> Self { + copy(extraOptions: state.extraOptions + values) + } + + /// Builds the raw `tar` command represented by the current builder state. + /// + /// Argv is assembled deterministically as: operation -> behavior/compression/filter flags -> + /// `-f ` -> `-C ` values -> path operands. Shared configuration is merged + /// via ``ToolConfiguration/apply(to:)``. + /// + /// - Returns: A ``Command`` ready for execution or pipeline composition. + public func command() -> Command { + var arguments: [String] = [] + + if let operation = state.operation { + arguments.append(operation.rawValue) + } + + if state.isVerbose { arguments.append("-v") } + if state.verifiesWrites { arguments.append("-W") } + if state.removesFilesAfterAdding { arguments.append("--remove-files") } + if state.dereferencesSymlinks { arguments.append("-h") } + if state.usesAbsoluteNames { arguments.append("-P") } + if state.preservesPermissions { arguments.append("-p") } + if state.preservesOwner { arguments.append("--same-owner") } + if state.skipsOwnerPreservation { arguments.append("--no-same-owner") } + if state.keepsOldFiles { arguments.append("-k") } + if state.skipsOldFiles { arguments.append("--skip-old-files") } + if state.overwritesExistingFiles { arguments.append("--overwrite") } + if state.disablesRecursion { arguments.append("--no-recursion") } + if state.staysOnOneFileSystem { arguments.append("--one-file-system") } + if state.outputsToStdout { arguments.append("-O") } + if state.usesNullTerminatedFiles { arguments.append("--null") } + + if let compression = state.compression { + arguments.append(contentsOf: compression.arguments) + } + + for pattern in state.excludes { + arguments.append("--exclude") + arguments.append(pattern) + } + + for path in state.excludeFiles { + arguments.append("-X") + arguments.append(path) + } + + for path in state.filesFrom { + arguments.append("-T") + arguments.append(path) + } + + if let count = state.stripComponents { + arguments.append("--strip-components") + arguments.append(String(count)) + } + + arguments.append(contentsOf: state.extraOptions) + + if let archivePath = state.archivePath { + arguments.append("-f") + arguments.append(archivePath) + } + + for directory in state.directories { + arguments.append("-C") + arguments.append(directory) + } + + arguments.append(contentsOf: state.paths) + + let base = Command("tar") + .args(arguments) + .stdout(state.stdoutDestination) + .stderr(state.stderrDestination) + + return state.config.apply(to: base) + } + + private func copy( + config: ToolConfiguration? = nil, + stdoutDestination: OutputDestination? = nil, + stderrDestination: OutputDestination? = nil, + operation: TarOperation?? = nil, + archivePath: String?? = nil, + paths: [String]? = nil, + directories: [String]? = nil, + compression: TarCompression?? = nil, + excludes: [String]? = nil, + excludeFiles: [String]? = nil, + filesFrom: [String]? = nil, + stripComponents: Int?? = nil, + outputsToStdout: Bool? = nil, + isVerbose: Bool? = nil, + verifiesWrites: Bool? = nil, + removesFilesAfterAdding: Bool? = nil, + dereferencesSymlinks: Bool? = nil, + usesAbsoluteNames: Bool? = nil, + preservesPermissions: Bool? = nil, + preservesOwner: Bool? = nil, + skipsOwnerPreservation: Bool? = nil, + keepsOldFiles: Bool? = nil, + skipsOldFiles: Bool? = nil, + overwritesExistingFiles: Bool? = nil, + disablesRecursion: Bool? = nil, + staysOnOneFileSystem: Bool? = nil, + usesNullTerminatedFiles: Bool? = nil, + extraOptions: [String]? = nil + ) -> Self { + Self( + state: TarState( + config: config ?? state.config, + stdoutDestination: stdoutDestination ?? state.stdoutDestination, + stderrDestination: stderrDestination ?? state.stderrDestination, + operation: operation ?? state.operation, + archivePath: archivePath ?? state.archivePath, + paths: paths ?? state.paths, + directories: directories ?? state.directories, + compression: compression ?? state.compression, + excludes: excludes ?? state.excludes, + excludeFiles: excludeFiles ?? state.excludeFiles, + filesFrom: filesFrom ?? state.filesFrom, + stripComponents: stripComponents ?? state.stripComponents, + outputsToStdout: outputsToStdout ?? state.outputsToStdout, + isVerbose: isVerbose ?? state.isVerbose, + verifiesWrites: verifiesWrites ?? state.verifiesWrites, + removesFilesAfterAdding: removesFilesAfterAdding ?? state.removesFilesAfterAdding, + dereferencesSymlinks: dereferencesSymlinks ?? state.dereferencesSymlinks, + usesAbsoluteNames: usesAbsoluteNames ?? state.usesAbsoluteNames, + preservesPermissions: preservesPermissions ?? state.preservesPermissions, + preservesOwner: preservesOwner ?? state.preservesOwner, + skipsOwnerPreservation: skipsOwnerPreservation ?? state.skipsOwnerPreservation, + keepsOldFiles: keepsOldFiles ?? state.keepsOldFiles, + skipsOldFiles: skipsOldFiles ?? state.skipsOldFiles, + overwritesExistingFiles: overwritesExistingFiles ?? state.overwritesExistingFiles, + disablesRecursion: disablesRecursion ?? state.disablesRecursion, + staysOnOneFileSystem: staysOnOneFileSystem ?? state.staysOnOneFileSystem, + usesNullTerminatedFiles: usesNullTerminatedFiles ?? state.usesNullTerminatedFiles, + extraOptions: extraOptions ?? state.extraOptions + ) + ) + } +} + +private struct TarState: Sendable { + let config: ToolConfiguration + let stdoutDestination: OutputDestination + let stderrDestination: OutputDestination + let operation: TarOperation? + let archivePath: String? + let paths: [String] + let directories: [String] + let compression: TarCompression? + let excludes: [String] + let excludeFiles: [String] + let filesFrom: [String] + let stripComponents: Int? + let outputsToStdout: Bool + let isVerbose: Bool + let verifiesWrites: Bool + let removesFilesAfterAdding: Bool + let dereferencesSymlinks: Bool + let usesAbsoluteNames: Bool + let preservesPermissions: Bool + let preservesOwner: Bool + let skipsOwnerPreservation: Bool + let keepsOldFiles: Bool + let skipsOldFiles: Bool + let overwritesExistingFiles: Bool + let disablesRecursion: Bool + let staysOnOneFileSystem: Bool + let usesNullTerminatedFiles: Bool + let extraOptions: [String] + + init( + config: ToolConfiguration, + stdoutDestination: OutputDestination = .capture, + stderrDestination: OutputDestination = .capture, + operation: TarOperation? = nil, + archivePath: String? = nil, + paths: [String] = [], + directories: [String] = [], + compression: TarCompression? = nil, + excludes: [String] = [], + excludeFiles: [String] = [], + filesFrom: [String] = [], + stripComponents: Int? = nil, + outputsToStdout: Bool = false, + isVerbose: Bool = false, + verifiesWrites: Bool = false, + removesFilesAfterAdding: Bool = false, + dereferencesSymlinks: Bool = false, + usesAbsoluteNames: Bool = false, + preservesPermissions: Bool = false, + preservesOwner: Bool = false, + skipsOwnerPreservation: Bool = false, + keepsOldFiles: Bool = false, + skipsOldFiles: Bool = false, + overwritesExistingFiles: Bool = false, + disablesRecursion: Bool = false, + staysOnOneFileSystem: Bool = false, + usesNullTerminatedFiles: Bool = false, + extraOptions: [String] = [] + ) { + self.config = config + self.stdoutDestination = stdoutDestination + self.stderrDestination = stderrDestination + self.operation = operation + self.archivePath = archivePath + self.paths = paths + self.directories = directories + self.compression = compression + self.excludes = excludes + self.excludeFiles = excludeFiles + self.filesFrom = filesFrom + self.stripComponents = stripComponents + self.outputsToStdout = outputsToStdout + self.isVerbose = isVerbose + self.verifiesWrites = verifiesWrites + self.removesFilesAfterAdding = removesFilesAfterAdding + self.dereferencesSymlinks = dereferencesSymlinks + self.usesAbsoluteNames = usesAbsoluteNames + self.preservesPermissions = preservesPermissions + self.preservesOwner = preservesOwner + self.skipsOwnerPreservation = skipsOwnerPreservation + self.keepsOldFiles = keepsOldFiles + self.skipsOldFiles = skipsOldFiles + self.overwritesExistingFiles = overwritesExistingFiles + self.disablesRecursion = disablesRecursion + self.staysOnOneFileSystem = staysOnOneFileSystem + self.usesNullTerminatedFiles = usesNullTerminatedFiles + self.extraOptions = extraOptions + } +} +#endif diff --git a/Sources/SwiftyShell/SwiftyShell.docc/Articles/SelectingCommandFamilies.md b/Sources/SwiftyShell/SwiftyShell.docc/Articles/SelectingCommandFamilies.md index e9984b5..b695862 100644 --- a/Sources/SwiftyShell/SwiftyShell.docc/Articles/SelectingCommandFamilies.md +++ b/Sources/SwiftyShell/SwiftyShell.docc/Articles/SelectingCommandFamilies.md @@ -8,7 +8,7 @@ SwiftyShell is split into a small, always-available `Core` (commands, pipelines, contexts, errors, executors) and a set of **opt-in** typed command families: ``Git``, ``Brew``, ``Grep``, ``Fzf``, and a collection of common file/directory utilities (``Ls``, ``Cp``, ``Mv``, ``Mkdir``, ``Chmod``, ``Rm``, ``Pwd``, -``Jq``, ``Zip``, ``Unzip``). +``Jq``, ``Tar``, ``Zip``, ``Unzip``). Each family is gated behind a SwiftPM **package trait**. By default no families are enabled, so a fresh `import SwiftyShell` exposes only `Core`. @@ -55,9 +55,10 @@ let status = try await Git() | `Rm` | ``Rm`` | | `Pwd` | ``Pwd`` | | `Jq` | ``Jq`` | +| `Tar` | ``Tar`` portable archive creation, extraction, and listing | | `Zip` | ``Zip`` Info-ZIP archive creation wrapper | | `Unzip` | ``Unzip`` Info-ZIP archive extraction and listing wrapper | -| `CommonUtilities` | All of `Ls`, `Cp`, `Mv`, `Mkdir`, `Chmod`, `Rm`, `Pwd`, `Jq`, `Zip`, `Unzip` | +| `CommonUtilities` | All of `Ls`, `Cp`, `Mv`, `Mkdir`, `Chmod`, `Rm`, `Pwd`, `Jq`, `Tar`, `Zip`, `Unzip` | | `All` | Every per-family trait above (the kitchen-sink umbrella) | ## Common recipes diff --git a/Sources/SwiftyShell/SwiftyShell.docc/SwiftyShell.md b/Sources/SwiftyShell/SwiftyShell.docc/SwiftyShell.md index fda0d30..40496e0 100644 --- a/Sources/SwiftyShell/SwiftyShell.docc/SwiftyShell.md +++ b/Sources/SwiftyShell/SwiftyShell.docc/SwiftyShell.md @@ -6,7 +6,7 @@ Type-safe shell support for Swift. SwiftyShell's primary API is a family of typed wrappers — ``Git``, ``Grep``, ``Rg``, ``Brew``, ``Fzf``, ``Ls``, ``Cp``, ``Mkdir``, ``Chmod``, ``Rm``, ``Mv``, ``Pwd``, ``Jq``, -``Zip``, ``Unzip`` — that model shell tools as Swift values. The compiler enforces which flags exist, +``Tar``, ``Zip``, ``Unzip`` — that model shell tools as Swift values. The compiler enforces which flags exist, which arguments are required, and what the result looks like. ``Command`` is the fluent escape hatch for tools that don't have a typed wrapper yet; it shares the same builder style so code does not change shape when you fall back @@ -159,6 +159,9 @@ let output = try await Command("echo", arguments: "hello").run(in: context) ### Archives +- ``Tar`` +- ``TarOperation`` +- ``TarCompression`` - ``Zip`` - ``ZipCompressionLevel`` - ``Unzip`` diff --git a/Sources/SwiftyShell/SwiftyShell.docc/Tar.md b/Sources/SwiftyShell/SwiftyShell.docc/Tar.md new file mode 100644 index 0000000..182dd45 --- /dev/null +++ b/Sources/SwiftyShell/SwiftyShell.docc/Tar.md @@ -0,0 +1,113 @@ +# ``Tar`` + +A fluent wrapper for portable `tar` archive operations. + +``Tar`` covers the archive operations that are most useful in build, release, and automation +scripts: create, extract, list, append, update, common compression modes, directory changes, +exclude filters, `files-from` inputs, and overwrite behavior. It builds an argv array directly, +so paths and patterns with spaces are passed as single arguments instead of being shell-parsed. + +Create a gzip-compressed source archive while excluding build output: + +```swift +try await Tar(context: context) + .create() + .gzip() + .file("/tmp/source.tar.gz") + .directory("/path/to/project") + .exclude(".build") + .path("Sources") + .path("Package.swift") + .run() +``` + +Extract into a destination directory without preserving archive owners: + +```swift +try await Tar(context: context) + .extract() + .file("/tmp/source.tar.gz") + .directory("/tmp/source") + .noSameOwner() + .run() +``` + +List selected members from an archive: + +```swift +let output = try await Tar(context: context) + .list() + .file("/tmp/source.tar.gz") + .path("Sources") + .run() +``` + +Use ``option(_:)`` or ``options(_:)`` for implementation-specific flags that are not modeled by +the typed API yet. + +## Topics + +### Operations + +- ``TarOperation`` +- ``create()`` +- ``extract()`` +- ``list()`` +- ``append()`` +- ``update()`` +- ``operation(_:)`` + +### Archive Inputs + +- ``file(_:)`` +- ``path(_:)`` +- ``paths(_:)`` +- ``directory(_:)`` +- ``filesFrom(_:)`` +- ``nullTerminatedFiles(_:)`` + +### Compression + +- ``TarCompression`` +- ``gzip()`` +- ``bzip2()`` +- ``xz()`` +- ``autoCompress()`` +- ``compression(_:)`` + +### Filtering + +- ``exclude(_:)`` +- ``excludes(_:)`` +- ``excludeFrom(_:)`` + +### Extraction Behavior + +- ``stripComponents(_:)`` +- ``toStdout(_:)`` +- ``preservePermissions(_:)`` +- ``sameOwner(_:)`` +- ``noSameOwner(_:)`` +- ``keepOldFiles(_:)`` +- ``skipOldFiles(_:)`` +- ``overwrite(_:)`` + +### Archive Behavior + +- ``verbose(_:)`` +- ``verify(_:)`` +- ``removeFilesAfterAdding(_:)`` +- ``dereferenceSymlinks(_:)`` +- ``absoluteNames(_:)`` +- ``noRecursion(_:)`` +- ``oneFileSystem(_:)`` + +### Raw Options + +- ``option(_:)`` +- ``options(_:)`` + +### Running + +- ``init(context:)`` +- ``command()`` diff --git a/Tests/SwiftyShellTests/Common/TarTests.swift b/Tests/SwiftyShellTests/Common/TarTests.swift new file mode 100644 index 0000000..ca21a0f --- /dev/null +++ b/Tests/SwiftyShellTests/Common/TarTests.swift @@ -0,0 +1,198 @@ +#if Tar +import Foundation +import Testing +@testable import SwiftyShell + +struct TarCommandTests { + let subject = Tar() + + @Test func buildsDefaultTarCommand() { + let command = subject.command() + + #expect(command.executableName == "tar") + #expect(command.arguments == []) + } + + @Test func buildsCreateCommandWithCompressionFiltersAndPaths() { + let command = + subject + .create() + .verbose() + .gzip() + .exclude("*.tmp") + .exclude("*/.build/*") + .file("/tmp/source.tar.gz") + .directory("/workspace") + .path("Sources") + .path("Package.swift") + .command() + + #expect(command.executableName == "tar") + #expect( + command.arguments == [ + "-c", "-v", "-z", + "--exclude", "*.tmp", + "--exclude", "*/.build/*", + "-f", "/tmp/source.tar.gz", + "-C", "/workspace", + "Sources", "Package.swift", + ] + ) + } + + @Test func latestOperationWins() { + let command = + subject + .create() + .extract() + .file("archive.tar") + .command() + + #expect(command.arguments == ["-x", "-f", "archive.tar"]) + } + + @Test(arguments: [ + (TarCompression.gzip, ["-z"]), + (TarCompression.bzip2, ["-j"]), + (TarCompression.xz, ["-J"]), + (TarCompression.auto, ["-a"]), + (TarCompression.custom("zstd -T0"), ["--use-compress-program", "zstd -T0"]), + ]) + func compressionArgumentMapping(compression: TarCompression, expectedArguments: [String]) { + #expect(subject.compression(compression).command().arguments == expectedArguments) + } + + @Test func emitsInputFilesAndExtractionOptions() { + let command = + subject + .extract() + .file("archive.tar") + .filesFrom("members.txt") + .nullTerminatedFiles() + .stripComponents(1) + .toStdout() + .preservePermissions() + .noSameOwner() + .keepOldFiles() + .option("--warning=no-unknown-keyword") + .path("Sources/SwiftyShell") + .command() + + #expect( + command.arguments == [ + "-x", "-p", "--no-same-owner", "-k", "-O", "--null", + "-T", "members.txt", + "--strip-components", "1", + "--warning=no-unknown-keyword", + "-f", "archive.tar", + "Sources/SwiftyShell", + ] + ) + } + + @Test func preservesToolConfigurationOverrides() async throws { + actor Recorder { + var command: Command? + var workingDirectory: String? + + func record(_ command: Command, context: ShellContext) { + self.command = command + self.workingDirectory = context.workingDirectory + } + } + + let recorder = Recorder() + let context = ShellContext( + executor: MockExecutor { command, context in + await recorder.record(command, context: context) + return ShellOutput(stdout: "ok\n", stderr: "", exitCode: 0) + }, + workingDirectory: "/context" + ) + + let output = try await Tar(context: context) + .executable("/usr/bin/tar") + .workingDirectory("/override") + .timeout(5) + .outputLimit(1024) + .create() + .file("archive.tar") + .path("file.txt") + .run() + + let command = await recorder.command + #expect(output.stdout == "ok\n") + #expect(command?.executableName == "tar") + #expect(command?.executableOverride == "/usr/bin/tar") + #expect(command?.workingDirectoryOverride == "/override") + #expect(command?.timeoutOverride == 5) + #expect(command?.outputLimitOverride == 1024) + #expect(command?.arguments == ["-c", "-f", "archive.tar", "file.txt"]) + #expect(await recorder.workingDirectory == "/context") + } + + @Test func createsAndExtractsArchiveOnDisk() async throws { + let directory = try CommonTestSupport.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let source = directory.appendingPathComponent("source", isDirectory: true) + let destination = directory.appendingPathComponent("destination", isDirectory: true) + try FileManager.default.createDirectory(at: source, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true) + + let inputName = "hello.txt" + let input = source.appendingPathComponent(inputName) + try "hello world".write(to: input, atomically: true, encoding: .utf8) + + let archive = directory.appendingPathComponent("out.tar") + let createOutput = + try await subject + .create() + .file(archive.path) + .directory(source.path) + .path(inputName) + .run() + + let extractOutput = + try await subject + .extract() + .file(archive.path) + .directory(destination.path) + .run() + + let extracted = destination.appendingPathComponent(inputName) + let extractedContents = try String(contentsOf: extracted, encoding: .utf8) + + #expect(createOutput.exitCode == 0) + #expect(extractOutput.exitCode == 0) + #expect(FileManager.default.fileExists(atPath: archive.path)) + #expect(extractedContents == "hello world") + } + + @Test func listsArchiveEntriesOnDisk() async throws { + let directory = try CommonTestSupport.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let inputName = "listed.txt" + let input = directory.appendingPathComponent(inputName) + try "listed".write(to: input, atomically: true, encoding: .utf8) + + let archive = directory.appendingPathComponent("out.tar") + _ = + try await subject + .create() + .file(archive.path) + .directory(directory.path) + .path(inputName) + .run() + + let output = + try await subject + .list() + .file(archive.path) + .run() + + #expect(output.stdout.split(separator: "\n").contains(Substring(inputName))) + } +} +#endif