Writing a commit message with Swift

Git commits characters waiting in line to ride a roller coaster

In a previous blog post, we covered how to define a custom editor to write your commit messages in. The example was deliberately simple and static, but we covered that an editor could be practically anything. Why not create our own editor in Swift then?

Writing a valid commit message?

Everybody has their own opinion when it comes to writing commit messages. I remember complaining about GitHub a long time ago, as I realized they only showed the first 50 characters of my message. I mean, what was that about?

A quick search results in this great blog post by Chris Beams about writing a good commit message, summed up in 7 points:

  1. Separate subject from body with a blank line
  2. Limit the subject line to 50 characters
  3. Capitalize the subject line
  4. Do not end the subject line with a period
  5. Use the imperative mood in the subject line
  6. Wrap the body at 72 characters
  7. Use the body to explain what and why vs. how

I have used a lot of different git tools over the years and I realized that the most popular ones do little to help you follow those - pretty simple - rules. Most of them provide you with a big text field to write your commit message. Some, like tower, will at least give you a small text field for the subject and a bigger, optional one for the body. Finally, some might show a warning when you go past the 50 characters in your subject. In retrospect, few of these focus on the commit message itself, and instead give you colours, graphs and buttons that hide the complexity of git. That’s totally fair, but a bit frustrating as 5 out of those 7 points would be extremely easy to implement as part of a git client.

Let’s see how to implement this client as a tiny command line tool, written in Swift. We’ll start by describing a commit message and see how we can enforce those recommendations.

What’s a commit message?

In Swift, we can easily represent a commit message with a struct that we can use to implement our validation rules.

struct CommitMessage {
    // Subject of the commit
    // - should not be more than 50 chars long
    // - should be capitalized
    // - should not end with a period
    let subject: String

    // Description of the commit
    // - can be undefined, even if it's frowned upon
    // - should be wrapped at 72 characters
    let body: String?
}

This is a simple struct, both fields are commented with the validation rules that we are going to implement with a couple of straightforward Swift functions, right after we’ve setup the project, using the Swift Package Manager.

Setting up the project

From a terminal prompt, run the following command:

mkdir Editor
cd Editor
swift package init --type=library

This will generate a default project structure that produces a Swift library:

Swift library project default directory structure

If you’re looking for a more detailed tutorial on how to get started with the Swift package manager, check the official documentation.

The first thing we’re going to do is create a CommitMessage file in the Sources/Editor folder, and paste the content mentioned earlier. We’ll also use this file and the EditorTests.swiftfile in the Tests/EditorTests folder to implement and test our validation and formatting rules.

Writing the validation rules

You must be this long to ride

There are a couple of things we can easily implement in Swift, based on the rules that we mentioned earlier. Starting with the subject, we want:

  • To remove extra punctuation at the end
  • To make sure that it’s not longer than 50 characters
  • To uppercase the first letter

To achieve that, let’s create a validate(subject:) function that takes a String and returns a cleaned String. If the provided String is invalid, it will trigger an error of type ‘ValidationError`.

func validate(subject: String) throws -> String {
    fatalError("Not implemented yet")
}

Then, we’ll write a couple of tests for this function:

final class EditorTests: XCTestCase {

    func testSubjectValidationRejectsEmptyTitle() {
        XCTAssertThrowsError(try validate(subject: "..."))
        XCTAssertThrowsError(try validate(subject: ""))
    }

    func testSubjectValidationRejectsOverlongSubjects() throws {
        let subject = "Implemented caching in the ProfileManager - It's a bit buggy but I **Think** it works most of the time."

        XCTAssertThrowsError(try validate(subject: subject))
    }

    func testSubhectValidationCleansUpTheString() throws {
        XCTAssertEqual(
            try validate(subject: "fixes the caching in the profile manager..."),
            "Fixes the caching in the profile manager"
        )
    }
}

Of course, they will fail the first time we try to run those tests…

Xcode showing failed tests

… and the actual implementation is fairly straightforward:

// It's swift and we love enum
enum ValidationError: Error {
    case empty
    case maxlength(expected: Int, actual: Int)
}

func validate(subject: String) throws -> String {
    // First, we remove extra spaces and punctuation characters
    // at the beginning and the end of the string
    let spacesAndPunctuations = CharacterSet.whitespaces.union(.punctuationCharacters)
    let cleaned = subject.trimmingCharacters(in: spacesAndPunctuations)

    // Then, we make sure that the resulting string isn't empty
    if cleaned.isEmpty {
        throw ValidationError.empty
    }

    // Finally we make sure that is not more than 50 characters in length
    if cleaned.count > 50 {
        throw ValidationError.maxlength(expected: 50, actual: subject.count)
    }

    // Finally, we make sure that the resulting string's first character is uppercased
    return cleaned.prefix(1).uppercased() + cleaned.dropFirst()
}

As for the body’s validation, all we want is to make sure it’s wrapped at 72 chars. We can start by writing a simple test…

final class EditorTests: XCTestCase {

    // ...

    func testBodyWrapping() {
        let source = """
MCP turned out to be evil and had become intent on world domination.
This commit throws Tron's disc into MCP (causing its deresolution) and turns it back into a chess game.
"""
        let length = 72

        let result = wrap(body: source, at: length)
        result?.enumerateLines { line, stop in
            XCTAssertTrue(line.count <= length, "Expected line to be maximum 72 chars long, got \(line.count)")
        }
    }

    // ...
}

… then a simple implementation, assuming we don’t care about wrapping a line in the middle of a word, yet:

func wrap(body: String?, at length: Int) -> String? {
    return body?
        // Iterate on all the lines
        .components(separatedBy: .newlines)
        .map { line -> String in
            // Use Stride to create a sequence from 0 to the length of the current line by increment
            // of `length` (which will be 72 in our example)
            // So for a 150 chars long line, it will be [0, 72, 144]
            let all = stride(from: 0, to: line.count, by: length).map { offset -> Substring in
                // The starting index of the string, offset by the current offset
                // So for the first iteration it will be startIndex offset by 0,
                // then start index offset by 72 and so on
                let start = line.index(line.startIndex, offsetBy: offset)

                // The ending index is the start of the string, offset by the provided length (72 in our example)
                // limited by the endindex of the line itself. The reason we're doing this is to avoid using an index
                // that's out of the bounds of the string.
                let end = line.index(start, offsetBy: length, limitedBy: line.endIndex) ?? line.endIndex

                // Return the resulting chunk between those 2 indexes
                return line[start..<end]
            }

            // Join all the chunks with the newline character
            return all.joined(separator: "\n")
        // Join all the lines with the newline character
        }.joined(separator: "\n")
}

At this point, we’ve implemented a few of the recommendations covered by the articles. We are now able to create a Swift struct that describes a commit message and we’re able to validate the input provided by the user.

The first recommendation of the seven requires that the subject should be separated from the body by a blank line, if provided. Once again, this is a process that can be represented in Swift fairly easily: we need a function that takes a CommitMessage and generate a String:

public func print(_ message: CommitMessage) -> String {
    fatalError("Not implemented yet")
}

This function needs to create a valid commit message from our Swift representation, as described by a few simple unit tests:

final class EditorTests: XCTestCase {
    func testCreateCommitMessage() {
        let m = CommitMessage(
            subject: "Derezz the master control program",
            body: """
MCP turned out to be evil and had become intent on world domination.
This commit throws Tron's disc into MCP (causing its deresolution)
and turns it back into a chess game.
"""
        )

        let expected = """
Derezz the master control program

MCP turned out to be evil and had become intent on world domination.
This commit throws Tron's disc into MCP (causing its deresolution)
and turns it back into a chess game.
"""

        XCTAssertEqual(print(m), expected)
    }

    func testCreateCommitMessageWithoutABody() {
        let m = CommitMessage(
            subject: "Fix typo in introduction to user guide",
            body: nil
        )

        XCTAssertEqual(
            print(m),
            "Fix typo in introduction to user guide"
        )
    }
}

The implementation itself is fairly basic, we rely on Swift’s compact map to automatically remove the optional body:

public func print(_ message: CommitMessage) -> String {
    return [message.subject, message.body]
        .compactMap { $0 }
        .joined(separator: "\n\n")
}

Conclusion

Most of us developers won’t spend too much time reading a history of Git commits, but it’s not rare to end up browsing throught it, either because you’ve inherited of a large codebase that you don’t necesarily understand, or that you’re investigating an issue. When that happens, having access to a clear, easy to read, and easy to understand history is a nice to have. Swift makes it really easy to build the foundation that we need to create the rest of our custom commit message editor, which we’ll cover in the next post!