├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── Documentation ├── config.md ├── only_xcode.md └── with_swiftpm.md ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── Komondor │ ├── Commands │ ├── install.swift │ ├── runner.swift │ └── uninstall.swift │ ├── Installation │ ├── renderScript.swift │ └── renderScriptHeader.swift │ ├── Utils │ ├── Edited.swift │ └── Logger.swift │ └── main.swift └── komondor.jpg /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["vknabel.vscode-swiftformat"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "swiftlint.enable": true 4 | } 5 | -------------------------------------------------------------------------------- /Documentation/config.md: -------------------------------------------------------------------------------- 1 | ### Available options 2 | 3 | Today you can hook into all of the following git-hooks: 4 | 5 | ```swift 6 | let hookList = [ 7 | "applypatch-msg", 8 | "pre-applypatch", 9 | "post-applypatch", 10 | "pre-commit", 11 | "prepare-commit-msg", 12 | "commit-msg", 13 | "post-commit", 14 | "pre-rebase", 15 | "post-checkout", 16 | "post-merge", 17 | "pre-push", 18 | "pre-receive", 19 | "update", 20 | "post-receive", 21 | "post-update", 22 | "push-to-checkout", 23 | "pre-auto-gc", 24 | "post-rewrite", 25 | "sendemail-validate", 26 | ] 27 | ``` 28 | 29 | These are all keys you can use in the config setting: 30 | 31 | ```swift 32 | #if canImport(PackageConfig) 33 | import PackageConfig 34 | 35 | let config = PackageConfiguration([ 36 | "komondor": [ 37 | "pre-commit": ["swift test", "swift run swiftFormat .", "git add ."], 38 | "pre-push": ["swift test", "swift run danger-swift local", "swift run swiftlint"] 39 | ], 40 | ]) 41 | #endif 42 | ``` 43 | 44 | The values can be either a single string, or an array of strings. Each command is executed sequentially. 45 | -------------------------------------------------------------------------------- /Documentation/only_xcode.md: -------------------------------------------------------------------------------- 1 | ### Xcode only install 2 | 3 | Whoah, so, welcome to using Swift Package Manager (SwiftPM). Given work in SwiftPM has so far been focused on 4 | server-side Swift, it's not really had wide-spread adoption, but a lot of the useful third-party eco-system tools 5 | support it: 6 | 7 | - [Danger Swift](https://github.com/danger/swift) 8 | - [SwiftLint](https://github.com/realm/swiftlint) 9 | - [SwiftFormat](https://github.com/nicklockwood/SwiftFormat) 10 | - [Sourcery](https://github.com/krzysztofzablocki/Sourcery) 11 | - [SwiftGen](https://github.com/SwiftGen/SwiftGen) 12 | 13 | and... well... 14 | 15 | - [Komondor](https://github.com/shibapm/Komondor) 16 | 17 | Maybe there's enough support that now is the time to move these sort of dependencies into a package manager. 18 | 19 | ### Getting started 20 | 21 | To get started, you need to be on a Mac with Xcode 10+ installed. This means you already have Swift 22 | Package Manager available. 23 | 24 | The `Package.swift` file is basically the same thing as a Podfile + Podspec combined, it combines all of 25 | your app and tooling dependencies in one places. 26 | 27 | Because we're not going to be using Swift PM for building apps/libraries/etc then the default template isn't 28 | useful for us. 29 | 30 | Here's what it looks like in the Artsy app, Eigen: 31 | 32 | ```swift 33 | // swift-tools-version:4.2 34 | // The swift-tools-version declares the minimum version of Swift required to build this package. 35 | 36 | import PackageDescription 37 | 38 | let package = Package( 39 | name: "[eigen]", 40 | dependencies: [ 41 | .package(url: "https://github.com/shibapm/Komondor.git", from: "1.0.0") 42 | ], 43 | targets: [ 44 | // This is just an arbitrary Swift file in the app, that has 45 | // no dependencies outside of Foundation 46 | .target(name: "eigen", dependencies: [], path: "Artsy", sources: ["Stringify.swift"]), 47 | ] 48 | ) 49 | ``` 50 | 51 | > **Note**: See that _target_? You must have a target (right now) in your `Package.swift` in order to use 52 | > tools that come from your dependencies. Find a single source file that you can call the package with, using 53 | > the `path:` to find its folder, and then `sources:` to set up that one target. 54 | 55 | This adds `Komondor` to the app, and allows you to run the CLI for Komondor, you can verify by running 56 | `swift run komondor` and seeing the help message. 57 | 58 | Next up: adding your git hooks to the config: 59 | 60 | ```diff 61 | .package(url: "https://github.com/shibapm/Komondor.git", from: "1.0.0") 62 | ] 63 | ) 64 | 65 | + #if canImport(PackageConfig) 66 | + import PackageConfig 67 | + 68 | + let config = PackageConfiguration([ 69 | + "komondor": [ 70 | + "pre-commit": "echo 'Hi'" 71 | + ], 72 | + ]) 73 | + #endif 74 | ``` 75 | 76 | This config is `"[git hook]": ["command"]`, you can read [more here](./config.md). 77 | 78 | Final step: run `swift run komondor install`, this will set up your git-hooks. If you `git add .` and 79 | `git commit -m "Added Komondor"` to the app, it will run the git-hooks and echo "Hi" to the terminal. 80 | 81 | ### What now? 82 | 83 | Improve your docs, for developers setting up your app for the first time, they will need to run: 84 | 85 | ```diff 86 | git clone https://github.com/my/app 87 | cd app 88 | 89 | bundle install 90 | bundle exec pod install 91 | + swift run komondor install 92 | ``` 93 | -------------------------------------------------------------------------------- /Documentation/with_swiftpm.md: -------------------------------------------------------------------------------- 1 | ### Already using SwiftPM 2 | 3 | 1. Add the dependency to your `Package.swift`. 4 | 2. Set up the git-hooks for `komondor` via [`PackageConfig`](https://github.com/shibapm/PackageConfig#packageconfig) 5 | 6 | ```diff 7 | // swift-tools-version:4.2 8 | 9 | import PackageDescription 10 | 11 | let package = Package( 12 | name: "Komondor", 13 | products: [ ... ], 14 | dependencies: [ 15 | // My dependencies 16 | .package(url: "https://github.com/shibapm/PackageConfig.git", from: "1.0.0"), 17 | // Dev deps 18 | + .package(url: "https://github.com/shibapm/Komondor.git", from: "1.0.0"), 19 | .package(url: "https://github.com/nicklockwood/SwiftFormat.git", from: "0.35.8"), 20 | ], 21 | targets: [...] 22 | ) 23 | 24 | + #if canImport(PackageConfig) 25 | + import PackageConfig 26 | + 27 | + let config = PackageConfiguration([ 28 | + "komondor": [ 29 | + "pre-commit": ["swift test", "swift run swiftFormat .", "git add ."], 30 | + "pre-push": "swift test" 31 | + ], 32 | + ]) 33 | + #endif 34 | ``` 35 | 36 | You can get more information on the [config here](./config.md). 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Orta Therox 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 | "object": { 3 | "pins": [ 4 | { 5 | "package": "Logger", 6 | "repositoryURL": "https://github.com/shibapm/Logger", 7 | "state": { 8 | "branch": null, 9 | "revision": "53c3ecca5abe8cf46697e33901ee774236d94cce", 10 | "version": "0.2.3" 11 | } 12 | }, 13 | { 14 | "package": "PackageConfig", 15 | "repositoryURL": "https://github.com/shibapm/PackageConfig.git", 16 | "state": { 17 | "branch": null, 18 | "revision": "7081db0a7ad0ce6002115944c26c915167dc0617", 19 | "version": "1.1.2" 20 | } 21 | }, 22 | { 23 | "package": "Rocket", 24 | "repositoryURL": "https://github.com/f-meloni/Rocket", 25 | "state": { 26 | "branch": null, 27 | "revision": "9880a5beb7fcb9e61ddd5764edc1700b8c418deb", 28 | "version": "1.2.1" 29 | } 30 | }, 31 | { 32 | "package": "ShellOut", 33 | "repositoryURL": "https://github.com/JohnSundell/ShellOut.git", 34 | "state": { 35 | "branch": null, 36 | "revision": "d3d54ce662dfee7fef619330b71d251b8d4869f9", 37 | "version": "2.2.0" 38 | } 39 | }, 40 | { 41 | "package": "SourceKitten", 42 | "repositoryURL": "https://github.com/jpsim/SourceKitten.git", 43 | "state": { 44 | "branch": null, 45 | "revision": "7f4be006fe73211b0fd9666c73dc2f2303ffa756", 46 | "version": "0.31.0" 47 | } 48 | }, 49 | { 50 | "package": "swift-argument-parser", 51 | "repositoryURL": "https://github.com/apple/swift-argument-parser.git", 52 | "state": { 53 | "branch": null, 54 | "revision": "9564d61b08a5335ae0a36f789a7d71493eacadfc", 55 | "version": "0.3.2" 56 | } 57 | }, 58 | { 59 | "package": "SwiftFormat", 60 | "repositoryURL": "https://github.com/nicklockwood/SwiftFormat.git", 61 | "state": { 62 | "branch": null, 63 | "revision": "8b3e855384f15847915fa3a02019185ee7515107", 64 | "version": "0.40.8" 65 | } 66 | }, 67 | { 68 | "package": "SwiftLint", 69 | "repositoryURL": "https://github.com/Realm/SwiftLint.git", 70 | "state": { 71 | "branch": null, 72 | "revision": "e820e750b08bd67bc9d98f4817868e9bc3d5d865", 73 | "version": "0.44.0" 74 | } 75 | }, 76 | { 77 | "package": "SwiftShell", 78 | "repositoryURL": "https://github.com/kareman/SwiftShell", 79 | "state": { 80 | "branch": null, 81 | "revision": "a6014fe94c3dbff0ad500e8da4f251a5d336530b", 82 | "version": "5.1.0-beta.1" 83 | } 84 | }, 85 | { 86 | "package": "SwiftyTextTable", 87 | "repositoryURL": "https://github.com/scottrhoyt/SwiftyTextTable.git", 88 | "state": { 89 | "branch": null, 90 | "revision": "c6df6cf533d120716bff38f8ff9885e1ce2a4ac3", 91 | "version": "0.9.0" 92 | } 93 | }, 94 | { 95 | "package": "SWXMLHash", 96 | "repositoryURL": "https://github.com/drmohundro/SWXMLHash.git", 97 | "state": { 98 | "branch": null, 99 | "revision": "9183170d20857753d4f331b0ca63f73c60764bf3", 100 | "version": "5.0.2" 101 | } 102 | }, 103 | { 104 | "package": "Yams", 105 | "repositoryURL": "https://github.com/jpsim/Yams", 106 | "state": { 107 | "branch": null, 108 | "revision": "9ff1cc9327586db4e0c8f46f064b6a82ec1566fa", 109 | "version": "4.0.6" 110 | } 111 | } 112 | ] 113 | }, 114 | "version": 1 115 | } 116 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:4.2 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Komondor", 7 | products: [ 8 | .executable(name: "komondor", targets: ["Komondor"]), 9 | ], 10 | dependencies: [ 11 | // User deps 12 | .package(url: "https://github.com/shibapm/PackageConfig.git", .upToNextMajor(from: "1.0.1")), 13 | .package(url: "https://github.com/JohnSundell/ShellOut.git", from: "2.1.0"), 14 | // Dev deps 15 | .package(url: "https://github.com/nicklockwood/SwiftFormat.git", from: "0.35.8"), // dev 16 | .package(url: "https://github.com/Realm/SwiftLint.git", from: "0.28.1"), // dev 17 | .package(url: "https://github.com/shibapm/Rocket", from: "1.2.1"), // dev 18 | ], 19 | targets: [ 20 | .target( 21 | name: "Komondor", 22 | dependencies: ["PackageConfig", "ShellOut"] 23 | ), 24 | ] 25 | ) 26 | 27 | #if canImport(PackageConfig) 28 | import PackageConfig 29 | 30 | let config = PackageConfiguration([ 31 | "komondor": [ 32 | "pre-push": "swift build", 33 | "pre-commit": [ 34 | "swift build", 35 | "swift run swiftformat .", 36 | "swift run swiftlint autocorrect --path Sources/", 37 | "git add .", 38 | ], 39 | ], 40 | "rocket": [ 41 | "after": [ 42 | "push", 43 | ], 44 | ], 45 | ]).write() 46 | #endif 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | # Komondor 4 | 5 | Git Hook automation for Swift and Xcode projects. A port of [Husky](https://github.com/typicode/husky) to Swift. 6 | 7 | ### TL:DR 8 | 9 | 1. Add or amend a `Package.swift` 10 | 2. Add this dependency `.package(url: "https://github.com/shibapm/Komondor.git", from: "1.0.0"),` 11 | 3. Run the install command: `swift run komondor install` 12 | 4. Add a config section to your [`Package.swift`](https://github.com/shibapm/Komondor/blob/master/Package.swift) 13 | 14 | Then you'll get git-hooks consolidated and centralized so that everyone can work with the same tooling. 15 | 16 | ### Why? 17 | 18 | > If you care about something, you should automate it. 19 | 20 | Git Hooks like what Komondor provides gives you more surface area for per-project automation. Komondor provides 21 | an easily understood way to see how all the git automation touch-points in your project will come together. These 22 | hooks allow for much faster feedback during development and let different team-members to use different tools 23 | but still have the same bar of quality. 24 | 25 | For example, adding [SwiftFormat](https://github.com/nicklockwood/SwiftFormat) to your `pre-commit` hook means that 26 | no-one will ever need to discuss formatting in code review again. Perfect. It won't slow down your Xcode builds, 27 | because it lives outside of your project and you can verify it on CI if you'd like to be 100% that everyone conforms. 28 | 29 | Another example, running tests before pushing - this means you don't have to come back 10-15m later once CI has told 30 | you that you have a failing test. This moves more validation to a point where you are still in-context. 31 | 32 | ### An Example 33 | 34 | This is from [the repo](https://github.com/shibapm/Komondor/blob/master/Package.swift) you're looking at: 35 | 36 | ```swift 37 | #if canImport(PackageConfig) 38 | import PackageConfig 39 | 40 | let config = PackageConfiguration([ 41 | "komondor": [ 42 | "pre-push": "swift test", 43 | "pre-commit": [ 44 | "swift test", 45 | "swift run swiftformat .", 46 | "swift run swiftlint autocorrect --path Sources/", 47 | "git add .", 48 | ], 49 | ], 50 | ]).write() 51 | #endif 52 | ``` 53 | 54 | See more about the [config here](./Documentation/config.md). 55 | 56 | ### Getting Set up 57 | 58 | | [On a SwiftPM project](Documentation/with_swiftpm.md) | [On an Xcode Project](Documentation/only_xcode.md) | 59 | | ----------------------------------------------------- | -------------------------------------------------- | 60 | 61 | ### Deployment 62 | 63 | Use `swift run rocket [patch]` 64 | -------------------------------------------------------------------------------- /Sources/Komondor/Commands/install.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import ShellOut 3 | 4 | /// The available hooks for git 5 | /// 6 | public enum Hooks: String, CaseIterable { 7 | case applyPatchMsg = "applypatch-msg" 8 | case preApplyPatch = "pre-applypatch" 9 | case postApplyPatch = "post-applypatch" 10 | case preCommit = "pre-commit" 11 | case prepareCommitMsg = "prepare-commit-msg" 12 | case commitMsg = "commit-msg" 13 | case postCommit = "post-commit" 14 | case preRebase = "pre-rebase" 15 | case postCheckout = "post-checkout" 16 | case postMerge = "post-merge" 17 | case prePush = "pre-push" 18 | case preReceive = "pre-receive" 19 | case update 20 | case postReceive = "post-receive" 21 | case postUpdate = "post-update" 22 | case pushToCheckout = "push-to-checkout" 23 | case preAutoGc = "pre-auto-gc" 24 | case postRewrite = "post-rewrite" 25 | case sendEmailValidate = "sendemail-validate" 26 | } 27 | 28 | public let hookList: [Hooks] = [ 29 | .applyPatchMsg, 30 | .preApplyPatch, 31 | .postApplyPatch, 32 | .preCommit, 33 | .prepareCommitMsg, 34 | .preRebase, 35 | .postCheckout, 36 | .postMerge, 37 | .prePush, 38 | .preReceive, 39 | .update, 40 | .postReceive, 41 | .postUpdate, 42 | .pushToCheckout, 43 | .preAutoGc, 44 | .postRewrite, 45 | .sendEmailValidate 46 | ] 47 | 48 | let skippableHooks: [Hooks] = [ 49 | .commitMsg, 50 | .preCommit, 51 | .preRebase, 52 | .prePush 53 | ] 54 | 55 | public func install(hooks: [Hooks] = hookList, logger _: Logger) throws { 56 | // Add a skip env var 57 | let env = ProcessInfo.processInfo.environment 58 | if env["SKIP_KOMONDOR"] != nil { 59 | logger.logInfo("Skipping Komondor integration due to SKIP_KOMONDOR being set") 60 | return 61 | } 62 | 63 | // Check for CI 64 | if env["CI"] != nil { 65 | logger.logInfo("Skipping Komondor integration due to CI being set") 66 | return 67 | } 68 | 69 | // Validate we're in a git repo 70 | do { 71 | try shellOut(to: "git remote") 72 | } catch { 73 | logger.logError("[Komondor] Can only install git-hooks into a git repo.") 74 | exit(1) 75 | } 76 | 77 | let fileManager = FileManager.default 78 | 79 | // Find the .git root 80 | let gitRootString = try shellOut(to: "git rev-parse --git-dir").trimmingCharacters(in: .whitespaces) 81 | logger.debug("Found git root at: \(gitRootString)") 82 | 83 | // Find or create the hooks dir in the .git folder 84 | var hooksRoot = URL(fileURLWithPath: gitRootString) 85 | hooksRoot.appendPathComponent("hooks", isDirectory: true) 86 | 87 | if !fileManager.fileExists(atPath: hooksRoot.path) { 88 | logger.debug("Making the hooks dir") 89 | try shellOut(to: .createFolder(named: hooksRoot.path)) 90 | } 91 | 92 | // Relative path to folder containing Package.swift 93 | let topLevelString = try shellOut(to: "git rev-parse --show-toplevel").trimmingCharacters(in: .whitespaces) 94 | let cwd = fileManager.currentDirectoryPath 95 | let swiftPackagePrefix: String? 96 | if cwd.hasPrefix(topLevelString), cwd != topLevelString { 97 | swiftPackagePrefix = "." + cwd.dropFirst(topLevelString.count) 98 | } else { 99 | swiftPackagePrefix = nil 100 | } 101 | 102 | // Copy in the komondor templates 103 | let hooksToInstall = hooks.isEmpty ? hookList : hooks 104 | try hooksToInstall.map(\.rawValue).forEach { hookName in 105 | let hookPath = hooksRoot.appendingPathComponent(hookName) 106 | 107 | // Separate header from script so we can 108 | // update if the script updates 109 | let header = renderScriptHeader(hookName) 110 | let script = renderScript(hookName, swiftPackagePrefix) 111 | let hook = header + script 112 | 113 | // This is the same permissions that husky uses 114 | let execAttribute: [FileAttributeKey: Any] = [ 115 | .posixPermissions: 0o755 116 | ] 117 | 118 | // Create it if it's not there 119 | if !fileManager.fileExists(atPath: hookPath.path) { 120 | if fileManager.createFile(atPath: hookPath.path, contents: hook.data(using: .utf8), attributes: execAttribute) { 121 | logger.debug("Added the hook: \(hookName)") 122 | } else { 123 | logger.logError("Could not add the hook: \(hookName)") 124 | } 125 | } else { 126 | // Check if the script part has had an update since last running install 127 | let existingFileData = try Data(contentsOf: hookPath, options: []) 128 | let content = String(data: existingFileData, encoding: .utf8)! 129 | 130 | if content.contains(script) { 131 | logger.debug("Skipped the hook: \(hookName)") 132 | } else { 133 | logger.debug("Updating the hook: \(hookName)") 134 | fileManager.createFile(atPath: hookPath.path, contents: hook.data(using: .utf8), attributes: execAttribute) 135 | } 136 | } 137 | } 138 | print("[Komondor] git-hooks installed" + (hooks.isEmpty ? "" : ": \(hooks.map(\.rawValue))")) 139 | } 140 | -------------------------------------------------------------------------------- /Sources/Komondor/Commands/runner.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import PackageConfig 3 | import ShellOut 4 | 5 | // To emulate running the command as the script would do: 6 | // 7 | // swift run komondor run [hook-name] 8 | // 9 | // 10 | public func runner(logger _: Logger, args: [String]) throws { 11 | let pkgConfig = try PackageConfiguration.load() 12 | 13 | guard let hook = args.first else { 14 | logger.logError("[Komondor] The runner was called without a hook") 15 | exit(1) 16 | } 17 | 18 | guard let config = pkgConfig["komondor"] as? [String: Any] else { 19 | logger.logError("[Komondor] Could not find komondor settings inside the Package.swift") 20 | exit(1) 21 | } 22 | 23 | if let hookOptions = config[hook] { 24 | var commands: [String] = [] 25 | if let stringOption = hookOptions as? String { 26 | commands = [stringOption] 27 | } else if let arrayOptions = hookOptions as? [String] { 28 | commands = arrayOptions 29 | } 30 | 31 | logger.debug("Running commands for komondor \(commands.joined())") 32 | let stagedFiles = try getStagedFiles() 33 | 34 | do { 35 | try commands.forEach { command in 36 | print("[Komondor] > \(hook) \(command)") 37 | let gitParams = Array(args.dropFirst()) 38 | let expandedCommand = expandEdited(forCommand: command, withFiles: stagedFiles) 39 | 40 | // Exporting git hook input params as shell env var GIT_PARAMS 41 | let cmd = "export GIT_PARAMS=\(gitParams.joined(separator: " ")) ; \(expandedCommand)" 42 | // Simple is fine for now 43 | print(try shellOut(to: cmd)) 44 | // Ideal: 45 | // Store STDOUT and STDERR, and only show it if it fails 46 | // Show a stepper like system of all commands 47 | } 48 | } catch let error as ShellOutError { 49 | print(error.message) 50 | print(error.output) 51 | 52 | let noVerifyMessage = skippableHooks.map(\.rawValue).contains(hook) ? "add --no-verify to skip" : "cannot be skipped due to Git specs" 53 | print("[Komondor] > \(hook) hook failed (\(noVerifyMessage))") 54 | exit(error.terminationStatus) 55 | } catch { 56 | print(error) 57 | exit(1) 58 | } 59 | } else { 60 | logger.logWarning("[Komondor] Could not find a key for '\(hook)' under the komondor settings'") 61 | exit(0) 62 | } 63 | } 64 | 65 | func getStagedFiles() throws -> [String] { 66 | // Find the project root directory 67 | let projectRootString = try shellOut(to: "git rev-parse --show-toplevel").trimmingCharacters(in: .whitespaces) 68 | logger.debug("Found project root at: \(projectRootString)") 69 | 70 | let stagedFilesString = try shellOut(to: "git", arguments: ["diff", "--staged", "--diff-filter=ACM", "--name-only"], at: projectRootString) 71 | logger.debug("Found staged files: \(stagedFilesString)") 72 | 73 | return stagedFilesString == "" ? [] : stagedFilesString.components(separatedBy: "\n") 74 | } 75 | 76 | func expandEdited(forCommand command: String, withFiles files: [String]) -> String { 77 | guard let exts = parseEdited(command: command) else { 78 | return command 79 | } 80 | 81 | let matchingFiles = files.filter { file in 82 | exts.contains(where: { ext in 83 | file.hasSuffix(".\(ext)") 84 | }) 85 | } 86 | 87 | return command.replacingOccurrences(of: editedRegexString, with: matchingFiles.joined(separator: " "), options: .regularExpression) 88 | } 89 | -------------------------------------------------------------------------------- /Sources/Komondor/Commands/uninstall.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import ShellOut 3 | 4 | public func uninstall(logger: Logger) throws { 5 | // Validate we're in a git repo 6 | do { 7 | try shellOut(to: "git remote") 8 | } catch { 9 | logger.logError("[Komondor] Can only uninstall git-hooks into a git repo.") 10 | exit(1) 11 | } 12 | 13 | let fileManager = FileManager.default 14 | 15 | // Find the .git root 16 | let gitRootString = try shellOut(to: "git rev-parse --git-dir").trimmingCharacters(in: .whitespaces) 17 | logger.debug("Found git root at: \(gitRootString)") 18 | 19 | // Find or create the hooks dir in the .git folder 20 | var hooksRoot = URL(fileURLWithPath: gitRootString) 21 | hooksRoot.appendPathComponent("hooks", isDirectory: true) 22 | 23 | // If no hooks dir just exit 24 | guard fileManager.fileExists(atPath: hooksRoot.path) else { 25 | print("[Komondor] hooks directory does not exist, no hooks to uninstall") 26 | exit(0) 27 | } 28 | 29 | try hookList.map(\.rawValue).forEach { hookName in 30 | var hookPath = URL(fileURLWithPath: hooksRoot.absoluteString) 31 | hookPath.appendPathComponent(hookName) 32 | 33 | guard fileManager.fileExists(atPath: hookPath.path) else { 34 | logger.debug("Skipped non-existant hook: \(hookName)") 35 | return 36 | } 37 | 38 | let fileData = try Data(contentsOf: hookPath, options: []) 39 | let content = String(data: fileData, encoding: .utf8)! 40 | 41 | // Only remove hook if it was created by Komondor 42 | guard content.contains("# Komondor") else { 43 | logger.debug("Skipped non-Komondor hook: \(hookName)") 44 | return 45 | } 46 | 47 | logger.debug("Removing the hook: \(hookName)") 48 | try fileManager.removeItem(atPath: hookPath.path) 49 | } 50 | 51 | print("[Komondor] git-hooks uninstalled") 52 | } 53 | -------------------------------------------------------------------------------- /Sources/Komondor/Installation/renderScript.swift: -------------------------------------------------------------------------------- 1 | /// The *script* part of the script, e.g. the stuff that 2 | /// runs the komodor runner. 3 | /// 4 | /// If *this* changes then the template should be updated 5 | /// 6 | public func renderScript(_ hookName: String, _ swiftPackagePrefix: String?) -> String { 7 | let changeDir = swiftPackagePrefix.map { "cd \($0)\n" } 8 | ?? "" 9 | return 10 | """ 11 | hookName=`basename "$0"` 12 | gitParams="$*" 13 | \(changeDir) 14 | # use prebuilt binary if one exists, preferring release 15 | builds=( '.build/release/komondor' '.build/debug/komondor' ) 16 | for build in ${builds[@]} ; do 17 | if [[ -e $build ]] ; then 18 | komondor=$build 19 | break 20 | fi 21 | done 22 | # fall back to using 'swift run' if no prebuilt binary found 23 | komondor=${komondor:-'swift run komondor'} 24 | 25 | # run hook 26 | $komondor run \(hookName) $gitParams 27 | """ 28 | } 29 | -------------------------------------------------------------------------------- /Sources/Komondor/Installation/renderScriptHeader.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// The *script* part of the script, e.g. the stuff that 4 | /// runs the komodor runner. 5 | /// 6 | /// If *this* changes then the template should be updated 7 | /// 8 | public func renderScriptHeader(_: String) -> String { 9 | let dateFormatter = DateFormatter() 10 | dateFormatter.dateStyle = .medium 11 | let installDate = dateFormatter.string(from: Date()) 12 | 13 | return 14 | """ 15 | #!/bin/sh 16 | # Komondor v\(KomondorVersion) 17 | # Installed: \(installDate) 18 | 19 | """ 20 | } 21 | -------------------------------------------------------------------------------- /Sources/Komondor/Utils/Edited.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | let editedRegexString = #"\[edited ([a-z]+)((?:,[a-z]+)*)\]"# 4 | 5 | func parseEdited(command: String) -> [String]? { 6 | let range = NSRange(location: 0, length: command.count) 7 | 8 | guard let regex = try? NSRegularExpression(pattern: editedRegexString) else { 9 | fatalError("Malformed regex") 10 | } 11 | 12 | guard let match = regex.firstMatch(in: command, options: [], range: range), 13 | let firstExtRange = Range(match.range(at: 1), in: command) 14 | else { 15 | return nil 16 | } 17 | 18 | let firstExt = String(command[firstExtRange]) 19 | guard let restExtRange = Range(match.range(at: 2), in: command) else { 20 | return [firstExt] 21 | } 22 | 23 | let restExt = command[restExtRange] 24 | var extList = restExt.components(separatedBy: ",") 25 | // extList[0] will be "" since there is was a leading comma 26 | // replace it will the first extension 27 | extList[0] = firstExt 28 | 29 | return extList 30 | } 31 | -------------------------------------------------------------------------------- /Sources/Komondor/Utils/Logger.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | private enum Colors: String { 4 | case `default` = "\u{001B}[0;0m" 5 | case red = "\u{001B}[31m" 6 | case yellow = "\u{001B}[33m" 7 | } 8 | 9 | public struct Logger { 10 | public let isVerbose: Bool 11 | public let isSilent: Bool 12 | private let printer: Printing 13 | 14 | public init(isVerbose: Bool = false, isSilent: Bool = false, printer: Printing = Printer()) { 15 | self.isVerbose = isVerbose 16 | self.isSilent = isSilent 17 | self.printer = printer 18 | } 19 | 20 | public func debug(_ items: Any..., separator: String = " ", terminator: String = "\n", isVerbose: Bool = true) { 21 | let message = items.joinedDescription(separator: separator) 22 | print(message, terminator: terminator, isVerbose: isVerbose) 23 | } 24 | 25 | public func logInfo(_ items: Any..., separator: String = " ", terminator: String = "\n", isVerbose: Bool = false) { 26 | let message = items.joinedDescription(separator: separator) 27 | print(message, terminator: terminator, isVerbose: isVerbose) 28 | } 29 | 30 | public func logWarning(_ items: Any..., separator: String = " ", terminator: String = "\n", isVerbose _: Bool = false) { 31 | let yellowWarning = Colors.yellow.rawValue + "WARNING:" 32 | let message = yellowWarning + " " + items.joinedDescription(separator: separator) 33 | Swift.print(message, terminator: terminator) 34 | } 35 | 36 | public func logError(_ items: Any..., separator: String = " ", terminator: String = "\n", isVerbose: Bool = false) { 37 | let redError = Colors.red.rawValue + "ERROR:" 38 | let message = redError + " " + items.joinedDescription(separator: separator) 39 | print(message, terminator: terminator, isVerbose: isVerbose) 40 | } 41 | 42 | private func print(_ message: String, terminator: String = "\n", isVerbose: Bool) { 43 | guard isSilent == false else { 44 | return 45 | } 46 | guard isVerbose == false || (isVerbose && self.isVerbose) else { 47 | return 48 | } 49 | printer.print(message, terminator: terminator) 50 | } 51 | } 52 | 53 | public protocol Printing { 54 | func print(_ message: String, terminator: String) 55 | } 56 | 57 | public struct Printer: Printing { 58 | public init() {} 59 | 60 | public func print(_ message: String, terminator: String) { 61 | Swift.print(message, terminator: terminator) 62 | Swift.print(Colors.default.rawValue, terminator: "") 63 | } 64 | } 65 | 66 | private extension Sequence { 67 | func joinedDescription(separator: String) -> String { 68 | return map { "\($0)" }.joined(separator: separator) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Sources/Komondor/main.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Version for showing in verbose mode 4 | public let KomondorVersion = "1.0.0" 5 | 6 | let isVerbose = CommandLine.arguments.contains("--verbose") || (ProcessInfo.processInfo.environment["DEBUG"] != nil) 7 | let isSilent = CommandLine.arguments.contains("--silent") 8 | let logger = Logger(isVerbose: isVerbose, isSilent: isSilent) 9 | logger.debug("Setting up .git-hooks for Komondor (v\(KomondorVersion))") 10 | 11 | let cliLength = ProcessInfo.processInfo.arguments.count 12 | 13 | guard cliLength > 1 else { 14 | print(""" 15 | Welcome to Komondor, it has 3 commands: 16 | 17 | - `swift run komondor install [pre-commit post-checkout pre-rebase ...]` sets up your git repo to use Komondor 18 | - `swift run komondor run [hook-name]` used by the git-hooks to run your hooks 19 | - `swift run komondor uninstall` removes git-hooks created by Komondor 20 | 21 | Docs are available at: https://github.com/shibapm/Komondor 22 | """) 23 | exit(0) 24 | } 25 | 26 | let task = CommandLine.arguments[1] 27 | 28 | switch task { 29 | case "install": 30 | let hooks = Array(CommandLine.arguments.dropFirst(2)) 31 | .compactMap { Hooks(rawValue: $0) } 32 | try install(hooks: hooks, logger: logger) 33 | case "run": 34 | let runnerArgs = Array(CommandLine.arguments.dropFirst().dropFirst()) 35 | try runner(logger: logger, args: runnerArgs) 36 | case "uninstall": 37 | try uninstall(logger: logger) 38 | default: 39 | logger.logError("command not recognised") 40 | exit(1) 41 | } 42 | -------------------------------------------------------------------------------- /komondor.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shibapm/Komondor/3734b0e8b62cc9a59c487eb0796905c441bf9c10/komondor.jpg --------------------------------------------------------------------------------