├── .github ├── activity_style_dots.gif ├── activity_style_kitt.gif ├── activity_style_snake.gif ├── activity_style_spinner.gif ├── demo_activity_style.tape ├── demo_progressline_output.tape ├── demo_standard_output.tape ├── progressline_matches_output.png ├── progressline_output.gif ├── standard_output.gif └── workflows │ ├── checks.yml │ └── semantic-pr-lint.yml ├── .gitignore ├── .sake.yml ├── .swiftformat ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── SakeApp ├── .gitignore ├── BrewCommands.swift ├── Package.resolved ├── Package.swift ├── ReleaseCommands.swift └── Sakefile.swift ├── Sources ├── ANSI.swift ├── ActivityIndicator+CommandArgument.swift ├── ActivityIndicator.swift ├── ErrorMessage.swift ├── FileHandler+AsyncStream.swift ├── LogAllController.swift ├── MatchesController.swift ├── OriginalLogController.swift ├── Printer.swift ├── PrintersHolder.swift ├── ProgressLine.swift ├── ProgressLineController.swift ├── ProgressLineFormatter.swift ├── ProgressTracker.swift ├── String+ANSI.swift ├── Task+Periodic.swift ├── UnderProgressLineLogger.swift ├── Version.swift ├── WindowSizeObserver.swift └── isTTY.swift ├── Tests ├── assert.sh ├── integration_tests.sh ├── snapshots │ ├── default.snapshot │ ├── default_with_original_log.snapshot │ ├── default_with_original_log_original_log.snapshot │ ├── log_all.snapshot │ ├── log_matches.snapshot │ └── static_text.snapshot └── test_data_producer.swift └── cliff.toml /.github/activity_style_dots.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kattouf/ProgressLine/3ef298aeb9d854a02bc8030b1defee44595d81b5/.github/activity_style_dots.gif -------------------------------------------------------------------------------- /.github/activity_style_kitt.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kattouf/ProgressLine/3ef298aeb9d854a02bc8030b1defee44595d81b5/.github/activity_style_kitt.gif -------------------------------------------------------------------------------- /.github/activity_style_snake.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kattouf/ProgressLine/3ef298aeb9d854a02bc8030b1defee44595d81b5/.github/activity_style_snake.gif -------------------------------------------------------------------------------- /.github/activity_style_spinner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kattouf/ProgressLine/3ef298aeb9d854a02bc8030b1defee44595d81b5/.github/activity_style_spinner.gif -------------------------------------------------------------------------------- /.github/demo_activity_style.tape: -------------------------------------------------------------------------------- 1 | Output out.gif 2 | 3 | Set Shell "zsh" 4 | Set FontSize 32 5 | Set Width 660 6 | Set Height 300 7 | Set TypingSpeed 75ms 8 | 9 | Type "progressline -s snake" 10 | 11 | Enter 12 | 13 | Sleep 8 14 | -------------------------------------------------------------------------------- /.github/demo_progressline_output.tape: -------------------------------------------------------------------------------- 1 | Output progressline_output.gif 2 | 3 | Require progressline 4 | 5 | Set Shell "zsh" 6 | Set FontSize 32 7 | Set Width 1600 8 | Set Height 400 9 | Set TypingSpeed 75ms 10 | 11 | Hide 12 | Type "make long-running-command" 13 | Show 14 | Sleep 1 15 | Type " | progressline" 16 | 17 | Enter 18 | 19 | Sleep 11 20 | -------------------------------------------------------------------------------- /.github/demo_standard_output.tape: -------------------------------------------------------------------------------- 1 | Output standard_output.gif 2 | 3 | Require progressline 4 | 5 | Set Shell "zsh" 6 | Set FontSize 32 7 | Set Width 1600 8 | Set Height 400 9 | Set TypingSpeed 75ms 10 | 11 | Hide 12 | Type "make long-running-command" 13 | Show 14 | Sleep 1 15 | 16 | Enter 17 | 18 | Sleep 11 19 | -------------------------------------------------------------------------------- /.github/progressline_matches_output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kattouf/ProgressLine/3ef298aeb9d854a02bc8030b1defee44595d81b5/.github/progressline_matches_output.png -------------------------------------------------------------------------------- /.github/progressline_output.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kattouf/ProgressLine/3ef298aeb9d854a02bc8030b1defee44595d81b5/.github/progressline_output.gif -------------------------------------------------------------------------------- /.github/standard_output.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kattouf/ProgressLine/3ef298aeb9d854a02bc8030b1defee44595d81b5/.github/standard_output.gif -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: Checks 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | jobs: 10 | build: 11 | name: Integration tests 12 | strategy: 13 | matrix: 14 | os: [ubuntu-latest, macos-latest] 15 | runs-on: ${{ matrix.os }} 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: swift-actions/setup-swift@v2 19 | with: 20 | swift-version: "5.10" 21 | - name: Prepare test build 22 | run: swift build 23 | - name: Run tests 24 | run: ./Tests/integration_tests.sh .build/debug/progressline 25 | lint: 26 | runs-on: macos-latest 27 | steps: 28 | - name: Checkout repository 29 | uses: actions/checkout@v4 30 | - name: Run SwiftFormat Linting 31 | run: swiftformat Sources SakeApp Package.swift --lint 32 | -------------------------------------------------------------------------------- /.github/workflows/semantic-pr-lint.yml: -------------------------------------------------------------------------------- 1 | name: "Lint PR" 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | - reopened 10 | 11 | permissions: 12 | pull-requests: read 13 | 14 | jobs: 15 | main: 16 | name: Validate PR title 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: amannn/action-semantic-pull-request@v5 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | with: 23 | types: | 24 | fix 25 | feat 26 | chore 27 | feat 28 | fix 29 | test 30 | perf 31 | refactor 32 | doc 33 | project 34 | revert 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /.index-build 4 | /Packages 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/configuration/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | TODO.md 11 | -------------------------------------------------------------------------------- /.sake.yml: -------------------------------------------------------------------------------- 1 | case_converting_strategy: toSnakeCase 2 | -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | --swiftversion 5.10 2 | 3 | --exclude .build,.index-build,SakeApp/.build,SakeApp/.index-build 4 | 5 | --maxwidth 140 6 | --wraparguments before-first 7 | --wrapparameters before-first 8 | --wrapcollections before-first 9 | 10 | --enable isEmpty,wrapSwitchCases,wrapConditionalBodies,wrapEnumCases 11 | --disable redundantRawValues,redundantSelf,redundantStaticSelf,redundantType 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Vasilii Ianguzin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "e759c45271facbb3650829c703702a2ac4817adf75a8116cc3d77eae8e3d3bae", 3 | "pins" : [ 4 | { 5 | "identity" : "swift-argument-parser", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/apple/swift-argument-parser.git", 8 | "state" : { 9 | "revision" : "0fbc8848e389af3bb55c182bc19ca9d5dc2f255b", 10 | "version" : "1.4.0" 11 | } 12 | }, 13 | { 14 | "identity" : "swift-concurrency-extras", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/pointfreeco/swift-concurrency-extras.git", 17 | "state" : { 18 | "revision" : "bb5059bde9022d69ac516803f4f227d8ac967f71", 19 | "version" : "1.1.0" 20 | } 21 | }, 22 | { 23 | "identity" : "swift-tagged", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/pointfreeco/swift-tagged.git", 26 | "state" : { 27 | "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", 28 | "version" : "0.10.0" 29 | } 30 | } 31 | ], 32 | "version" : 3 33 | } 34 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.10 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "ProgressLine", 8 | platforms: [ 9 | .macOS(.v10_15), 10 | ], 11 | products: [ 12 | .executable(name: "progressline", targets: ["progressline"]), 13 | ], 14 | dependencies: [ 15 | .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.4.0"), 16 | .package(url: "https://github.com/pointfreeco/swift-tagged.git", from: "0.10.0"), 17 | .package(url: "https://github.com/pointfreeco/swift-concurrency-extras.git", from: "1.1.0"), 18 | ], 19 | targets: [ 20 | .executableTarget( 21 | name: "progressline", 22 | dependencies: [ 23 | .product(name: "ArgumentParser", package: "swift-argument-parser"), 24 | .product(name: "TaggedTime", package: "swift-tagged"), 25 | .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), 26 | ], swiftSettings: [ 27 | .enableExperimentalFeature("StrictConcurrency"), 28 | ] 29 | ), 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## ProgressLine 2 | 3 | ![](https://img.shields.io/badge/Platform-macOS-6464aa) 4 | ![](https://img.shields.io/badge/Platform-Linux-6464aa) 5 | [![Latest Release](https://img.shields.io/github/release/kattouf/ProgressLine.svg)](https://github.com/kattouf/ProgressLine/releases/latest) 6 | ![](https://github.com/kattouf/ProgressLine/actions/workflows/checks.yml/badge.svg?branch=main) 7 | 8 | Track commands progress in a compact one-line format. 9 | 10 | | ⏳ `progressline` output | 11 | |:--:| 12 | | ![](./.github/progressline_output.gif) | 13 | 14 | | 📝 standard output | 15 | |:--:| 16 | | ![](./.github/standard_output.gif) | 17 | 18 | [Usage](#usage) • [Features](#features) • [Installation](#installation) 19 | 20 | ## Usage 21 | 22 | Simply pipe your command output into `progressline` to start tracking: 23 | 24 | ```sh 25 | long-running-command | progressline 26 | ``` 27 | 28 | If the command you are executing also writes data to `stderr`, then you should probably use ["redirection"](https://www.gnu.org/software/bash/manual/html_node/Redirections.html) and send `stderr` messages to `stdout` so that they also go through the `progressline`: 29 | 30 | ``` sh 31 | long-running-command 2>&1 | progressline 32 | ``` 33 | 34 | ## Features 35 | 36 | ### Change activity indicator styles 37 | 38 | ProgressLine offers different styles to represent activity, they can be changed using `-s, --activity-style` option: 39 | 40 | ``` sh 41 | long-running-command | progressline --activity-style snake 42 | ``` 43 | 44 | Available styles: 45 | 46 | | dots (Default) | snake | [kitt](https://en.wikipedia.org/wiki/KITT) | spinner | 47 | |:--:|:--:|:--:|:--:| 48 | | ![](./.github/activity_style_dots.gif) | ![](./.github/activity_style_snake.gif) | ![](./.github/activity_style_kitt.gif) | ![](./.github/activity_style_spinner.gif) | 49 | 50 | ### Replace log output with custom text 51 | 52 | If you don't need to see the log output during execution, even in a single line, you can replace it with your own text using the `-t, --static-text` option. 53 | 54 | ``` sh 55 | long-running-command | progressline --static-text "Updating sources..." 56 | ``` 57 | 58 | ### Highlight important lines 59 | 60 | Log specific stdin lines above the progress line using the `-m, --log-matches` option: 61 | 62 | ``` sh 63 | long-running-command | progressline --log-matches "regex-1" --log-matches "regex-2" 64 | ``` 65 | 66 | ### Use progress line as an addition to standard output 67 | 68 | Log all stdin data above the progress line using the `-a, --log-all` option: 69 | 70 | ```sh 71 | long-running-command | progressline --log-all 72 | ``` 73 | 74 | ### Save original log 75 | 76 | You have two options for saving the full original log: 77 | 78 | 1. Using [tee](https://en.wikipedia.org/wiki/Tee_(command)) 79 | 80 | ``` sh 81 | long-running-command | tee original-log.txt | progressline 82 | ``` 83 | 84 | 2. Using `-l, --original-log-path` option: 85 | 86 | ``` sh 87 | long-running-command | progressline --original-log-path original-log.txt 88 | ``` 89 | 90 | ## Installation 91 | 92 | ### [Homebrew](https://brew.sh) (macOS / Linux) 93 | 94 | ``` sh 95 | brew install progressline 96 | ``` 97 | 98 | 99 | 100 | ### [Mint](https://github.com/yonaskolb/Mint) (macOS) 101 | 102 | ``` sh 103 | mint install kattouf/ProgressLine 104 | ``` 105 | 106 | ### [Mise](https://mise.jdx.dev) (macOS) 107 | 108 | ``` sh 109 | mise use -g spm:kattouf/ProgressLine 110 | ``` 111 | 112 | ### Manual Installation (macOS / Linux) 113 | 114 | Download the binary for your platform from the [releases page](https://github.com/kattouf/ProgressLine/releases), and place it in your executable path. 115 | 116 | ## Contributing 117 | 118 | Feel free to open a pull request or a discussion. 119 | -------------------------------------------------------------------------------- /SakeApp/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /.index-build 4 | /Packages 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/configuration/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc -------------------------------------------------------------------------------- /SakeApp/BrewCommands.swift: -------------------------------------------------------------------------------- 1 | import Sake 2 | import SwiftShell 3 | 4 | @CommandGroup 5 | struct BrewCommands { 6 | static var ensureSwiftFormatInstalled: Command { 7 | Command( 8 | description: "Ensure swiftformat is installed", 9 | skipIf: { _ in 10 | run("which", "swiftformat").succeeded 11 | }, 12 | run: { _ in 13 | try runAndPrint("brew", "install", "swiftformat") 14 | } 15 | ) 16 | } 17 | 18 | static var ensureGhInstalled: Command { 19 | Command( 20 | description: "Ensure gh is installed", 21 | skipIf: { _ in 22 | run("which", "gh").succeeded 23 | }, 24 | run: { _ in 25 | try runAndPrint("brew", "install", "gh") 26 | } 27 | ) 28 | } 29 | 30 | static var ensureGitCliffInstalled: Command { 31 | Command( 32 | description: "Ensure git-cliff is installed", 33 | skipIf: { _ in 34 | run("which", "git-cliff").succeeded 35 | }, 36 | run: { _ in 37 | try runAndPrint("brew", "install", "git-cliff") 38 | } 39 | ) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /SakeApp/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "564ae29a93959e0a64ff9ad1a401e5db179007f3ecef20571f852606328631f6", 3 | "pins" : [ 4 | { 5 | "identity" : "sake", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/kattouf/Sake", 8 | "state" : { 9 | "revision" : "f2c91c8ecb4f67f0c565b081deb7d180761a21d7", 10 | "version" : "0.2.2" 11 | } 12 | }, 13 | { 14 | "identity" : "swift-argument-parser", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/apple/swift-argument-parser.git", 17 | "state" : { 18 | "revision" : "41982a3656a71c768319979febd796c6fd111d5c", 19 | "version" : "1.5.0" 20 | } 21 | }, 22 | { 23 | "identity" : "swift-syntax", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/apple/swift-syntax", 26 | "state" : { 27 | "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d", 28 | "version" : "509.1.1" 29 | } 30 | }, 31 | { 32 | "identity" : "swiftshell", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/kareman/SwiftShell", 35 | "state" : { 36 | "revision" : "99680b2efc7c7dbcace1da0b3979d266f02e213c", 37 | "version" : "5.1.0" 38 | } 39 | }, 40 | { 41 | "identity" : "yams", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/jpsim/Yams.git", 44 | "state" : { 45 | "revision" : "3036ba9d69cf1fd04d433527bc339dc0dc75433d", 46 | "version" : "5.1.3" 47 | } 48 | } 49 | ], 50 | "version" : 3 51 | } 52 | -------------------------------------------------------------------------------- /SakeApp/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.10 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import CompilerPluginSupport 5 | import PackageDescription 6 | 7 | let package = Package( 8 | name: "SakeApp", 9 | platforms: [.macOS(.v10_15)], // Required by SwiftSyntax for the macro feature in Sake 10 | products: [ 11 | .executable(name: "SakeApp", targets: ["SakeApp"]), 12 | ], 13 | dependencies: [ 14 | .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.0"), 15 | .package(url: "https://github.com/kattouf/Sake", from: "0.1.0"), 16 | .package(url: "https://github.com/kareman/SwiftShell", from: "5.1.0"), 17 | ], 18 | targets: [ 19 | .executableTarget( 20 | name: "SakeApp", 21 | dependencies: [ 22 | .product(name: "ArgumentParser", package: "swift-argument-parser"), 23 | "Sake", 24 | "SwiftShell", 25 | ], 26 | path: "." 27 | ), 28 | ] 29 | ) 30 | -------------------------------------------------------------------------------- /SakeApp/ReleaseCommands.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import CryptoKit 3 | import Foundation 4 | import Sake 5 | import SwiftShell 6 | 7 | @CommandGroup 8 | struct ReleaseCommands { 9 | private struct BuildTarget { 10 | enum Arch { 11 | case x86 12 | case arm 13 | } 14 | 15 | enum OS { 16 | case macos 17 | case linux 18 | } 19 | 20 | let arch: Arch 21 | let os: OS 22 | 23 | var triple: String { 24 | switch (arch, os) { 25 | case (.x86, .macos): "x86_64-apple-macosx" 26 | case (.arm, .macos): "arm64-apple-macosx" 27 | case (.x86, .linux): "x86_64-unknown-linux-gnu" 28 | case (.arm, .linux): "aarch64-unknown-linux-gnu" 29 | } 30 | } 31 | } 32 | 33 | private enum Constants { 34 | static let buildArtifactsDirectory = ".build/artifacts" 35 | static let swiftVersion = "6.0" 36 | static let buildTargets: [BuildTarget] = [ 37 | .init(arch: .arm, os: .macos), 38 | .init(arch: .x86, os: .macos), 39 | .init(arch: .x86, os: .linux), 40 | .init(arch: .arm, os: .linux), 41 | ] 42 | static let executableName = "progressline" 43 | } 44 | 45 | private struct ReleaseArguments: ParsableArguments { 46 | @Argument(help: "Version number") 47 | var version: String 48 | 49 | func validate() throws { 50 | guard version.range(of: #"^\d+\.\d+\.\d+$"#, options: .regularExpression) != nil else { 51 | throw ValidationError("Invalid version number. Should be in the format 'x.y.z'") 52 | } 53 | } 54 | } 55 | 56 | public static var brewRelease: Command { 57 | Command( 58 | description: "Brew to Homebrew", 59 | run: { context in 60 | let arguments = try ReleaseArguments.parse(context.arguments) 61 | try arguments.validate() 62 | 63 | let version = arguments.version 64 | try runAndPrint("brew", "bump-formula-pr", "--version=\(version)", "progressline") 65 | } 66 | ) 67 | } 68 | 69 | public static var githubRelease: Command { 70 | Command( 71 | description: "Release to GitHub", 72 | dependencies: [ 73 | bumpVersion, 74 | cleanReleaseArtifacts, 75 | buildReleaseArtifacts, 76 | calculateBuildArtifactsSha256, 77 | createAndPushTag, 78 | generateReleaseNotes, 79 | draftReleaseWithArtifacts, 80 | ] 81 | ) 82 | } 83 | 84 | static var bumpVersion: Command { 85 | Command( 86 | description: "Bump version", 87 | skipIf: { context in 88 | let arguments = try ReleaseArguments.parse(context.arguments) 89 | try arguments.validate() 90 | 91 | let version = arguments.version 92 | let versionFilePath = "Sources/Version.swift" 93 | let currentVersion = try String(contentsOfFile: versionFilePath) 94 | .split(separator: "\"")[1] 95 | if currentVersion == version { 96 | print("Version is already \(version). Skipping...") 97 | return true 98 | } else { 99 | return false 100 | } 101 | }, 102 | run: { context in 103 | let arguments = try ReleaseArguments.parse(context.arguments) 104 | try arguments.validate() 105 | 106 | let version = arguments.version 107 | let versionFilePath = "Sources/Version.swift" 108 | let versionFileContent = """ 109 | // This file is autogenerated. Do not edit. 110 | let progressLineVersion = "\(version)" 111 | 112 | """ 113 | try versionFileContent.write(toFile: versionFilePath, atomically: true, encoding: .utf8) 114 | 115 | try runAndPrint("git", "add", versionFilePath) 116 | try runAndPrint("git", "commit", "-m", "chore(release): Bump version to \(version)") 117 | print("Version bumped to \(version)") 118 | } 119 | ) 120 | } 121 | 122 | static var cleanReleaseArtifacts: Command { 123 | Command( 124 | description: "Clean release artifacts", 125 | run: { _ in 126 | try? runAndPrint("rm", "-rf", Constants.buildArtifactsDirectory) 127 | } 128 | ) 129 | } 130 | 131 | static var buildReleaseArtifacts: Command { 132 | Command( 133 | description: "Build release artifacts", 134 | skipIf: { context in 135 | let arguments = try ReleaseArguments.parse(context.arguments) 136 | try arguments.validate() 137 | let version = arguments.version 138 | 139 | let targetsWithExistingArtifacts = Constants.buildTargets.filter { target in 140 | let archivePath = executableArchivePath(target: target, version: version) 141 | return FileManager.default.fileExists(atPath: archivePath) 142 | } 143 | if targetsWithExistingArtifacts.count == Constants.buildTargets.count { 144 | print("Release artifacts already exist. Skipping...") 145 | return true 146 | } else { 147 | context.storage["existing-artifacts-triples"] = targetsWithExistingArtifacts.map(\.triple) 148 | return false 149 | } 150 | }, 151 | run: { context in 152 | let arguments = try ReleaseArguments.parse(context.arguments) 153 | try arguments.validate() 154 | let version = arguments.version 155 | 156 | try FileManager.default.createDirectory( 157 | atPath: Constants.buildArtifactsDirectory, 158 | withIntermediateDirectories: true, 159 | attributes: nil 160 | ) 161 | let existingArtifactsTriples = context.storage["existing-artifacts-triples"] as? [String] ?? [] 162 | for target in Constants.buildTargets { 163 | if existingArtifactsTriples.contains(target.triple) { 164 | print("Skipping \(target.triple) as artifacts already exist") 165 | continue 166 | } 167 | let (swiftBuild, swiftClean, strip, zip) = { 168 | let buildFlags = ["--disable-sandbox", "--configuration", "release", "--triple", target.triple] 169 | if target.os == .linux { 170 | let platform = target.arch == .arm ? "linux/arm64" : "linux/amd64" 171 | let dockerExec = 172 | "docker run --rm --volume \(context.projectRoot):/workdir --workdir /workdir --platform \(platform) swift:\(Constants.swiftVersion)" 173 | let buildFlags = (buildFlags + ["--static-swift-stdlib"]).joined(separator: " ") 174 | return ( 175 | "\(dockerExec) swift build \(buildFlags)", 176 | "\(dockerExec) swift package clean", 177 | "\(dockerExec) strip -s", 178 | "zip -j" 179 | ) 180 | } else { 181 | let buildFlags = buildFlags.joined(separator: " ") 182 | return ( 183 | "swift build \(buildFlags)", 184 | "swift package clean", 185 | "strip -rSTx", 186 | "zip -j" 187 | ) 188 | } 189 | }() 190 | 191 | try runAndPrint(bash: swiftClean) 192 | try runAndPrint(bash: swiftBuild) 193 | 194 | let binPath: String = run(bash: "\(swiftBuild) --show-bin-path").stdout 195 | if binPath.isEmpty { 196 | throw NSError(domain: "Fail to get bin path", code: -999) 197 | } 198 | let executablePath = binPath + "/\(Constants.executableName)" 199 | 200 | try runAndPrint(bash: "\(strip) \(executablePath)") 201 | 202 | let executableArchivePath = executableArchivePath(target: target, version: version) 203 | try runAndPrint( 204 | bash: "\(zip) \(executableArchivePath) \(executablePath.replacingOccurrences(of: "/workdir", with: context.projectRoot))" 205 | ) 206 | } 207 | 208 | print("Release artifacts built successfully at '\(Constants.buildArtifactsDirectory)'") 209 | } 210 | ) 211 | } 212 | 213 | static var calculateBuildArtifactsSha256: Command { 214 | @Sendable 215 | func shasumFilePath(version: String) -> String { 216 | ".build/artifacts/shasum-\(version)" 217 | } 218 | 219 | return Command( 220 | description: "Calculate SHA-256 checksums for build artifacts", 221 | skipIf: { context in 222 | let arguments = try ReleaseArguments.parse(context.arguments) 223 | try arguments.validate() 224 | let version = arguments.version 225 | 226 | let shasumFilePath = shasumFilePath(version: version) 227 | 228 | return FileManager.default.fileExists(atPath: shasumFilePath) 229 | }, 230 | run: { context in 231 | let arguments = try ReleaseArguments.parse(context.arguments) 232 | try arguments.validate() 233 | let version = arguments.version 234 | 235 | var shasumResults = [String]() 236 | for target in Constants.buildTargets { 237 | let archivePath = executableArchivePath(target: target, version: version) 238 | let file = FileHandle(forReadingAtPath: archivePath)! 239 | let shasum = SHA256.hash(data: file.readDataToEndOfFile()) 240 | let shasumString = shasum.compactMap { String(format: "%02x", $0) }.joined() 241 | shasumResults.append("\(shasumString) \(archivePath)") 242 | } 243 | FileManager.default.createFile( 244 | atPath: shasumFilePath(version: version), 245 | contents: shasumResults.joined(separator: "\n").data(using: .utf8) 246 | ) 247 | } 248 | ) 249 | } 250 | 251 | static var createAndPushTag: Command { 252 | Command( 253 | description: "Create and push a tag", 254 | skipIf: { context in 255 | let arguments = try ReleaseArguments.parse(context.arguments) 256 | try arguments.validate() 257 | 258 | let version = arguments.version 259 | 260 | let grepResult = run(bash: "git tag | grep \(arguments.version)") 261 | if grepResult.succeeded { 262 | print("Tag \(version) already exists. Skipping...") 263 | return true 264 | } else { 265 | return false 266 | } 267 | }, 268 | run: { context in 269 | let arguments = try ReleaseArguments.parse(context.arguments) 270 | try arguments.validate() 271 | 272 | let version = arguments.version 273 | 274 | print("Creating and pushing tag \(version)") 275 | try runAndPrint("git", "tag", version) 276 | try runAndPrint("git", "push", "origin", "tag", version) 277 | try runAndPrint("git", "push") // push local changes like version bump 278 | } 279 | ) 280 | } 281 | 282 | static var generateReleaseNotes: Command { 283 | Command( 284 | description: "Generate release notes", 285 | dependencies: [BrewCommands.ensureGitCliffInstalled], 286 | skipIf: { context in 287 | let arguments = try ReleaseArguments.parse(context.arguments) 288 | try arguments.validate() 289 | 290 | let version = arguments.version 291 | let releaseNotesPath = releaseNotesPath(version: version) 292 | if FileManager.default.fileExists(atPath: releaseNotesPath) { 293 | print("Release notes for \(version) already exist at \(releaseNotesPath). Skipping...") 294 | return true 295 | } else { 296 | return false 297 | } 298 | }, 299 | run: { context in 300 | let arguments = try ReleaseArguments.parse(context.arguments) 301 | try arguments.validate() 302 | 303 | let version = arguments.version 304 | let releaseNotesPath = releaseNotesPath(version: version) 305 | try runAndPrint("git", "cliff", "--latest", "--strip=all", "--tag", version, "--output", releaseNotesPath) 306 | print("Release notes generated at \(releaseNotesPath)") 307 | } 308 | ) 309 | } 310 | 311 | static var draftReleaseWithArtifacts: Command { 312 | Command( 313 | description: "Draft a release on GitHub", 314 | dependencies: [BrewCommands.ensureGhInstalled], 315 | skipIf: { context in 316 | let arguments = try ReleaseArguments.parse(context.arguments) 317 | try arguments.validate() 318 | 319 | let tagName = arguments.version 320 | let ghViewResult = run(bash: "gh release view \(tagName)") 321 | if ghViewResult.succeeded { 322 | print("Release \(tagName) already exists. Skipping...") 323 | return true 324 | } else { 325 | return false 326 | } 327 | }, 328 | run: { context in 329 | let arguments = try ReleaseArguments.parse(context.arguments) 330 | try arguments.validate() 331 | 332 | print("Drafting release \(arguments.version) on GitHub") 333 | let tagName = arguments.version 334 | let releaseTitle = arguments.version 335 | let draftReleaseCommand = 336 | "gh release create \(tagName) \(Constants.buildArtifactsDirectory)/*.zip --title '\(releaseTitle)' --draft --verify-tag --notes-file \(releaseNotesPath(version: tagName))" 337 | try runAndPrint(bash: draftReleaseCommand) 338 | } 339 | ) 340 | } 341 | 342 | private static func executableArchivePath(target: BuildTarget, version: String) -> String { 343 | "\(Constants.buildArtifactsDirectory)/\(Constants.executableName)-\(version)-\(target.triple).zip" 344 | } 345 | 346 | private static func releaseNotesPath(version: String) -> String { 347 | ".build/artifacts/release-notes-\(version).md" 348 | } 349 | } 350 | -------------------------------------------------------------------------------- /SakeApp/Sakefile.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import Foundation 3 | import Sake 4 | import SwiftShell 5 | 6 | @main 7 | @CommandGroup 8 | struct Commands: SakeApp { 9 | public static let configuration = SakeAppConfiguration( 10 | commandGroups: [ 11 | TestCommands.self, 12 | ReleaseCommands.self, 13 | ] 14 | ) 15 | 16 | public static var lint: Command { 17 | Command( 18 | description: "Lint code", 19 | dependencies: [BrewCommands.ensureSwiftFormatInstalled], 20 | run: { _ in 21 | try runAndPrint("swiftformat", "Sources", "SakeApp", "Package.swift", "--lint") 22 | } 23 | ) 24 | } 25 | 26 | public static var format: Command { 27 | Command( 28 | description: "Format code", 29 | dependencies: [BrewCommands.ensureSwiftFormatInstalled], 30 | run: { _ in 31 | try runAndPrint("swiftformat", "Sources", "SakeApp", "Package.swift") 32 | } 33 | ) 34 | } 35 | } 36 | 37 | @CommandGroup 38 | struct TestCommands { 39 | public static var test: Command { 40 | Command( 41 | description: "Run tests", 42 | dependencies: [ensureDebugBuildIsUpToDate], 43 | run: { context in 44 | try runAndPrint( 45 | bash: 46 | "\(context.projectRoot)/Tests/integration_tests.sh \(context.projectRoot)/.build/debug/progressline" 47 | ) 48 | } 49 | ) 50 | } 51 | 52 | private static var ensureDebugBuildIsUpToDate: Command { 53 | Command( 54 | description: "Ensure debug build is up to date", 55 | run: { context in 56 | try runAndPrint(bash: "swift build --package-path \(context.projectRoot)") 57 | } 58 | ) 59 | } 60 | } 61 | 62 | extension Command.Context { 63 | var projectRoot: String { 64 | "\(appDirectory)/.." 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Sources/ANSI.swift: -------------------------------------------------------------------------------- 1 | enum ANSI { 2 | // Cursor controls 3 | static func cursorUp(_ count: Int) -> String { 4 | "\u{1B}[\(count)A" 5 | } 6 | 7 | static func cursorToColumn(_ column: Int) -> String { 8 | "\u{1B}[\(column)G" 9 | } 10 | 11 | static let eraseLine = "\u{1B}[2K" 12 | 13 | // Colors and styles 14 | static let noStyleMode = !isTTY 15 | static var red: String { 16 | noStyleMode ? "" : "\u{1B}[31m" 17 | } 18 | 19 | static var green: String { 20 | noStyleMode ? "" : "\u{1B}[32m" 21 | } 22 | 23 | static var yellow: String { 24 | noStyleMode ? "" : "\u{1B}[33m" 25 | } 26 | 27 | static var blue: String { 28 | noStyleMode ? "" : "\u{1B}[34m" 29 | } 30 | 31 | static var magenta: String { 32 | noStyleMode ? "" : "\u{1B}[35m" 33 | } 34 | 35 | static var bold: String { 36 | noStyleMode ? "" : "\u{1B}[1m" 37 | } 38 | 39 | static var reset: String { 40 | noStyleMode ? "" : "\u{1B}[0m" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/ActivityIndicator+CommandArgument.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | 3 | enum ActivityIndicatorStyle: String, CaseIterable, ExpressibleByArgument { 4 | case dots 5 | case kitt 6 | case snake 7 | case spinner 8 | } 9 | 10 | extension ActivityIndicator { 11 | static func make(style: ActivityIndicatorStyle) -> ActivityIndicator { 12 | switch style { 13 | case .dots: 14 | .dots 15 | case .kitt: 16 | .kitt 17 | case .snake: 18 | .snake 19 | case .spinner: 20 | .spinner 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/ActivityIndicator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import TaggedTime 3 | 4 | final class ActivityIndicator: Sendable { 5 | struct Configuration { 6 | let refreshRate: Milliseconds 7 | let states: [String] 8 | } 9 | 10 | let configuration: Configuration 11 | 12 | init(configuration: Configuration) { 13 | self.configuration = configuration 14 | } 15 | 16 | func state(forDuration duration: Seconds) -> String { 17 | let iteration = Int(duration.milliseconds.rawValue / TimeInterval(configuration.refreshRate.rawValue)) % configuration.states.count 18 | return configuration.states[iteration] 19 | } 20 | } 21 | 22 | extension ActivityIndicator { 23 | static let dots: ActivityIndicator = { 24 | let configuration = Configuration( 25 | refreshRate: 125, 26 | states: [ 27 | "⠋", 28 | "⠙", 29 | "⠹", 30 | "⠸", 31 | "⠼", 32 | "⠴", 33 | "⠦", 34 | "⠧", 35 | "⠇", 36 | "⠏", 37 | ] 38 | ) 39 | return ActivityIndicator(configuration: configuration) 40 | }() 41 | 42 | static let kitt: ActivityIndicator = { 43 | let configuration = Configuration( 44 | refreshRate: 125, 45 | states: [ 46 | "▰▱▱▱▱", 47 | "▰▰▱▱▱", 48 | "▰▰▰▱▱", 49 | "▱▰▰▰▱", 50 | "▱▱▰▰▰", 51 | "▱▱▱▰▰", 52 | "▱▱▱▱▰", 53 | "▱▱▱▰▰", 54 | "▱▱▰▰▰", 55 | "▱▰▰▰▱", 56 | "▰▰▰▱▱", 57 | "▰▰▱▱▱", 58 | ] 59 | ) 60 | return ActivityIndicator(configuration: configuration) 61 | }() 62 | 63 | static let snake: ActivityIndicator = { 64 | let configuration = Configuration( 65 | refreshRate: 125, 66 | states: [ 67 | "▰▱▱▱▱", 68 | "▰▰▱▱▱", 69 | "▰▰▰▱▱", 70 | "▱▰▰▰▱", 71 | "▱▱▰▰▰", 72 | "▱▱▱▰▰", 73 | "▱▱▱▱▰", 74 | "▱▱▱▱▱", 75 | ] 76 | ) 77 | return ActivityIndicator(configuration: configuration) 78 | }() 79 | 80 | static let spinner: ActivityIndicator = { 81 | let configuration = Configuration( 82 | refreshRate: 125, 83 | states: [ 84 | "\\", 85 | "|", 86 | "/", 87 | "-", 88 | ] 89 | ) 90 | return ActivityIndicator(configuration: configuration) 91 | }() 92 | } 93 | 94 | #if DEBUG 95 | extension ActivityIndicator { 96 | static func disabled() -> ActivityIndicator { 97 | .init( 98 | configuration: .init(refreshRate: 1_000_000_000, states: []) 99 | ) 100 | } 101 | } 102 | #endif 103 | -------------------------------------------------------------------------------- /Sources/ErrorMessage.swift: -------------------------------------------------------------------------------- 1 | enum ErrorMessage { 2 | static let canNotDecodeData = "\(ANSI.yellow)[!] progressline: Failed to decode stdin data as UTF-8\(ANSI.reset)" 3 | static func canNotCompileRegex(_ regex: String) -> String { 4 | "\(ANSI.yellow)[!] progressline: Failed to compile regular expression: \(regex)\(ANSI.reset)" 5 | } 6 | 7 | static func canNotOpenFile(_ path: String) -> String { 8 | "\(ANSI.yellow)[!] progressline: Failed to open file at path: \(path)\(ANSI.reset)" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/FileHandler+AsyncStream.swift: -------------------------------------------------------------------------------- 1 | #if os(Linux) 2 | // Linux implementation of FileHandle not Sendable 3 | @preconcurrency import Foundation 4 | #else 5 | import Foundation 6 | #endif 7 | 8 | extension FileHandle { 9 | var asyncStream: AsyncStream { 10 | AsyncStream { continuation in 11 | Task { 12 | while let data = try waitAndReadAvailableData() { 13 | continuation.yield(data) 14 | } 15 | continuation.finish() 16 | } 17 | } 18 | } 19 | 20 | private func waitAndReadAvailableData() throws -> Data? { 21 | let data = availableData 22 | guard !data.isEmpty else { 23 | return nil 24 | } 25 | return data 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/LogAllController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | final class LogAllController { 4 | private let logger: AboveProgressLineLogger 5 | 6 | init(logger: AboveProgressLineLogger) { 7 | self.logger = logger 8 | } 9 | 10 | func didGetStdinDataChunk(_ data: Data) async { 11 | let text = String(data: data, encoding: .utf8) 12 | guard let text else { 13 | await logger.logError(ErrorMessage.canNotDecodeData) 14 | return 15 | } 16 | // we control newlines and whitespaces in the logger 17 | let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines) 18 | await logger.log(trimmedText) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/MatchesController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | final class MatchesController { 4 | private let logger: AboveProgressLineLogger 5 | let regexps: [NSRegularExpression] 6 | 7 | init?(logger: AboveProgressLineLogger, regexps: [String]) async { 8 | self.logger = logger 9 | guard !regexps.isEmpty else { 10 | return nil 11 | } 12 | var invalidRegexps = [String]() 13 | self.regexps = regexps.compactMap { regexp in 14 | do { 15 | return try NSRegularExpression(pattern: regexp) 16 | } catch { 17 | invalidRegexps.append(regexp) 18 | return nil 19 | } 20 | } 21 | for invalidRegexp in invalidRegexps { 22 | await logger.logError(ErrorMessage.canNotCompileRegex(invalidRegexp)) 23 | } 24 | } 25 | 26 | func didGetStdinDataChunk(_ data: Data) async { 27 | let text = String(data: data, encoding: .utf8) 28 | guard let text else { 29 | await logger.logError(ErrorMessage.canNotDecodeData) 30 | return 31 | } 32 | for line in text.split(whereSeparator: \.isNewline) { 33 | let range = NSRange(location: 0, length: line.utf16.count) 34 | for regex in regexps { 35 | if regex.firstMatch(in: String(line), range: range) != nil { 36 | await logger.log(String(line)) 37 | break 38 | } 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/OriginalLogController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | final class OriginalLogController { 4 | private let logger: AboveProgressLineLogger 5 | let fileHandle: FileHandle 6 | 7 | init?(logger: AboveProgressLineLogger, path: String) async { 8 | self.logger = logger 9 | 10 | do { 11 | let url = URL(fileURLWithPath: path) 12 | try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) 13 | _ = FileManager.default.createFile(atPath: path, contents: nil) 14 | self.fileHandle = try FileHandle(forWritingTo: url) 15 | fileHandle.seekToEndOfFile() 16 | } catch { 17 | await logger.logError(ErrorMessage.canNotOpenFile(path)) 18 | return nil 19 | } 20 | } 21 | 22 | deinit { 23 | fileHandle.closeFile() 24 | } 25 | 26 | func didGetStdinDataChunk(_ data: Data) async { 27 | fileHandle.write(data) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/Printer.swift: -------------------------------------------------------------------------------- 1 | import ConcurrencyExtras 2 | #if os(Linux) 3 | // Linux implementation of FileHandle not Sendable 4 | @preconcurrency import Foundation 5 | #else 6 | import Foundation 7 | #endif 8 | 9 | final class Printer: Sendable { 10 | private let fileHandle: LockIsolated 11 | private let buffer = LockIsolated(String()) 12 | private let _wasWritten = LockIsolated(false) 13 | 14 | var wasWritten: Bool { 15 | _wasWritten.value 16 | } 17 | 18 | init(fileHandle: FileHandle) { 19 | self.fileHandle = .init(fileHandle) 20 | } 21 | 22 | @discardableResult 23 | func writeln(_ text: String) -> Self { 24 | buffer.withValue { $0 += text + "\n" } 25 | return self 26 | } 27 | 28 | @discardableResult 29 | func write(_ text: String) -> Self { 30 | buffer.withValue { $0 += text } 31 | return self 32 | } 33 | 34 | @discardableResult 35 | func cursorToColumn(_ column: Int) -> Self { 36 | buffer.withValue { $0 += ANSI.cursorToColumn(column) } 37 | return self 38 | } 39 | 40 | @discardableResult 41 | func cursorUp(_ count: Int = 1) -> Self { 42 | buffer.withValue { $0 += ANSI.cursorUp(count) } 43 | return self 44 | } 45 | 46 | @discardableResult 47 | func eraseLine() -> Self { 48 | buffer.withValue { $0 += ANSI.eraseLine } 49 | return self 50 | } 51 | 52 | func flush() { 53 | fileHandle.withValue { 54 | $0.write(buffer.value.data(using: .utf8)!) 55 | try? $0.synchronize() 56 | } 57 | if !_wasWritten.value { 58 | _wasWritten.setValue(true) 59 | } 60 | buffer.setValue(String()) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Sources/PrintersHolder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // "Lock" access to printers to prevent write conflicts 4 | final actor PrintersHolder { 5 | private let printer: Printer 6 | private let errorsPrinter: Printer 7 | 8 | init(printer: Printer, errorsPrinter: Printer) { 9 | self.printer = printer 10 | self.errorsPrinter = errorsPrinter 11 | } 12 | 13 | func withPrinter(_ body: @Sendable (Printer) async throws -> T) async rethrows -> T { 14 | try await body(printer) 15 | } 16 | 17 | func withErrorsPrinter(_ body: @Sendable (Printer) async throws -> T) async rethrows -> T { 18 | try await body(errorsPrinter) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/ProgressLine.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import ConcurrencyExtras 3 | import Foundation 4 | import TaggedTime 5 | 6 | @main 7 | struct ProgressLine: AsyncParsableCommand { 8 | static let configuration = CommandConfiguration( 9 | commandName: "progressline", 10 | abstract: "A command-line tool for compactly tracking the progress of piped commands.", 11 | usage: "some-command | progressline", 12 | version: progressLineVersion 13 | ) 14 | 15 | @Option(name: [.long, .customShort("t")], help: "The static text to display instead of the latest stdin data.") 16 | var staticText: String? 17 | 18 | @Option(name: [.customLong("activity-style"), .customShort("s")], help: "The style of the activity indicator.") 19 | var activityIndicatorStyle: ActivityIndicatorStyle = .dots 20 | 21 | @Option(name: [.customLong("original-log-path"), .customShort("l")], help: "Save the original log to a file.") 22 | var originalLogPath: String? 23 | 24 | @Option( 25 | name: [.customLong("log-matches"), .customShort("m")], 26 | help: "Log above progress line lines matching the given regular expressions." 27 | ) 28 | var matchesToLog: [String] = [] 29 | 30 | @Flag(name: [.customLong("log-all"), .customShort("a")], help: "Log all lines above the progress line.") 31 | var shouldLogAll: Bool = false 32 | 33 | #if DEBUG 34 | @Flag(name: [.customLong("test-mode")], help: "Enable test mode. Activity indicator will be replaced with a static string.") 35 | var testMode: Bool = false 36 | #endif 37 | 38 | mutating func run() async throws { 39 | try validateConfiguration() 40 | 41 | let printers = PrintersHolder( 42 | printer: Printer(fileHandle: .standardOutput), 43 | errorsPrinter: Printer(fileHandle: .standardError) 44 | ) 45 | let logger = AboveProgressLineLogger(printers: printers) 46 | 47 | #if DEBUG 48 | let activityIndicator: ActivityIndicator = testMode ? .disabled() : .make(style: activityIndicatorStyle) 49 | #else 50 | let testMode = false 51 | let activityIndicator: ActivityIndicator = .make(style: activityIndicatorStyle) 52 | #endif 53 | let progressLineController = await ProgressLineController.buildAndStart( 54 | textMode: staticText.map { .staticText($0) } ?? .stdin, 55 | printers: printers, 56 | logger: logger, 57 | activityIndicator: activityIndicator, 58 | mockActivityAndDuration: testMode 59 | ) 60 | let originalLogController = if let originalLogPath { 61 | await OriginalLogController(logger: logger, path: originalLogPath) 62 | } else { 63 | OriginalLogController?.none 64 | } 65 | let matchesController = await MatchesController(logger: logger, regexps: matchesToLog) 66 | let logAllController = shouldLogAll ? LogAllController(logger: logger) : nil 67 | 68 | for await data in FileHandle.standardInput.asyncStream { 69 | await logAllController?.didGetStdinDataChunk(data) 70 | await matchesController?.didGetStdinDataChunk(data) 71 | await progressLineController.didGetStdinDataChunk(data) 72 | await originalLogController?.didGetStdinDataChunk(data) 73 | } 74 | 75 | await progressLineController.didReachEndOfStdin() 76 | } 77 | 78 | private func validateConfiguration() throws { 79 | guard !shouldLogAll || matchesToLog.isEmpty else { 80 | throw ValidationError("The --log-all and --log-matches options are mutually exclusive.") 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Sources/ProgressLineController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import TaggedTime 3 | 4 | final actor ProgressLineController { 5 | enum TextMode { 6 | case staticText(String) 7 | case stdin 8 | } 9 | 10 | // Dependencies 11 | private let textMode: TextMode 12 | private let printers: PrintersHolder 13 | private let logger: AboveProgressLineLogger 14 | private let progressLineFormatter: ProgressLineFormatter 15 | private let progressTracker: ProgressTracker 16 | // State 17 | private var renderLoopTask: Task? 18 | private var lastStdinLine: String? 19 | private var progress: Progress? 20 | 21 | private init( 22 | textMode: TextMode, 23 | printers: PrintersHolder, 24 | logger: AboveProgressLineLogger, 25 | progressLineFormatter: ProgressLineFormatter, 26 | progressTracker: ProgressTracker 27 | ) { 28 | self.textMode = textMode 29 | self.printers = printers 30 | self.logger = logger 31 | self.progressLineFormatter = progressLineFormatter 32 | self.progressTracker = progressTracker 33 | } 34 | 35 | // MARK: - Public 36 | 37 | static func buildAndStart( 38 | textMode: TextMode, 39 | printers: PrintersHolder, 40 | logger: AboveProgressLineLogger, 41 | activityIndicator: ActivityIndicator, 42 | mockActivityAndDuration: Bool = false 43 | ) async -> Self { 44 | let progressTracker = ProgressTracker.start() 45 | let windowSizeObserver = WindowSizeObserver.startObserving() 46 | let progressLineFormatter = ProgressLineFormatter( 47 | activityIndicator: activityIndicator, 48 | windowSizeObserver: windowSizeObserver, 49 | mockActivityAndDuration: mockActivityAndDuration 50 | ) 51 | 52 | let controller = Self( 53 | textMode: textMode, 54 | printers: printers, 55 | logger: logger, 56 | progressLineFormatter: progressLineFormatter, 57 | progressTracker: progressTracker 58 | ) 59 | await controller.startAnimationLoop(refreshRate: activityIndicator.configuration.refreshRate) 60 | 61 | return controller 62 | } 63 | 64 | // MARK: - Input 65 | 66 | func didGetStdinDataChunk(_ data: Data) async { 67 | guard case .stdin = textMode else { 68 | // we will redraw anyway to sync (prevent flickering) with other log controllers 69 | await redrawProgressLine() 70 | return 71 | } 72 | 73 | let stdinText = String(data: data, encoding: .utf8) 74 | guard let stdinText else { 75 | await logger.logError(ErrorMessage.canNotDecodeData) 76 | return 77 | } 78 | 79 | lastStdinLine = stdinText 80 | .split(whereSeparator: \.isNewline) 81 | .last { !$0.isEmpty } 82 | .map(String.init) 83 | 84 | await redrawProgressLine() 85 | } 86 | 87 | func didReachEndOfStdin() async { 88 | stopAnimationLoop() 89 | 90 | let progressLine = progressLineFormatter.finished(progress: progress) 91 | await printers.withPrinter { printer in 92 | if printer.wasWritten { 93 | printer 94 | .cursorUp() 95 | .eraseLine() 96 | } 97 | printer 98 | .writeln(progressLine) 99 | .flush() 100 | } 101 | } 102 | 103 | // MARK: - Private 104 | 105 | private func startAnimationLoop(refreshRate: Milliseconds) { 106 | renderLoopTask = Task.periodic(interval: refreshRate) { [weak self] in 107 | guard !Task.isCancelled else { 108 | return 109 | } 110 | await self?.redrawProgressLine() 111 | } 112 | } 113 | 114 | private func stopAnimationLoop() { 115 | renderLoopTask?.cancel() 116 | } 117 | 118 | private func redrawProgressLine() async { 119 | let lineText: String? = switch textMode { 120 | case let .staticText(text): 121 | text 122 | case .stdin: 123 | lastStdinLine 124 | } 125 | let progress = progressTracker.moveForward(lineText) 126 | let progressLine = progressLineFormatter.inProgress(progress: progress) 127 | self.progress = progress 128 | await printers.withPrinter { printer in 129 | if printer.wasWritten { 130 | printer 131 | .cursorUp() 132 | .eraseLine() 133 | } 134 | printer 135 | .writeln(progressLine) 136 | .flush() 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /Sources/ProgressLineFormatter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import TaggedTime 3 | 4 | private enum Symbol { 5 | static let checkmark = "✓" 6 | static let prompt = "❯" 7 | } 8 | 9 | final class ProgressLineFormatter: Sendable { 10 | // Linux doesn't support DateComponentsFormatter 11 | #if os(macOS) 12 | private let durationFormatter: DateComponentsFormatter = { 13 | let durationFormatter = DateComponentsFormatter() 14 | durationFormatter.unitsStyle = .abbreviated 15 | durationFormatter.allowedUnits = [.hour, .minute, .second] 16 | durationFormatter.maximumUnitCount = 2 17 | return durationFormatter 18 | }() 19 | #endif 20 | 21 | private let activityIndicator: ActivityIndicator 22 | private let windowSizeObserver: WindowSizeObserver? 23 | private let mockActivityAndDuration: Bool 24 | 25 | init( 26 | activityIndicator: ActivityIndicator, 27 | windowSizeObserver: WindowSizeObserver?, 28 | mockActivityAndDuration: Bool 29 | ) { 30 | self.activityIndicator = activityIndicator 31 | self.windowSizeObserver = windowSizeObserver 32 | self.mockActivityAndDuration = mockActivityAndDuration 33 | } 34 | 35 | func inProgress(progress: Progress) -> String { 36 | let activityIndicator = mockActivityAndDuration ? "" : activityIndicator.state(forDuration: progress.duration) 37 | let formattedDuration = mockActivityAndDuration ? "" : formatDuration(from: progress.duration) 38 | 39 | let styledActivityIndicator = ANSI.blue + activityIndicator + ANSI.reset 40 | let styledDuration = ANSI.bold + formattedDuration + ANSI.reset 41 | let styledPrompt = ANSI.blue + Symbol.prompt + ANSI.reset 42 | 43 | return buildResultString( 44 | styledActivityIndicator: styledActivityIndicator, 45 | styledDuration: styledDuration, 46 | styledPrompt: styledPrompt, 47 | progressLine: progress.line 48 | ) 49 | } 50 | 51 | func finished(progress: Progress?) -> String { 52 | let formattedDuration = mockActivityAndDuration ? "" : progress.map { formatDuration(from: $0.duration) } 53 | 54 | let styledActivityIndicator = ANSI.green + Symbol.checkmark + ANSI.reset 55 | let styledDuration = formattedDuration.map { ANSI.bold + $0 + ANSI.reset } 56 | let styledPrompt = ANSI.green + Symbol.prompt + ANSI.reset 57 | 58 | return buildResultString( 59 | styledActivityIndicator: styledActivityIndicator, 60 | styledDuration: styledDuration, 61 | styledPrompt: styledPrompt, 62 | progressLine: progress?.line 63 | ) 64 | } 65 | 66 | private func buildResultString( 67 | styledActivityIndicator: String, 68 | styledDuration: String?, 69 | styledPrompt: String, 70 | progressLine: String? 71 | ) -> String { 72 | let buildResultWithProgressLine = { (progressLine: String?) -> String in 73 | [styledActivityIndicator, styledDuration, styledPrompt, progressLine] 74 | .compactMap { $0 } 75 | .joined(separator: " ") 76 | } 77 | let result = buildResultWithProgressLine(progressLine) 78 | 79 | let notFittedToWindowLength = calculateStringNotFittedToWindowLength(result) 80 | if let progressLine, notFittedToWindowLength > 0 { 81 | let fittedProgressLine = String(progressLine.prefix(progressLine.count - notFittedToWindowLength)) 82 | return buildResultWithProgressLine(fittedProgressLine) 83 | } else { 84 | return result 85 | } 86 | } 87 | 88 | private func calculateStringNotFittedToWindowLength(_ string: String) -> Int { 89 | guard let windowSizeObserver else { 90 | return 0 91 | } 92 | let stringWithoutANSI = string.withoutANSI() 93 | let windowWidth = windowSizeObserver.size.width 94 | return max(stringWithoutANSI.count - windowWidth, 0) 95 | } 96 | 97 | private func formatDuration(from duration: Seconds) -> String { 98 | #if os(Linux) 99 | duration.rawValue.formattedDuration() 100 | #else 101 | durationFormatter.string(from: duration.rawValue)! 102 | #endif 103 | } 104 | } 105 | 106 | #if os(Linux) 107 | extension TimeInterval { 108 | func formattedDuration() -> String { 109 | let totalSeconds = Int(self) 110 | let hours = totalSeconds / 3600 111 | let minutes = (totalSeconds % 3600) / 60 112 | let seconds = totalSeconds % 60 113 | 114 | if hours >= 1 { 115 | return "\(hours)h \(minutes)m" 116 | } else if minutes >= 1 { 117 | return "\(minutes)m \(seconds)s" 118 | } else { 119 | return "\(seconds)s" 120 | } 121 | } 122 | } 123 | #endif 124 | -------------------------------------------------------------------------------- /Sources/ProgressTracker.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import TaggedTime 3 | 4 | struct Progress { 5 | let line: String? 6 | let duration: Seconds 7 | } 8 | 9 | final class ProgressTracker: Sendable { 10 | private let startTimestamp: Seconds 11 | 12 | private init(startTimestamp: Seconds) { 13 | self.startTimestamp = startTimestamp 14 | } 15 | 16 | static func start() -> ProgressTracker { 17 | ProgressTracker(startTimestamp: Seconds(Date().timeIntervalSince1970)) 18 | } 19 | 20 | func moveForward(_ line: String?) -> Progress { 21 | let duration = Seconds(Date().timeIntervalSince1970) - startTimestamp 22 | return Progress(line: line, duration: duration) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/String+ANSI.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension String { 4 | private static let ansiRegex = try! NSRegularExpression(pattern: "\u{1B}(?:[@-Z\\-_]|\\[[0-?]*[ -/]*[@-~])") 5 | 6 | func withoutANSI() -> String { 7 | let range = NSRange(startIndex ..< endIndex, in: self) 8 | return Self.ansiRegex.stringByReplacingMatches(in: self, options: [], range: range, withTemplate: "") 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/Task+Periodic.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import TaggedTime 3 | 4 | extension Task where Success == Never, Failure == any Error { 5 | @discardableResult 6 | static func periodic(interval: Milliseconds, operation: @Sendable @escaping () async throws -> Void) -> Task { 7 | Task { 8 | while true { 9 | try Task.checkCancellation() 10 | try await operation() 11 | try await Task.sleep(nanoseconds: 1_000_000 * interval.rawValue) 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/UnderProgressLineLogger.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | final class AboveProgressLineLogger: Sendable { 4 | private let printers: PrintersHolder 5 | 6 | init(printers: PrintersHolder) { 7 | self.printers = printers 8 | } 9 | 10 | func log(_ text: String) async { 11 | await printers.withPrinter { printer in 12 | await log(printer: printer, text: text) 13 | } 14 | } 15 | 16 | func logError(_ text: String) async { 17 | await printers.withErrorsPrinter { errorsPrinter in 18 | await log(printer: errorsPrinter, text: text) 19 | } 20 | } 21 | 22 | private func log(printer: Printer, text: String) async { 23 | if printer.wasWritten { 24 | printer 25 | .cursorUp() 26 | .eraseLine() 27 | } 28 | printer 29 | .writeln(text) 30 | .writeln("") // Add an empty line after the message for delete it by progress line controller 31 | .flush() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/Version.swift: -------------------------------------------------------------------------------- 1 | // This file is autogenerated. Do not edit. 2 | let progressLineVersion = "0.2.4" 3 | -------------------------------------------------------------------------------- /Sources/WindowSizeObserver.swift: -------------------------------------------------------------------------------- 1 | import ConcurrencyExtras 2 | import Foundation 3 | 4 | final class WindowSizeObserver: Sendable { 5 | struct Size { 6 | let width: Int 7 | let height: Int 8 | } 9 | 10 | private let signalHandler: LockIsolated?> = .init(nil) 11 | private let _size: LockIsolated = .init(getTerminalSize()) 12 | 13 | var size: Size { 14 | _size.value 15 | } 16 | 17 | static func startObserving() -> WindowSizeObserver? { 18 | guard isTTY else { 19 | return nil 20 | } 21 | let observer = WindowSizeObserver() 22 | observer.setupSignalHandler() 23 | return observer 24 | } 25 | 26 | private init() {} 27 | 28 | private func setupSignalHandler() { 29 | let sigwinch = SIGWINCH 30 | 31 | let signalHandler = DispatchSource.makeSignalSource(signal: sigwinch) 32 | signal(sigwinch, SIG_IGN) 33 | 34 | signalHandler.setEventHandler { [weak self] in 35 | guard let self else { 36 | return 37 | } 38 | self.syncWindowSize() 39 | } 40 | signalHandler.resume() 41 | 42 | let uncheckedSendable = UncheckedSendable(signalHandler) 43 | self.signalHandler.setValue(uncheckedSendable) 44 | } 45 | 46 | private func syncWindowSize() { 47 | _size.setValue(Self.getTerminalSize()) 48 | } 49 | 50 | static func getTerminalSize() -> Size { 51 | var w = winsize() 52 | #if os(Linux) 53 | _ = ioctl(STDOUT_FILENO, UInt(TIOCGWINSZ), &w) 54 | #else 55 | _ = ioctl(STDOUT_FILENO, TIOCGWINSZ, &w) 56 | #endif 57 | return Size(width: Int(w.ws_col), height: Int(w.ws_row)) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/isTTY.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | let isTTY = isatty(STDOUT_FILENO) != 0 4 | -------------------------------------------------------------------------------- /Tests/assert.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # Determine if the script is sourced or executed 4 | if [[ "${BASH_SOURCE[0]}" != "${0}" ]]; then 5 | # Script is being sourced 6 | SNAPSHOTS_DIR="$(cd "$(dirname "${BASH_SOURCE[1]}")" && pwd)" 7 | else 8 | # Script is being executed 9 | SNAPSHOTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 10 | fi 11 | 12 | # takes a name for snapshot file and string to compare to reference. 13 | # If the snapshot file does not exist, it will be created. 14 | # If the snapshot file does exist, the string will be compared to the snapshot. 15 | # If the string is different, assertion will fail and print the diff. 16 | # For recording new snapshots, use the `SNAPSHOT_RECORD=true` environment variable. 17 | assert_snapshot() { 18 | local snapshot_name="$1" 19 | local snapshot_value="$2" 20 | local snapshot_file="$SNAPSHOTS_DIR/snapshots/$snapshot_name.snapshot" 21 | mkdir -p "$SNAPSHOTS_DIR" 22 | 23 | if [ "$SNAPSHOT_RECORD" = "true" ]; then 24 | echo "Recording snapshot $snapshot_name" >&2 25 | echo "$snapshot_value" > "$snapshot_file" 26 | return 27 | fi 28 | 29 | if [ ! -f "$snapshot_file" ]; then 30 | echo "Snapshot $snapshot_name does not exist. Recording new snapshot." >&2 31 | echo "$snapshot_value" > "$snapshot_file" 32 | return 33 | fi 34 | 35 | local snapshot_diff=$(diff -u "$snapshot_file" <(echo "$snapshot_value")) 36 | if [ -n "$snapshot_diff" ]; then 37 | echo "Snapshot $snapshot_name does not match reference." >&2 38 | echo "$snapshot_diff" 39 | exit 1 40 | fi 41 | 42 | echo "Snapshot $snapshot_name matches reference." >&2 43 | } 44 | -------------------------------------------------------------------------------- /Tests/integration_tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eo pipefail 4 | 5 | TESTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" 6 | 7 | source "$TESTS_DIR/assert.sh" 8 | 9 | # Parse arg: 10 | executable_path="$1" 11 | if [ -z "$executable_path" ]; then 12 | echo "Usage: $0 " 13 | exit 1 14 | fi 15 | 16 | # Prepare test data 17 | 18 | test_data_producer_config="{ 19 | \"chunk_count\": 30, 20 | \"chunk_size\": 3, 21 | \"write_delay\": 10, 22 | }" 23 | test_data_producer_config_file="/tmp/progressline_test_data_producer_config.json" 24 | echo "$test_data_producer_config" > "$test_data_producer_config_file" 25 | 26 | # warmup test data producer 27 | swift "$TESTS_DIR"/test_data_producer.swift $test_data_producer_config_file > /dev/null 28 | 29 | generate_test_output="swift $TESTS_DIR/test_data_producer.swift $test_data_producer_config_file" 30 | 31 | # Test default mode 32 | 33 | output=$($generate_test_output | "$executable_path" --test-mode) 34 | assert_snapshot "default" "$output" 35 | 36 | # Test static text mode 37 | 38 | output=$($generate_test_output | "$executable_path" --test-mode --static-text "Static text") 39 | assert_snapshot "static_text" "$output" 40 | 41 | # Test default mode with save original log 42 | 43 | output=$($generate_test_output | "$executable_path" --test-mode --original-log-path /tmp/progressline_test_original_log.txt) 44 | assert_snapshot "default_with_original_log" "$output" 45 | assert_snapshot "default_with_original_log_original_log" "$(cat /tmp/progressline_test_original_log.txt)" 46 | rm /tmp/progressline_test_original_log.txt 47 | 48 | # Test log matches 49 | 50 | output=$($generate_test_output | "$executable_path" --test-mode --log-matches "Chunk number: \d+[1-5]{1}") 51 | assert_snapshot "log_matches" "$output" 52 | 53 | # Test log all 54 | 55 | output=$($generate_test_output | "$executable_path" --test-mode --log-all) 56 | assert_snapshot "log_all" "$output" 57 | -------------------------------------------------------------------------------- /Tests/snapshots/default.snapshot: -------------------------------------------------------------------------------- 1 | ❯ 2 |  ❯ Chunk number: 1, Chunk Line: 3 3 |  ❯ Chunk number: 2, Chunk Line: 3 4 |  ❯ Chunk number: 3, Chunk Line: 3 5 |  ❯ Chunk number: 4, Chunk Line: 3 6 |  ❯ Chunk number: 5, Chunk Line: 3 7 |  ❯ Chunk number: 6, Chunk Line: 3 8 |  ❯ Chunk number: 7, Chunk Line: 3 9 |  ❯ Chunk number: 8, Chunk Line: 3 10 |  ❯ Chunk number: 9, Chunk Line: 3 11 |  ❯ Chunk number: 10, Chunk Line: 3 12 |  ❯ Chunk number: 11, Chunk Line: 3 13 |  ❯ Chunk number: 12, Chunk Line: 3 14 |  ❯ Chunk number: 13, Chunk Line: 3 15 |  ❯ Chunk number: 14, Chunk Line: 3 16 |  ❯ Chunk number: 15, Chunk Line: 3 17 |  ❯ Chunk number: 16, Chunk Line: 3 18 |  ❯ Chunk number: 17, Chunk Line: 3 19 |  ❯ Chunk number: 18, Chunk Line: 3 20 |  ❯ Chunk number: 19, Chunk Line: 3 21 |  ❯ Chunk number: 20, Chunk Line: 3 22 |  ❯ Chunk number: 21, Chunk Line: 3 23 |  ❯ Chunk number: 22, Chunk Line: 3 24 |  ❯ Chunk number: 23, Chunk Line: 3 25 |  ❯ Chunk number: 24, Chunk Line: 3 26 |  ❯ Chunk number: 25, Chunk Line: 3 27 |  ❯ Chunk number: 26, Chunk Line: 3 28 |  ❯ Chunk number: 27, Chunk Line: 3 29 |  ❯ Chunk number: 28, Chunk Line: 3 30 |  ❯ Chunk number: 29, Chunk Line: 3 31 |  ❯ Chunk number: 30, Chunk Line: 3 32 | ✓ ❯ Chunk number: 30, Chunk Line: 3 33 | -------------------------------------------------------------------------------- /Tests/snapshots/default_with_original_log.snapshot: -------------------------------------------------------------------------------- 1 | ❯ 2 |  ❯ Chunk number: 1, Chunk Line: 3 3 |  ❯ Chunk number: 2, Chunk Line: 3 4 |  ❯ Chunk number: 3, Chunk Line: 3 5 |  ❯ Chunk number: 4, Chunk Line: 3 6 |  ❯ Chunk number: 5, Chunk Line: 3 7 |  ❯ Chunk number: 6, Chunk Line: 3 8 |  ❯ Chunk number: 7, Chunk Line: 3 9 |  ❯ Chunk number: 8, Chunk Line: 3 10 |  ❯ Chunk number: 9, Chunk Line: 3 11 |  ❯ Chunk number: 10, Chunk Line: 3 12 |  ❯ Chunk number: 11, Chunk Line: 3 13 |  ❯ Chunk number: 12, Chunk Line: 3 14 |  ❯ Chunk number: 13, Chunk Line: 3 15 |  ❯ Chunk number: 14, Chunk Line: 3 16 |  ❯ Chunk number: 15, Chunk Line: 3 17 |  ❯ Chunk number: 16, Chunk Line: 3 18 |  ❯ Chunk number: 17, Chunk Line: 3 19 |  ❯ Chunk number: 18, Chunk Line: 3 20 |  ❯ Chunk number: 19, Chunk Line: 3 21 |  ❯ Chunk number: 20, Chunk Line: 3 22 |  ❯ Chunk number: 21, Chunk Line: 3 23 |  ❯ Chunk number: 22, Chunk Line: 3 24 |  ❯ Chunk number: 23, Chunk Line: 3 25 |  ❯ Chunk number: 24, Chunk Line: 3 26 |  ❯ Chunk number: 25, Chunk Line: 3 27 |  ❯ Chunk number: 26, Chunk Line: 3 28 |  ❯ Chunk number: 27, Chunk Line: 3 29 |  ❯ Chunk number: 28, Chunk Line: 3 30 |  ❯ Chunk number: 29, Chunk Line: 3 31 |  ❯ Chunk number: 30, Chunk Line: 3 32 | ✓ ❯ Chunk number: 30, Chunk Line: 3 33 | -------------------------------------------------------------------------------- /Tests/snapshots/default_with_original_log_original_log.snapshot: -------------------------------------------------------------------------------- 1 | Chunk number: 1, Chunk Line: 1 2 | Chunk number: 1, Chunk Line: 2 3 | Chunk number: 1, Chunk Line: 3 4 | Chunk number: 2, Chunk Line: 1 5 | Chunk number: 2, Chunk Line: 2 6 | Chunk number: 2, Chunk Line: 3 7 | Chunk number: 3, Chunk Line: 1 8 | Chunk number: 3, Chunk Line: 2 9 | Chunk number: 3, Chunk Line: 3 10 | Chunk number: 4, Chunk Line: 1 11 | Chunk number: 4, Chunk Line: 2 12 | Chunk number: 4, Chunk Line: 3 13 | Chunk number: 5, Chunk Line: 1 14 | Chunk number: 5, Chunk Line: 2 15 | Chunk number: 5, Chunk Line: 3 16 | Chunk number: 6, Chunk Line: 1 17 | Chunk number: 6, Chunk Line: 2 18 | Chunk number: 6, Chunk Line: 3 19 | Chunk number: 7, Chunk Line: 1 20 | Chunk number: 7, Chunk Line: 2 21 | Chunk number: 7, Chunk Line: 3 22 | Chunk number: 8, Chunk Line: 1 23 | Chunk number: 8, Chunk Line: 2 24 | Chunk number: 8, Chunk Line: 3 25 | Chunk number: 9, Chunk Line: 1 26 | Chunk number: 9, Chunk Line: 2 27 | Chunk number: 9, Chunk Line: 3 28 | Chunk number: 10, Chunk Line: 1 29 | Chunk number: 10, Chunk Line: 2 30 | Chunk number: 10, Chunk Line: 3 31 | Chunk number: 11, Chunk Line: 1 32 | Chunk number: 11, Chunk Line: 2 33 | Chunk number: 11, Chunk Line: 3 34 | Chunk number: 12, Chunk Line: 1 35 | Chunk number: 12, Chunk Line: 2 36 | Chunk number: 12, Chunk Line: 3 37 | Chunk number: 13, Chunk Line: 1 38 | Chunk number: 13, Chunk Line: 2 39 | Chunk number: 13, Chunk Line: 3 40 | Chunk number: 14, Chunk Line: 1 41 | Chunk number: 14, Chunk Line: 2 42 | Chunk number: 14, Chunk Line: 3 43 | Chunk number: 15, Chunk Line: 1 44 | Chunk number: 15, Chunk Line: 2 45 | Chunk number: 15, Chunk Line: 3 46 | Chunk number: 16, Chunk Line: 1 47 | Chunk number: 16, Chunk Line: 2 48 | Chunk number: 16, Chunk Line: 3 49 | Chunk number: 17, Chunk Line: 1 50 | Chunk number: 17, Chunk Line: 2 51 | Chunk number: 17, Chunk Line: 3 52 | Chunk number: 18, Chunk Line: 1 53 | Chunk number: 18, Chunk Line: 2 54 | Chunk number: 18, Chunk Line: 3 55 | Chunk number: 19, Chunk Line: 1 56 | Chunk number: 19, Chunk Line: 2 57 | Chunk number: 19, Chunk Line: 3 58 | Chunk number: 20, Chunk Line: 1 59 | Chunk number: 20, Chunk Line: 2 60 | Chunk number: 20, Chunk Line: 3 61 | Chunk number: 21, Chunk Line: 1 62 | Chunk number: 21, Chunk Line: 2 63 | Chunk number: 21, Chunk Line: 3 64 | Chunk number: 22, Chunk Line: 1 65 | Chunk number: 22, Chunk Line: 2 66 | Chunk number: 22, Chunk Line: 3 67 | Chunk number: 23, Chunk Line: 1 68 | Chunk number: 23, Chunk Line: 2 69 | Chunk number: 23, Chunk Line: 3 70 | Chunk number: 24, Chunk Line: 1 71 | Chunk number: 24, Chunk Line: 2 72 | Chunk number: 24, Chunk Line: 3 73 | Chunk number: 25, Chunk Line: 1 74 | Chunk number: 25, Chunk Line: 2 75 | Chunk number: 25, Chunk Line: 3 76 | Chunk number: 26, Chunk Line: 1 77 | Chunk number: 26, Chunk Line: 2 78 | Chunk number: 26, Chunk Line: 3 79 | Chunk number: 27, Chunk Line: 1 80 | Chunk number: 27, Chunk Line: 2 81 | Chunk number: 27, Chunk Line: 3 82 | Chunk number: 28, Chunk Line: 1 83 | Chunk number: 28, Chunk Line: 2 84 | Chunk number: 28, Chunk Line: 3 85 | Chunk number: 29, Chunk Line: 1 86 | Chunk number: 29, Chunk Line: 2 87 | Chunk number: 29, Chunk Line: 3 88 | Chunk number: 30, Chunk Line: 1 89 | Chunk number: 30, Chunk Line: 2 90 | Chunk number: 30, Chunk Line: 3 91 | -------------------------------------------------------------------------------- /Tests/snapshots/log_all.snapshot: -------------------------------------------------------------------------------- 1 | ❯ 2 | Chunk number: 1, Chunk Line: 1 3 | Chunk number: 1, Chunk Line: 2 4 | Chunk number: 1, Chunk Line: 3 5 | 6 |  ❯ Chunk number: 1, Chunk Line: 3 7 | Chunk number: 2, Chunk Line: 1 8 | Chunk number: 2, Chunk Line: 2 9 | Chunk number: 2, Chunk Line: 3 10 | 11 |  ❯ Chunk number: 2, Chunk Line: 3 12 | Chunk number: 3, Chunk Line: 1 13 | Chunk number: 3, Chunk Line: 2 14 | Chunk number: 3, Chunk Line: 3 15 | 16 |  ❯ Chunk number: 3, Chunk Line: 3 17 | Chunk number: 4, Chunk Line: 1 18 | Chunk number: 4, Chunk Line: 2 19 | Chunk number: 4, Chunk Line: 3 20 | 21 |  ❯ Chunk number: 4, Chunk Line: 3 22 | Chunk number: 5, Chunk Line: 1 23 | Chunk number: 5, Chunk Line: 2 24 | Chunk number: 5, Chunk Line: 3 25 | 26 |  ❯ Chunk number: 5, Chunk Line: 3 27 | Chunk number: 6, Chunk Line: 1 28 | Chunk number: 6, Chunk Line: 2 29 | Chunk number: 6, Chunk Line: 3 30 | 31 |  ❯ Chunk number: 6, Chunk Line: 3 32 | Chunk number: 7, Chunk Line: 1 33 | Chunk number: 7, Chunk Line: 2 34 | Chunk number: 7, Chunk Line: 3 35 | 36 |  ❯ Chunk number: 7, Chunk Line: 3 37 | Chunk number: 8, Chunk Line: 1 38 | Chunk number: 8, Chunk Line: 2 39 | Chunk number: 8, Chunk Line: 3 40 | 41 |  ❯ Chunk number: 8, Chunk Line: 3 42 | Chunk number: 9, Chunk Line: 1 43 | Chunk number: 9, Chunk Line: 2 44 | Chunk number: 9, Chunk Line: 3 45 | 46 |  ❯ Chunk number: 9, Chunk Line: 3 47 | Chunk number: 10, Chunk Line: 1 48 | Chunk number: 10, Chunk Line: 2 49 | Chunk number: 10, Chunk Line: 3 50 | 51 |  ❯ Chunk number: 10, Chunk Line: 3 52 | Chunk number: 11, Chunk Line: 1 53 | Chunk number: 11, Chunk Line: 2 54 | Chunk number: 11, Chunk Line: 3 55 | 56 |  ❯ Chunk number: 11, Chunk Line: 3 57 | Chunk number: 12, Chunk Line: 1 58 | Chunk number: 12, Chunk Line: 2 59 | Chunk number: 12, Chunk Line: 3 60 | 61 |  ❯ Chunk number: 12, Chunk Line: 3 62 | Chunk number: 13, Chunk Line: 1 63 | Chunk number: 13, Chunk Line: 2 64 | Chunk number: 13, Chunk Line: 3 65 | 66 |  ❯ Chunk number: 13, Chunk Line: 3 67 | Chunk number: 14, Chunk Line: 1 68 | Chunk number: 14, Chunk Line: 2 69 | Chunk number: 14, Chunk Line: 3 70 | 71 |  ❯ Chunk number: 14, Chunk Line: 3 72 | Chunk number: 15, Chunk Line: 1 73 | Chunk number: 15, Chunk Line: 2 74 | Chunk number: 15, Chunk Line: 3 75 | 76 |  ❯ Chunk number: 15, Chunk Line: 3 77 | Chunk number: 16, Chunk Line: 1 78 | Chunk number: 16, Chunk Line: 2 79 | Chunk number: 16, Chunk Line: 3 80 | 81 |  ❯ Chunk number: 16, Chunk Line: 3 82 | Chunk number: 17, Chunk Line: 1 83 | Chunk number: 17, Chunk Line: 2 84 | Chunk number: 17, Chunk Line: 3 85 | 86 |  ❯ Chunk number: 17, Chunk Line: 3 87 | Chunk number: 18, Chunk Line: 1 88 | Chunk number: 18, Chunk Line: 2 89 | Chunk number: 18, Chunk Line: 3 90 | 91 |  ❯ Chunk number: 18, Chunk Line: 3 92 | Chunk number: 19, Chunk Line: 1 93 | Chunk number: 19, Chunk Line: 2 94 | Chunk number: 19, Chunk Line: 3 95 | 96 |  ❯ Chunk number: 19, Chunk Line: 3 97 | Chunk number: 20, Chunk Line: 1 98 | Chunk number: 20, Chunk Line: 2 99 | Chunk number: 20, Chunk Line: 3 100 | 101 |  ❯ Chunk number: 20, Chunk Line: 3 102 | Chunk number: 21, Chunk Line: 1 103 | Chunk number: 21, Chunk Line: 2 104 | Chunk number: 21, Chunk Line: 3 105 | 106 |  ❯ Chunk number: 21, Chunk Line: 3 107 | Chunk number: 22, Chunk Line: 1 108 | Chunk number: 22, Chunk Line: 2 109 | Chunk number: 22, Chunk Line: 3 110 | 111 |  ❯ Chunk number: 22, Chunk Line: 3 112 | Chunk number: 23, Chunk Line: 1 113 | Chunk number: 23, Chunk Line: 2 114 | Chunk number: 23, Chunk Line: 3 115 | 116 |  ❯ Chunk number: 23, Chunk Line: 3 117 | Chunk number: 24, Chunk Line: 1 118 | Chunk number: 24, Chunk Line: 2 119 | Chunk number: 24, Chunk Line: 3 120 | 121 |  ❯ Chunk number: 24, Chunk Line: 3 122 | Chunk number: 25, Chunk Line: 1 123 | Chunk number: 25, Chunk Line: 2 124 | Chunk number: 25, Chunk Line: 3 125 | 126 |  ❯ Chunk number: 25, Chunk Line: 3 127 | Chunk number: 26, Chunk Line: 1 128 | Chunk number: 26, Chunk Line: 2 129 | Chunk number: 26, Chunk Line: 3 130 | 131 |  ❯ Chunk number: 26, Chunk Line: 3 132 | Chunk number: 27, Chunk Line: 1 133 | Chunk number: 27, Chunk Line: 2 134 | Chunk number: 27, Chunk Line: 3 135 | 136 |  ❯ Chunk number: 27, Chunk Line: 3 137 | Chunk number: 28, Chunk Line: 1 138 | Chunk number: 28, Chunk Line: 2 139 | Chunk number: 28, Chunk Line: 3 140 | 141 |  ❯ Chunk number: 28, Chunk Line: 3 142 | Chunk number: 29, Chunk Line: 1 143 | Chunk number: 29, Chunk Line: 2 144 | Chunk number: 29, Chunk Line: 3 145 | 146 |  ❯ Chunk number: 29, Chunk Line: 3 147 | Chunk number: 30, Chunk Line: 1 148 | Chunk number: 30, Chunk Line: 2 149 | Chunk number: 30, Chunk Line: 3 150 | 151 |  ❯ Chunk number: 30, Chunk Line: 3 152 | ✓ ❯ Chunk number: 30, Chunk Line: 3 153 | -------------------------------------------------------------------------------- /Tests/snapshots/log_matches.snapshot: -------------------------------------------------------------------------------- 1 | ❯ 2 |  ❯ Chunk number: 1, Chunk Line: 3 3 |  ❯ Chunk number: 2, Chunk Line: 3 4 |  ❯ Chunk number: 3, Chunk Line: 3 5 |  ❯ Chunk number: 4, Chunk Line: 3 6 |  ❯ Chunk number: 5, Chunk Line: 3 7 |  ❯ Chunk number: 6, Chunk Line: 3 8 |  ❯ Chunk number: 7, Chunk Line: 3 9 |  ❯ Chunk number: 8, Chunk Line: 3 10 |  ❯ Chunk number: 9, Chunk Line: 3 11 |  ❯ Chunk number: 10, Chunk Line: 3 12 | Chunk number: 11, Chunk Line: 1 13 | 14 | Chunk number: 11, Chunk Line: 2 15 | 16 | Chunk number: 11, Chunk Line: 3 17 | 18 |  ❯ Chunk number: 11, Chunk Line: 3 19 | Chunk number: 12, Chunk Line: 1 20 | 21 | Chunk number: 12, Chunk Line: 2 22 | 23 | Chunk number: 12, Chunk Line: 3 24 | 25 |  ❯ Chunk number: 12, Chunk Line: 3 26 | Chunk number: 13, Chunk Line: 1 27 | 28 | Chunk number: 13, Chunk Line: 2 29 | 30 | Chunk number: 13, Chunk Line: 3 31 | 32 |  ❯ Chunk number: 13, Chunk Line: 3 33 | Chunk number: 14, Chunk Line: 1 34 | 35 | Chunk number: 14, Chunk Line: 2 36 | 37 | Chunk number: 14, Chunk Line: 3 38 | 39 |  ❯ Chunk number: 14, Chunk Line: 3 40 | Chunk number: 15, Chunk Line: 1 41 | 42 | Chunk number: 15, Chunk Line: 2 43 | 44 | Chunk number: 15, Chunk Line: 3 45 | 46 |  ❯ Chunk number: 15, Chunk Line: 3 47 |  ❯ Chunk number: 16, Chunk Line: 3 48 |  ❯ Chunk number: 17, Chunk Line: 3 49 |  ❯ Chunk number: 18, Chunk Line: 3 50 |  ❯ Chunk number: 19, Chunk Line: 3 51 |  ❯ Chunk number: 20, Chunk Line: 3 52 | Chunk number: 21, Chunk Line: 1 53 | 54 | Chunk number: 21, Chunk Line: 2 55 | 56 | Chunk number: 21, Chunk Line: 3 57 | 58 |  ❯ Chunk number: 21, Chunk Line: 3 59 | Chunk number: 22, Chunk Line: 1 60 | 61 | Chunk number: 22, Chunk Line: 2 62 | 63 | Chunk number: 22, Chunk Line: 3 64 | 65 |  ❯ Chunk number: 22, Chunk Line: 3 66 | Chunk number: 23, Chunk Line: 1 67 | 68 | Chunk number: 23, Chunk Line: 2 69 | 70 | Chunk number: 23, Chunk Line: 3 71 | 72 |  ❯ Chunk number: 23, Chunk Line: 3 73 | Chunk number: 24, Chunk Line: 1 74 | 75 | Chunk number: 24, Chunk Line: 2 76 | 77 | Chunk number: 24, Chunk Line: 3 78 | 79 |  ❯ Chunk number: 24, Chunk Line: 3 80 | Chunk number: 25, Chunk Line: 1 81 | 82 | Chunk number: 25, Chunk Line: 2 83 | 84 | Chunk number: 25, Chunk Line: 3 85 | 86 |  ❯ Chunk number: 25, Chunk Line: 3 87 |  ❯ Chunk number: 26, Chunk Line: 3 88 |  ❯ Chunk number: 27, Chunk Line: 3 89 |  ❯ Chunk number: 28, Chunk Line: 3 90 |  ❯ Chunk number: 29, Chunk Line: 3 91 |  ❯ Chunk number: 30, Chunk Line: 3 92 | ✓ ❯ Chunk number: 30, Chunk Line: 3 93 | -------------------------------------------------------------------------------- /Tests/snapshots/static_text.snapshot: -------------------------------------------------------------------------------- 1 | ❯ Static text 2 |  ❯ Static text 3 |  ❯ Static text 4 |  ❯ Static text 5 |  ❯ Static text 6 |  ❯ Static text 7 |  ❯ Static text 8 |  ❯ Static text 9 |  ❯ Static text 10 |  ❯ Static text 11 |  ❯ Static text 12 |  ❯ Static text 13 |  ❯ Static text 14 |  ❯ Static text 15 |  ❯ Static text 16 |  ❯ Static text 17 |  ❯ Static text 18 |  ❯ Static text 19 |  ❯ Static text 20 |  ❯ Static text 21 |  ❯ Static text 22 |  ❯ Static text 23 |  ❯ Static text 24 |  ❯ Static text 25 |  ❯ Static text 26 |  ❯ Static text 27 |  ❯ Static text 28 |  ❯ Static text 29 |  ❯ Static text 30 |  ❯ Static text 31 |  ❯ Static text 32 | ✓ ❯ Static text 33 | -------------------------------------------------------------------------------- /Tests/test_data_producer.swift: -------------------------------------------------------------------------------- 1 | /* 2 | Generates test data for the app. 3 | Features: 4 | - iterates over the given number and prints the iteration number 5 | - prints with a delay if the delay is greater than 0 6 | - can produce a given number of lines per iteration 7 | Usage: 8 | test_data_producer.swift [] [] 9 | */ 10 | 11 | import Foundation 12 | 13 | struct Configuration: Decodable { 14 | let chunkCount: Int 15 | let chunkSize: Int 16 | let writeDelay: TimeInterval 17 | 18 | enum CodingKeys: String, CodingKey { 19 | case chunkCount = "chunk_count" 20 | case chunkSize = "chunk_size" 21 | case writeDelay = "write_delay" 22 | } 23 | } 24 | 25 | let arguments = CommandLine.arguments.dropFirst() 26 | guard arguments.count >= 1 else { 27 | print("Usage: \(CommandLine.arguments.first!) ") 28 | exit(1) 29 | } 30 | 31 | let filePath = arguments.first! 32 | let configuration = try JSONDecoder().decode(Configuration.self, from: try Data(contentsOf: URL(fileURLWithPath: filePath))) 33 | 34 | for i in 0 ..< configuration.chunkCount { 35 | let data = (0 ..< configuration.chunkSize) 36 | .map { "Chunk number: \(i + 1), Chunk Line: \($0 + 1)" } 37 | .joined(separator: "\n") 38 | print(data) 39 | fflush(stdout) 40 | if configuration.writeDelay > 0 { 41 | usleep(useconds_t(configuration.writeDelay * 1000)) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | # git-cliff ~ configuration file 2 | # https://git-cliff.org/docs/configuration 3 | 4 | [remote.github] 5 | owner = "kattouf" 6 | repo = "ProgressLine" 7 | # token = "" 8 | 9 | [changelog] 10 | # changelog header 11 | header = """ 12 | # Changelog\n 13 | """ 14 | # template for the changelog body 15 | # https://keats.github.io/tera/docs/#introduction 16 | body = """ 17 | {% if version %}\ 18 | {% if previous.version %}\ 19 | ## [{{ version | trim_start_matches(pat="v") }}]({{ self::remote_url() }}/compare/{{ previous.version }}..{{ version }}) - {{ timestamp | date(format="%Y-%m-%d") }} 20 | {% else %}\ 21 | ## {{ version | trim_start_matches(pat="v") }} - {{ timestamp | date(format="%Y-%m-%d") }} 22 | {% endif %}\ 23 | {% else %}\ 24 | ## Unreleased 25 | {% endif %}\ 26 | {% for group, commits in commits | group_by(attribute="group") %} 27 | ### {{ group | striptags | trim | upper_first }} 28 | 29 | {% for commit in commits 30 | | filter(attribute="scope") 31 | | sort(attribute="scope") -%} 32 | {% if commit.scope -%} 33 | * {{self::commit(commit=commit)}}\ 34 | {% endif -%} 35 | {% endfor -%} 36 | {% for commit in commits -%} 37 | {% if commit.scope -%} 38 | {% else -%} 39 | * {{self::commit(commit=commit)}}\ 40 | {% endif -%} 41 | {% endfor -%} 42 | {% endfor %} 43 | 44 | {%- if github -%} 45 | {% if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %} 46 | {% raw %}\n{% endraw -%} 47 | ## New Contributors 48 | {%- endif %}\ 49 | {% for contributor in github.contributors | filter(attribute="is_first_time", value=true) %} 50 | * @{{ contributor.username }} made their first contribution 51 | {%- if contributor.pr_number %} in \ 52 | [#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \ 53 | {%- endif %} 54 | {%- endfor -%} 55 | {%- endif -%} 56 | 57 | {% if version %} 58 | {% if previous.version %} 59 | **Full Changelog**: {{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }} 60 | {% endif %} 61 | {% else -%} 62 | {% raw %}\n{% endraw %} 63 | {% endif %} 64 | 65 | {%- macro remote_url() -%} 66 | https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} 67 | {%- endmacro -%} 68 | 69 | {% macro commit(commit) -%} 70 | {% if commit.scope %}**({{commit.scope}})** {% endif -%} 71 | {% if commit.breaking %}**breaking** {% endif -%} 72 | {{ commit.message | split(pat="\n") | first | trim }} by \ 73 | {% if commit.remote.username %}[@{{commit.remote.username}}](https://github.com/{{commit.remote.username}})\ 74 | {% else %}{{commit.author.name}}{% endif %} in \ 75 | {% if commit.remote.pr_number %}[#{{ commit.remote.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.remote.pr_number }})\ 76 | {% else %}[{{ commit.id | truncate(length=7, end="") }}]({{ self::remote_url() }}/commit/{{ commit.id }})\ 77 | {%- endif %} 78 | {% endmacro commit -%} 79 | """ 80 | # template for the changelog footer 81 | footer = """ 82 | 83 | """ 84 | # remove the leading and trailing whitespace from the template 85 | trim = true 86 | # postprocessors 87 | postprocessors = [] 88 | 89 | [git] 90 | # parse the commits based on https://www.conventionalcommits.org 91 | conventional_commits = true 92 | # filter out the commits that are not conventional 93 | filter_unconventional = false 94 | # process each line of a commit as an individual commit 95 | split_commits = false 96 | # regex for preprocessing the commit messages 97 | commit_preprocessors = [ 98 | # remove issue numbers from commits 99 | { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "" }, 100 | ] 101 | # regex for parsing and grouping commits 102 | commit_parsers = [ 103 | { message = '^chore\(release\): Bump version to', skip = true }, 104 | { message = '^(chore|fix)\(deps\):', group = ":package: Dependency Updates", scope = "" }, 105 | { message = '^feat', group = ":rocket: Features" }, 106 | { message = '^fix', group = ":bug: Bug Fixes" }, 107 | { message = '^test', group = ":test_tube: Testing" }, 108 | { message = '^perf', group = ":zap: Performance" }, 109 | { message = '^refactor', group = ":tractor: Refactoring" }, 110 | { message = '^doc', group = ":books: Documentation" }, 111 | { body = '.*security', group = ":shield: Security" }, 112 | { message = '^project', group = ":file_folder: Project" }, 113 | { message = '^revert', group = ":leftwards_arrow_with_hook: Revert" }, 114 | { message = '.', group = ":card_index_dividers: Other Changes" }, 115 | ] 116 | # filter out the commits that are not matched by commit parsers 117 | filter_commits = false 118 | # sort the tags topologically 119 | topo_order = false 120 | # sort the commits inside sections by oldest/newest order 121 | sort_commits = "newest" 122 | --------------------------------------------------------------------------------