Approval-based snapshot testing library for Go (mirror)
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: use go-snaps difflib

+453 -72
+6
.gitignore
··· 8 8 go.work 9 9 go.work.sum 10 10 .env 11 + cmd/tui/freeze-tui 12 + cmd/tui/tui 13 + cmd/freeze/freeze 14 + freeze-tui 15 + tui 16 + freeze
+12 -1
README.md
··· 2 2 3 3 A [birdie](https://github.com/giacomocavalieri/birdie) and [insta](https://github.com/mitsuhiko/insta) inspired snapshot testing library for Go. 4 4 5 - ![New snapshot screen](./assets/screenshot-new.png) 5 + ![New snapshot screen](./assets/screenshot-new.png "New snapshot view") 6 + 7 + ![Snapshot review CLI](./assets/screenshots-diff-cli "Snapshot diff view (CLI)") 6 8 7 9 ## Installation 8 10 ··· 30 32 go run github.com/ptdewey/freeze/cmd/freeze review 31 33 ``` 32 34 35 + <!-- TODO: add example of `freeze.Review()` in go code --> 36 + 37 + Freeze also includes (in a separate Go module) with a [Bubbletea](https://github.com/charmbracelet/bubbletea) TUI in [cmd/tui/main.go](./cmd/tui/main.go). (The TUI is shipped in a separate module to make the added dependencies optional) 38 + 39 + ```sh 40 + # TODO: tui usage 41 + ``` 42 + 33 43 ## Disclaimer 34 44 35 45 - This package was largely vibe coded, your mileage may vary (but this library provides more of what I want than the ones below). ··· 37 47 ## Other Libraries 38 48 39 49 - [go-snaps](https://github.com/gkampitakis/go-snaps) 50 + - Freeze uses the diff implementation from `go-snaps`. 40 51 - [cupaloy](https://github.com/bradleyjkemp/cupaloy)
+2 -3
__snapshots__/test_map.snap
··· 1 1 --- 2 2 title: Map Test 3 3 test_name: TestMap 4 - file_path: /home/patrick/projects/scratchpad/freeze/freeze.go 4 + file_path: /home/patrick/projects/freeze/freeze.go 5 5 func_name: 6 6 --- 7 7 map[string]interface{}{ 8 + "foo": "bar", 8 9 "wibble": "wobble", 9 - "foo": "bar", 10 - "wibbling": "wobble", 11 10 }
+1 -1
__snapshots__/test_snap_custom_type.snap
··· 1 1 --- 2 2 title: Custom Type Test 3 3 test_name: TestSnapCustomType 4 - file_path: /home/patrick/projects/scratchpad/freeze/freeze.go 4 + file_path: /home/patrick/projects/freeze/freeze.go 5 5 func_name: 6 6 --- 7 7 freeze_test.CustomStruct{
+1 -1
__snapshots__/test_snap_func.snap
··· 1 1 --- 2 2 title: TestSnapFunc 3 3 test_name: TestSnapFunc 4 - file_path: /home/patrick/projects/scratchpad/freeze/freeze_test.go 4 + file_path: /home/patrick/projects/freeze/freeze_test.go 5 5 func_name: 6 6 --- 7 7 "helper result"
+1 -1
__snapshots__/test_snap_func_another_helper.snap
··· 1 1 --- 2 2 title: TestSnapFuncAnotherHelper 3 3 test_name: TestSnapFuncAnotherHelper 4 - file_path: /home/patrick/projects/scratchpad/freeze/freeze_test.go 4 + file_path: /home/patrick/projects/freeze/freeze_test.go 5 5 func_name: 6 6 --- 7 7 10
+1 -1
__snapshots__/test_snap_multiple.snap
··· 1 1 --- 2 2 title: Multiple Values Test 3 3 test_name: TestSnapMultiple 4 - file_path: /home/patrick/projects/scratchpad/freeze/freeze.go 4 + file_path: /home/patrick/projects/freeze/freeze.go 5 5 func_name: 6 6 --- 7 7 "value1"
+1 -1
__snapshots__/test_snap_string.snap
··· 1 1 --- 2 2 title: Simple String Test 3 3 test_name: TestSnapString 4 - file_path: /home/patrick/projects/scratchpad/freeze/freeze.go 4 + file_path: /home/patrick/projects/freeze/freeze.go 5 5 func_name: 6 6 --- 7 7 hello world
assets/screenshot-diff-cli.png

This is a binary file and will not be displayed.

+5 -6
freeze_test.go
··· 37 37 38 38 func TestMap(t *testing.T) { 39 39 freeze.Snap(t, "Map Test", map[string]any{ 40 - "foo": "bar", 41 - "wibbling": "wobble", 42 - "wibble": "wobble", 40 + "foo": "bar", 41 + "wibble": "wobble", 43 42 }) 44 43 } 45 44 ··· 131 130 } 132 131 133 132 func TestHistogramDiff(t *testing.T) { 134 - old := "line1\nline2\nline3" 135 - new := "line1\nmodified\nline3" 133 + oldStr := "line1\nline2\nline3" 134 + newStr := "line1\nmodified\nline3" 136 135 137 - diff := freeze.Histogram(old, new) 136 + diff := freeze.Histogram(oldStr, newStr) 138 137 139 138 if len(diff) < 3 { 140 139 t.Errorf("expected at least 3 diff lines, got %d", len(diff))
+416 -57
internal/diff/diff.go
··· 1 1 package diff 2 2 3 - import ( 4 - "strings" 5 - ) 3 + /* 4 + This file was sourced from github.com/gkampitakis/go-snaps, available with the following License 5 + 6 + MIT License 7 + 8 + Copyright (c) 2021 Georgios Kampitakis 9 + 10 + Permission is hereby granted, free of charge, to any person obtaining a copy 11 + of this software and associated documentation files (the "Software"), to deal 12 + in the Software without restriction, including without limitation the rights 13 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 + copies of the Software, and to permit persons to whom the Software is 15 + furnished to do so, subject to the following conditions: 16 + 17 + The above copyright notice and this permission notice shall be included in all 18 + copies or substantial portions of the Software. 19 + 20 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 + SOFTWARE. 27 + 28 + ======================= 29 + 30 + This package is a partial port of Python difflib. 31 + 32 + This library is vendored and modified to address the `go-snaps` needs for a more 33 + readable difference report. 34 + 35 + 36 + Original source: https://github.com/pmezard/go-difflib 37 + 38 + Copyright (c) 2013, Patrick Mezard 39 + All rights reserved. 40 + 41 + Redistribution and use in source and binary forms, with or without 42 + modification, are permitted provided that the following conditions are 43 + met: 44 + 45 + Redistributions of source code must retain the above copyright 46 + notice, this list of conditions and the following disclaimer. 47 + Redistributions in binary form must reproduce the above copyright 48 + notice, this list of conditions and the following disclaimer in the 49 + documentation and/or other materials provided with the distribution. 50 + The names of its contributors may not be used to endorse or promote 51 + products derived from this software without specific prior written 52 + permission. 53 + 54 + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS 55 + IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 56 + TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A 57 + PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 58 + HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 59 + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED 60 + TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 61 + PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 62 + LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 63 + NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 64 + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 65 + */ 66 + 67 + import "strconv" 6 68 7 69 type DiffKind int 8 70 ··· 19 81 Kind DiffKind 20 82 } 21 83 22 - func Histogram(old, new string) []DiffLine { 23 - oldLines := strings.Split(old, "\n") 24 - newLines := strings.Split(new, "\n") 84 + const ( 85 + // Tag Codes for internal opcodes 86 + opEqual int8 = iota 87 + opInsert 88 + opDelete 89 + opReplace 90 + ) 91 + 92 + type match struct { 93 + A int 94 + B int 95 + Size int 96 + } 97 + 98 + type opCode struct { 99 + Tag int8 100 + I1 int 101 + I2 int 102 + J1 int 103 + J2 int 104 + } 105 + 106 + func min(a, b int) int { 107 + if a < b { 108 + return a 109 + } 110 + return b 111 + } 112 + 113 + func max(a, b int) int { 114 + if a > b { 115 + return a 116 + } 117 + return b 118 + } 119 + 120 + // sequenceMatcher compares sequence of strings. The basic 121 + // algorithm predates, and is a little fancier than, an algorithm 122 + // published in the late 1980's by Ratcliff and Obershelp under the 123 + // hyperbolic name "gestalt pattern matching". The basic idea is to find 124 + // the longest contiguous matching subsequence that contains no "junk" 125 + // elements (R-O doesn't address junk). The same idea is then applied 126 + // recursively to the pieces of the sequences to the left and to the right 127 + // of the matching subsequence. This does not yield minimal edit 128 + // sequences, but does tend to yield matches that "look right" to people. 129 + // 130 + // sequenceMatcher tries to compute a "human-friendly diff" between two 131 + // sequences. Unlike e.g. UNIX(tm) diff, the fundamental notion is the 132 + // longest *contiguous* & junk-free matching subsequence. That's what 133 + // catches peoples' eyes. The Windows(tm) windiff has another interesting 134 + // notion, pairing up elements that appear uniquely in each sequence. 135 + // That, and the method here, appear to yield more intuitive difference 136 + // reports than does diff. This method appears to be the least vulnerable 137 + // to synching up on blocks of "junk lines", though (like blank lines in 138 + // ordinary text files, or maybe "<P>" lines in HTML files). That may be 139 + // because this is the only method of the 3 that has a *concept* of 140 + // "junk" <wink>. 141 + // 142 + // Timing: Basic R-O is cubic time worst case and quadratic time expected 143 + // case. sequenceMatcher is quadratic time for the worst case and has 144 + // expected-case behavior dependent in a complicated way on how many 145 + // elements the sequences have in common; best case time is linear. 146 + type sequenceMatcher struct { 147 + a []string 148 + b []string 149 + b2j map[string][]int 150 + IsJunk func(string) bool 151 + autoJunk bool 152 + bJunk map[string]struct{} 153 + matchingBlocks []match 154 + fullBCount map[string]int 155 + bPopular map[string]struct{} 156 + opCodes []opCode 157 + } 158 + 159 + func newMatcher(a, b []string) *sequenceMatcher { 160 + m := sequenceMatcher{autoJunk: true} 161 + m.setSeqs(a, b) 162 + return &m 163 + } 164 + 165 + // Set two sequences to be compared. 166 + func (m *sequenceMatcher) setSeqs(a, b []string) { 167 + m.setSeq1(a) 168 + m.setSeq2(b) 169 + } 25 170 26 - if len(oldLines) == 1 && oldLines[0] == "" { 27 - oldLines = []string{} 171 + // Set the first sequence to be compared. 172 + func (m *sequenceMatcher) setSeq1(a []string) { 173 + if &a == &m.a { 174 + return 28 175 } 29 - if len(newLines) == 1 && newLines[0] == "" { 30 - newLines = []string{} 176 + m.a = a 177 + m.matchingBlocks, m.opCodes = nil, nil 178 + } 179 + 180 + // Set the second sequence to be compared. 181 + func (m *sequenceMatcher) setSeq2(b []string) { 182 + if &b == &m.b { 183 + return 31 184 } 185 + m.b = b 186 + m.matchingBlocks, m.opCodes, m.fullBCount = nil, nil, nil 187 + m.chainB() 188 + } 32 189 33 - matrix := computeEditDistance(oldLines, newLines) 34 - return traceback(oldLines, newLines, matrix) 190 + func (m *sequenceMatcher) chainB() { 191 + // Populate line -> index mapping 192 + b2j := map[string][]int{} 193 + for i, elt := range m.b { 194 + indices := b2j[elt] 195 + indices = append(indices, i) 196 + b2j[elt] = indices 197 + } 198 + 199 + // Purge junk elements 200 + m.bJunk = map[string]struct{}{} 201 + if m.IsJunk != nil { 202 + junk := m.bJunk 203 + for elt := range b2j { 204 + if m.IsJunk(elt) { 205 + junk[elt] = struct{}{} 206 + } 207 + } 208 + for elt := range junk { // separate loop avoids separate list of keys 209 + delete(b2j, elt) 210 + } 211 + } 212 + 213 + // Purge popular elements that are not junk 214 + popular := map[string]struct{}{} 215 + n := len(m.b) 216 + if m.autoJunk && n >= 200 { 217 + ntest := n/100 + 1 218 + for s, indices := range b2j { 219 + if len(indices) > ntest { 220 + popular[s] = struct{}{} 221 + } 222 + } 223 + for s := range popular { 224 + delete(b2j, s) 225 + } 226 + } 227 + m.bPopular = popular 228 + m.b2j = b2j 229 + } 230 + 231 + func (m *sequenceMatcher) isBJunk(s string) bool { 232 + _, ok := m.bJunk[s] 233 + return ok 35 234 } 36 235 37 - func computeEditDistance(old, new []string) [][]int { 38 - m, n := len(old), len(new) 39 - matrix := make([][]int, m+1) 40 - for i := range matrix { 41 - matrix[i] = make([]int, n+1) 236 + // Find longest matching block in a[alo:ahi] and b[blo:bhi]. 237 + func (m *sequenceMatcher) findLongestMatch(alo, ahi, blo, bhi int) match { 238 + besti, bestj, bestsize := alo, blo, 0 239 + 240 + // find longest junk-free match 241 + j2len := map[int]int{} 242 + for i := alo; i != ahi; i++ { 243 + newj2len := map[int]int{} 244 + for _, j := range m.b2j[m.a[i]] { 245 + if j < blo { 246 + continue 247 + } 248 + if j >= bhi { 249 + break 250 + } 251 + k := j2len[j-1] + 1 252 + newj2len[j] = k 253 + if k > bestsize { 254 + besti, bestj, bestsize = i-k+1, j-k+1, k 255 + } 256 + } 257 + j2len = newj2len 42 258 } 43 259 44 - for i := 0; i <= m; i++ { 45 - matrix[i][0] = i 260 + // Extend the best by non-junk elements on each end 261 + for besti > alo && bestj > blo && !m.isBJunk(m.b[bestj-1]) && 262 + m.a[besti-1] == m.b[bestj-1] { 263 + besti, bestj, bestsize = besti-1, bestj-1, bestsize+1 264 + } 265 + for besti+bestsize < ahi && bestj+bestsize < bhi && 266 + !m.isBJunk(m.b[bestj+bestsize]) && 267 + m.a[besti+bestsize] == m.b[bestj+bestsize] { 268 + bestsize++ 269 + } 270 + 271 + // Suck up junk on each side 272 + for besti > alo && bestj > blo && m.isBJunk(m.b[bestj-1]) && 273 + m.a[besti-1] == m.b[bestj-1] { 274 + besti, bestj, bestsize = besti-1, bestj-1, bestsize+1 275 + } 276 + for besti+bestsize < ahi && bestj+bestsize < bhi && 277 + m.isBJunk(m.b[bestj+bestsize]) && 278 + m.a[besti+bestsize] == m.b[bestj+bestsize] { 279 + bestsize++ 46 280 } 47 - for j := 0; j <= n; j++ { 48 - matrix[0][j] = j 281 + 282 + return match{A: besti, B: bestj, Size: bestsize} 283 + } 284 + 285 + // Return list of triples describing matching subsequences. 286 + func (m *sequenceMatcher) getMatchingBlocks() []match { 287 + if m.matchingBlocks != nil { 288 + return m.matchingBlocks 49 289 } 50 290 51 - for i := 1; i <= m; i++ { 52 - for j := 1; j <= n; j++ { 53 - if old[i-1] == new[j-1] { 54 - matrix[i][j] = matrix[i-1][j-1] 55 - } else { 56 - matrix[i][j] = 1 + minThree(matrix[i-1][j], matrix[i][j-1], matrix[i-1][j-1]) 291 + var matchBlocks func(alo, ahi, blo, bhi int, matched []match) []match 292 + matchBlocks = func(alo, ahi, blo, bhi int, matched []match) []match { 293 + match := m.findLongestMatch(alo, ahi, blo, bhi) 294 + i, j, k := match.A, match.B, match.Size 295 + if match.Size > 0 { 296 + if alo < i && blo < j { 297 + matched = matchBlocks(alo, i, blo, j, matched) 298 + } 299 + matched = append(matched, match) 300 + if i+k < ahi && j+k < bhi { 301 + matched = matchBlocks(i+k, ahi, j+k, bhi, matched) 57 302 } 58 303 } 304 + return matched 59 305 } 306 + matched := matchBlocks(0, len(m.a), 0, len(m.b), nil) 60 307 61 - return matrix 308 + // Collapse adjacent equal blocks 309 + nonAdjacent := []match{} 310 + i1, j1, k1 := 0, 0, 0 311 + for _, b := range matched { 312 + i2, j2, k2 := b.A, b.B, b.Size 313 + if i1+k1 == i2 && j1+k1 == j2 { 314 + k1 += k2 315 + } else { 316 + if k1 > 0 { 317 + nonAdjacent = append(nonAdjacent, match{i1, j1, k1}) 318 + } 319 + i1, j1, k1 = i2, j2, k2 320 + } 321 + } 322 + if k1 > 0 { 323 + nonAdjacent = append(nonAdjacent, match{i1, j1, k1}) 324 + } 325 + 326 + nonAdjacent = append(nonAdjacent, match{len(m.a), len(m.b), 0}) 327 + m.matchingBlocks = nonAdjacent 328 + return m.matchingBlocks 329 + } 330 + 331 + // Return list of opcodes describing how to turn a into b. 332 + func (m *sequenceMatcher) getOpCodes() []opCode { 333 + if m.opCodes != nil { 334 + return m.opCodes 335 + } 336 + i, j := 0, 0 337 + matching := m.getMatchingBlocks() 338 + opCodes := make([]opCode, 0, len(matching)) 339 + for _, m := range matching { 340 + ai, bj, size := m.A, m.B, m.Size 341 + var tag int8 = 0 342 + if i < ai && j < bj { 343 + tag = opReplace 344 + } else if i < ai { 345 + tag = opDelete 346 + } else if j < bj { 347 + tag = opInsert 348 + } 349 + if tag > 0 { 350 + opCodes = append(opCodes, opCode{tag, i, ai, j, bj}) 351 + } 352 + i, j = ai+size, bj+size 353 + if size > 0 { 354 + opCodes = append(opCodes, opCode{opEqual, ai, i, bj, j}) 355 + } 356 + } 357 + m.opCodes = opCodes 358 + return m.opCodes 62 359 } 63 360 64 - func traceback(old, new []string, matrix [][]int) []DiffLine { 361 + // Histogram computes a diff between two strings using the Ratcliff-Obershelp algorithm 362 + func Histogram(old, new string) []DiffLine { 363 + oldLines := splitLines(old) 364 + newLines := splitLines(new) 365 + 366 + matcher := newMatcher(oldLines, newLines) 367 + opcodes := matcher.getOpCodes() 368 + 65 369 var result []DiffLine 66 - i, j := len(old), len(new) 67 370 68 - for i > 0 || j > 0 { 69 - if i > 0 && j > 0 && old[i-1] == new[j-1] { 70 - // Lines match - shared 71 - result = append([]DiffLine{{Line: old[i-1], Kind: DiffShared, OldNumber: i, NewNumber: j}}, result...) 72 - i-- 73 - j-- 74 - } else if i > 0 && j > 0 && matrix[i-1][j] == matrix[i][j-1] { 75 - // Equal cost - prefer showing deletions first, then additions 76 - // This groups changes better 77 - result = append([]DiffLine{{Line: old[i-1], Kind: DiffOld, OldNumber: i}}, result...) 78 - i-- 79 - } else if j > 0 && (i == 0 || matrix[i][j-1] <= matrix[i-1][j]) { 80 - // Addition 81 - result = append([]DiffLine{{Line: new[j-1], Kind: DiffNew, NewNumber: j}}, result...) 82 - j-- 83 - } else if i > 0 { 84 - // Deletion 85 - result = append([]DiffLine{{Line: old[i-1], Kind: DiffOld, OldNumber: i}}, result...) 86 - i-- 371 + for _, op := range opcodes { 372 + switch op.Tag { 373 + case opEqual: 374 + for i := op.I1; i < op.I2; i++ { 375 + newIdx := i + (op.J1 - op.I1) 376 + result = append(result, DiffLine{ 377 + Line: oldLines[i], 378 + Kind: DiffShared, 379 + OldNumber: i + 1, 380 + NewNumber: newIdx + 1, 381 + }) 382 + } 383 + case opDelete: 384 + for i := op.I1; i < op.I2; i++ { 385 + result = append(result, DiffLine{ 386 + Line: oldLines[i], 387 + Kind: DiffOld, 388 + OldNumber: i + 1, 389 + }) 390 + } 391 + case opInsert: 392 + for j := op.J1; j < op.J2; j++ { 393 + result = append(result, DiffLine{ 394 + Line: newLines[j], 395 + Kind: DiffNew, 396 + NewNumber: j + 1, 397 + }) 398 + } 399 + case opReplace: 400 + for i := op.I1; i < op.I2; i++ { 401 + result = append(result, DiffLine{ 402 + Line: oldLines[i], 403 + Kind: DiffOld, 404 + OldNumber: i + 1, 405 + }) 406 + } 407 + for j := op.J1; j < op.J2; j++ { 408 + result = append(result, DiffLine{ 409 + Line: newLines[j], 410 + Kind: DiffNew, 411 + NewNumber: j + 1, 412 + }) 413 + } 87 414 } 88 415 } 89 416 90 417 return result 91 418 } 92 419 93 - func minThree(a, b, c int) int { 94 - if a < b { 95 - if a < c { 96 - return a 420 + // splitLines splits a string by newlines, handling empty strings 421 + func splitLines(s string) []string { 422 + if s == "" { 423 + return []string{} 424 + } 425 + lines := splitLinesKeepNewline(s) 426 + return lines 427 + } 428 + 429 + // splitLinesKeepNewline splits on newlines but removes the newlines themselves 430 + func splitLinesKeepNewline(s string) []string { 431 + var lines []string 432 + var current string 433 + 434 + for _, char := range s { 435 + if char == '\n' { 436 + lines = append(lines, current) 437 + current = "" 438 + } else { 439 + current += string(char) 97 440 } 98 - return c 441 + } 442 + 443 + if current != "" { 444 + lines = append(lines, current) 445 + } 446 + 447 + return lines 448 + } 449 + 450 + // FormatRangeUnified converts range to the "ed" format for unified diffs 451 + func FormatRangeUnified(start, stop int) string { 452 + beginning := start + 1 // lines start numbering with one 453 + length := stop - start 454 + 455 + if length == 1 { 456 + return strconv.Itoa(beginning) 99 457 } 100 - if b < c { 101 - return b 458 + if length == 0 { 459 + beginning-- 102 460 } 103 - return c 461 + 462 + return strconv.Itoa(beginning) + "," + strconv.Itoa(length) 104 463 }
+7
justfile
··· 10 10 11 11 clean: 12 12 @rm -rf ./__snapshots__ 13 + 14 + tui: 15 + @pushd ./cmd/tui && go build -o freeze-tui ./main.go && popd 16 + @./cmd/tui/freeze-tui 17 + 18 + review: 19 + @./cmd/tui/freeze-tui