しおメモ

雑多な技術系ブログです。ニッチな内容が多いです。

VSCodeの3-way mergeをgitのmergetoolとして利用する

1年ほど前にgitのmergetoolvimdiffからvscodeの3-wayに乗り換えて馴染んできたので、設定方法のメモと布教です。

設定方法

メインのエディタでVSCodeを使っていて、git操作もその中で完結している場合は、VSCode自体の設定でgit.mergeEditortrueにするだけで良いです。
一方で、IDE勢でgit操作だけターミナルを使うような場合は、git configから設定する必要があります。

git config --global merge.tool vscode
git config --global mergetool.vscode.cmd 'code --wait --new-window --merge $LOCAL $REMOTE $BASE $MERGED'

.gitconfigではこのようになります。

[merge]
    tool = vscode
[mergetool "vscode"]
    cmd = code --wait --new-window --merge $LOCAL $REMOTE $BASE $MERGED

簡単な解説を付け加えると、--wait(-w)オプションがVSCodeで該当のファイルが閉じられるまでシェル側で待つ、--new-window(-n)オプションが新しくウィンドウを作成するというものです。

git mergetoolでVSCodeの新しいウィンドウが開き、閉じると次のファイルに移ります。

所感

3-way merge editorは不評だったようで、一時期デフォルトだったものの、外されてしまいました。

github.com

操作は]cやら1doやらを打たずに、"Accept xxx"や、"x Conflict Remaining"をポチポチ押すだけなので、普段あまりvimを使わない場合はこちらの方が直感的で楽に感じました。

scior.hatenablog.com

URLRequestをcurlコマンドに変換する

サーバーサイドエンジニアとやり取りする時など、何かとcurlコマンドが欲しい機会があると思います。
Charlesなどの外部ツールを使っても出来ますが、LLDBで扱えると時短になります。

Swift側のextension

HEADリクエストなどを考慮していないため、厳密ではない部分はありますが、実務で使うようなリクエストにはシンプルなコードで対応できます。
必要に応じて、オプションをcomponentsに追加することで調整可能です。

extension URLRequest {
    var curlCommand: String? {
        guard let method = httpMethod,
              let url = url?.absoluteString else {
            return nil
        }

        var components = ["curl", "-X", method, "'\(url)'"]
        for (key, value) in allHTTPHeaderFields ?? [:] {
            components.append("-H")
            components.append("'\(key): \(value)'")
        }
        if let httpBody, let body = String(data: httpBody, encoding: .utf8) {
            components.append("-d")
            components.append("'\(body)'")
        }

        return components.joined(separator: " ")
    }
}
let request = URLRequest(url: .init(string: "https://www.hogehoge.com")!)
print(request.curlCommand!) // curl -X GET 'https://www.hogehoge.com'

LLDB用のPythonスクリプト

プロダクトに入れる必要は全くないので、swiftファイルをプロジェクト外に置いておき、LLDBのスクリプトで読ませるのがおすすめです。
今回はSwift側で整形するので、Python側の処理はswiftファイルを読み込んで、そのままLLDBに食わせるだけです。

import lldb
import lldbcommon as common
import os


@lldb.command("curl")
def print_curl(debugger, exp, result, dict):
    path = os.path.join(os.path.dirname(__file__), 'swift/URLRequest+curlCommand.swift')
    with open(path, 'r') as f:
        common.evaluate(f.read())

    common.evaluate(f'print({exp}.curlCommand!)')

毎回swiftファイルをロードしてしまうので、 気になる方はPython側で制御を追加してください。

common.evaluateの実装の一例は以下の通りです。

def evaluate(exp):
    options = lldb.SBExpressionOptions()
    options.SetLanguage(lldb.eLanguageTypeSwift)
    options.SetTimeoutInMicroSeconds(3 * 1000 * 1000)
    options.SetTrapExceptions(False)

    frame = (
        lldb.debugger.GetSelectedTarget()
        .GetProcess()
        .GetSelectedThread()
        .GetSelectedFrame()
    )

    value = frame.EvaluateExpression(exp, options)
    error = value.GetError()
    if not error.Success() and error.value != 0x1001:
        print(error)
        
    return value

XCTestを使ったflaky testの扱い方

Xcode13からXCTest周りの機能が強化され、特にflaky testに対応しやすくなったので、実際の対応方法をいくつか書きます。

Flaky testの再現確認

まず始めに、失敗する状況を手元で再現できれば原因を特定できる可能性があるので、Xcodeの機能を使って繰り返し実行してみることをおすすめします。

Xcode13から追加された機能で、Xcodeから操作する場合は、テストケースの左側にあるボタンから実行することができます。
本題から少し外れてしまうので、詳しいオプションはドキュメントを参照してください。

developer.apple.com

xcodebuildで行う場合は、-test-iterationsというオプションで実行可能です。

XCTSkipを利用してskipする

特定の環境のみで再現するケースでは、暫定対応としてまずはXCTSkipを使って該当する環境でskipすることをおすすめします。
単純にコメントアウトする場合と異なり、skipする条件を指定できるので、正常に動いている環境ではテストを実行したままにすることが出来ます。

func testSkip() throws {
    try XCTSkipIf(environment.isCI, "<https://hoge1234fuga.com/bugs/5678> hoge bug")
}

またXCTSkipを利用した場合、skipされたテストケースも出力には残るため、集計の点でもメリットがあります。

XCTExpectedFailureを利用して結果を成功扱いにする

Flaky testになっている原因が特定できない場合は、XCTExpectedFailureを使うことで、CIなどでテストケースを実行させながら、結果は成功扱いにすることが出来ます。
XCTSkipを使う場合よりも、テストケースの実行を維持できる点と、場合によっては失敗した原因も探ることができる点で、いくらかメリットがあります。

// メソッドの一部分を対象にする
func testExpectedFailure() throws {
    XCTExpectFailure("<https://hoge1234fuga.com/bugs/5678> hoge bug", strict: false) {
        // テストケースのコード
    }
}

// メソッド全体を対象にする
func testExpectedFailure2() throws {
    XCTExpectFailure("<https://hoge1234fuga.com/bugs/5678> hoge bug", strict: false)
    // テストケースのコード
}

strictオプションはデフォルトでtrueですが、この場合は逆にブロック内のテストが成功した場合に、テストケースが失敗扱いになってしまうため、flaky testの場合はfalseにする必要があります。

さらに細かく、テストケースの失敗の原因のうち一部のみを許容したい場合は、issueMatcherと組み合わせることが出来ます。
例としてHogeError.expectedだけ許容するケースを考えてみます。

// HogeError.expectedは許容するので成功扱い
func testMatcherExpectedFailure() throws {
    try XCTExpectFailure("<https://hoge1234fuga.com/bugs/5678> hoge bug", strict: false) {
        throw HogeError.expected
    } issueMatcher: { issue in
        issue.type == .thrownError && issue.associatedError as? HogeError == .expected
    }
}
// HogeError.unexpectedは許容しないので失敗扱い
func testMatcherExpectedFailure2() throws {
    try XCTExpectFailure("<https://hoge1234fuga.com/bugs/5678> hoge bug", strict: false) {
        throw HogeError.unexpected
    } issueMatcher: { issue in
        issue.type == .thrownError && issue.associatedError as? HogeError == .expected
    }
}
// ブロック内のテストが成功する場合はそのまま成功扱い
func testMatcherExpectedFailure3() {
    XCTExpectFailure("<https://hoge1234fuga.com/bugs/5678> hoge bug", strict: false) {
        print("hoge")
    } issueMatcher: { issue in
        issue.type == .thrownError && issue.associatedError as? HogeError == .expected
    }
}

失敗の原因のうちissueMatcherでマッチしなかったものは、XCTExpectFailureを書かない場合と同じ扱いで、失敗になります。

実際の出力は以下のようになります。

Test Case '-[__lldb_expr_31.HogeTests testMatcherExpectedFailure]' started.
XCTExpectFailure: matcher accepted Thrown Error: failed: caught error: "expected"
Test Case '-[__lldb_expr_31.HogeTests testMatcherExpectedFailure]' passed (0.034 seconds).

Test Case '-[__lldb_expr_31.HogeTests testMatcherExpectedFailure2]' started.
XCTExpectFailure: matcher rejected Thrown Error: failed: caught error: "unexpected""unexpected"
Test Case '-[__lldb_expr_31.HogeTests testMatcherExpectedFailure2]' failed (0.031 seconds).

Test Case '-[__lldb_expr_31.HogeTests testMatcherExpectedFailure3]' started.
hoge
Test Case '-[__lldb_expr_31.HogeTests testMatcherExpectedFailure3]' passed (0.002 seconds).

issueMatcherの引数のXCTIssueについては、typeassociatedErrorが情報として活用できますが、不足する場合はdetailedDescriptionなどの文字列を見るケースもあり得ます。

developer.apple.com

動画リンク

今回は結構ざっくりしていて、ドキュメントもわかりづらいので、実際に動かすのが一番良いかもです

developer.apple.com

developer.apple.com