A virtual jailed shell environment for Go apps backed by an io/fs#FS.
1
fork

Configure Feed

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

feat(diff): default to normal format and add -c/-C/-e/-r/-U/-b

Implements GNU coreutils compatibility for diff:

- Default output is normal (Nc/Na/Nd ed-like) when no
format flag is given; previously always unified
- -c context format and -C n with N lines context
- -U n unified with N lines context (default 3 via -u)
- -e ed-script output
- -r recursive directory comparison with "Only in"/
"Common subdirectories" reporting
- -b ignore amount of whitespace, wired through
compareOptions and collapseSpace
- -q brief and -s report-identical retained
- Exit status 0/1/2 verified explicitly

Refs: docs/posix2018/CONFORMANCE.md
Assisted-by: Claude Opus 4.7 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

+910 -43
+633 -32
command/internal/diff/diff.go
··· 6 6 "fmt" 7 7 "io" 8 8 "path" 9 + "sort" 10 + "strconv" 9 11 "strings" 12 + "time" 13 + "unicode" 10 14 11 15 "github.com/pborman/getopt/v2" 12 16 "github.com/pmezard/go-difflib/difflib" ··· 16 20 17 21 type Impl struct{} 18 22 23 + var nowFunc = time.Now 24 + 25 + type format int 26 + 27 + const ( 28 + formatNormal format = iota 29 + formatContext 30 + formatUnified 31 + formatEd 32 + formatForwardEd 33 + ) 34 + 19 35 func (Impl) Exec(ctx context.Context, ec *command.ExecContext, args []string) error { 20 36 if ec == nil { 21 37 return errors.New("diff: nil ExecContext") ··· 37 53 usage := func() { 38 54 fmt.Fprint(stderr, "Usage: diff [OPTION]... FILE1 FILE2\n") 39 55 fmt.Fprint(stderr, "Compare files line by line.\n\n") 40 - fmt.Fprint(stderr, " -u, --unified output unified diff format (default)\n") 56 + fmt.Fprint(stderr, " -b, --ignore-space-change ignore changes in the amount of white space\n") 57 + fmt.Fprint(stderr, " -c output 3 lines of copied context\n") 58 + fmt.Fprint(stderr, " -C NUM, --context[=NUM] output NUM (default 3) lines of copied context\n") 59 + fmt.Fprint(stderr, " -e, --ed output an ed script\n") 60 + fmt.Fprint(stderr, " -f output a forward ed script\n") 61 + fmt.Fprint(stderr, " -u output 3 lines of unified context\n") 62 + fmt.Fprint(stderr, " -U NUM, --unified[=NUM] output NUM (default 3) lines of unified context\n") 63 + fmt.Fprint(stderr, " -r, --recursive recursively compare any subdirectories found\n") 41 64 fmt.Fprint(stderr, " -q, --brief report only whether files differ\n") 42 65 fmt.Fprint(stderr, " -s, --report-identical-files report when files are the same\n") 43 66 fmt.Fprint(stderr, " -i, --ignore-case ignore case differences\n") ··· 45 68 } 46 69 set.SetUsage(usage) 47 70 48 - unified := set.BoolLong("unified", 'u', "output unified diff format (default)") 71 + unified := set.Bool('u', "output 3 lines of unified context") 72 + unifiedN := set.StringLong("unified", 'U', "", "output NUM lines of unified context") 73 + contextFlag := set.Bool('c', "output 3 lines of copied context") 74 + contextN := set.StringLong("context", 'C', "", "output NUM lines of copied context") 75 + edScript := set.BoolLong("ed", 'e', "output an ed script") 76 + forwardEd := set.Bool('f', "output a forward ed script") 77 + recursive := set.BoolLong("recursive", 'r', "recursively compare any subdirectories found") 78 + ignoreSpace := set.BoolLong("ignore-space-change", 'b', "ignore changes in the amount of white space") 49 79 brief := set.BoolLong("brief", 'q', "report only whether files differ") 50 80 reportSame := set.BoolLong("report-identical-files", 's', "report when files are the same") 51 81 ignoreCase := set.BoolLong("ignore-case", 'i', "ignore case differences") ··· 60 90 usage() 61 91 return nil 62 92 } 63 - _ = unified 93 + 94 + fmtKind := formatNormal 95 + contextLines := 3 96 + switch { 97 + case *edScript: 98 + fmtKind = formatEd 99 + case *forwardEd: 100 + fmtKind = formatForwardEd 101 + case *unifiedN != "": 102 + fmtKind = formatUnified 103 + n, err := strconv.Atoi(*unifiedN) 104 + if err != nil || n < 0 { 105 + fmt.Fprintf(stderr, "diff: invalid context length '%s'\n", *unifiedN) 106 + return interp.ExitStatus(2) 107 + } 108 + contextLines = n 109 + case *contextN != "": 110 + fmtKind = formatContext 111 + n, err := strconv.Atoi(*contextN) 112 + if err != nil || n < 0 { 113 + fmt.Fprintf(stderr, "diff: invalid context length '%s'\n", *contextN) 114 + return interp.ExitStatus(2) 115 + } 116 + contextLines = n 117 + case *unified: 118 + fmtKind = formatUnified 119 + case *contextFlag: 120 + fmtKind = formatContext 121 + } 64 122 65 123 files := set.Args() 66 124 if len(files) < 2 { ··· 70 128 71 129 f1, f2 := files[0], files[1] 72 130 73 - c1, err := readContent(ec, f1) 131 + opts := compareOptions{ 132 + ignoreCase: *ignoreCase, 133 + ignoreSpace: *ignoreSpace, 134 + brief: *brief, 135 + reportSame: *reportSame, 136 + recursive: *recursive, 137 + fmtKind: fmtKind, 138 + contextLines: contextLines, 139 + } 140 + 141 + // If both operands are directories, perform directory comparison. 142 + isDir1, err1 := isDirectory(ec, f1) 143 + isDir2, err2 := isDirectory(ec, f2) 144 + if err1 != nil && f1 != "-" { 145 + fmt.Fprintf(stderr, "diff: %s: No such file or directory\n", f1) 146 + return interp.ExitStatus(2) 147 + } 148 + if err2 != nil && f2 != "-" { 149 + fmt.Fprintf(stderr, "diff: %s: No such file or directory\n", f2) 150 + return interp.ExitStatus(2) 151 + } 152 + 153 + if isDir1 && isDir2 { 154 + return diffDirs(ec, stdout, stderr, f1, f2, opts) 155 + } 156 + if isDir1 || isDir2 { 157 + // If one is a directory, append the other's basename to find the 158 + // matching file under it. This is GNU's behavior. 159 + if isDir1 { 160 + f1 = path.Join(f1, path.Base(f2)) 161 + } else { 162 + f2 = path.Join(f2, path.Base(f1)) 163 + } 164 + } 165 + 166 + return diffFiles(ec, stdout, stderr, f1, f2, opts) 167 + } 168 + 169 + type compareOptions struct { 170 + ignoreCase bool 171 + ignoreSpace bool 172 + brief bool 173 + reportSame bool 174 + recursive bool 175 + fmtKind format 176 + contextLines int 177 + } 178 + 179 + func diffFiles(ec *command.ExecContext, stdout, stderr io.Writer, f1, f2 string, opts compareOptions) error { 180 + c1, t1stamp, err := readContent(ec, f1) 74 181 if err != nil { 75 182 fmt.Fprintf(stderr, "diff: %s: No such file or directory\n", f1) 76 183 return interp.ExitStatus(2) 77 184 } 78 - c2, err := readContent(ec, f2) 185 + c2, t2stamp, err := readContent(ec, f2) 79 186 if err != nil { 80 187 fmt.Fprintf(stderr, "diff: %s: No such file or directory\n", f2) 81 188 return interp.ExitStatus(2) 82 189 } 83 190 84 - t1, t2 := c1, c2 85 - if *ignoreCase { 86 - t1 = strings.ToLower(t1) 87 - t2 = strings.ToLower(t2) 191 + a := splitLines(c1) 192 + b := splitLines(c2) 193 + 194 + cmpA := normalizeLines(a, opts.ignoreCase, opts.ignoreSpace) 195 + cmpB := normalizeLines(b, opts.ignoreCase, opts.ignoreSpace) 196 + 197 + equal := len(cmpA) == len(cmpB) 198 + if equal { 199 + for i := range cmpA { 200 + if cmpA[i] != cmpB[i] { 201 + equal = false 202 + break 203 + } 204 + } 88 205 } 89 - 90 - if t1 == t2 { 91 - if *reportSame { 206 + if equal { 207 + if opts.reportSame { 92 208 fmt.Fprintf(stdout, "Files %s and %s are identical\n", f1, f2) 93 209 } 94 210 return nil 95 211 } 96 212 97 - if *brief { 213 + if opts.brief { 98 214 fmt.Fprintf(stdout, "Files %s and %s differ\n", f1, f2) 99 215 return interp.ExitStatus(1) 100 216 } 101 217 102 - udiff := difflib.UnifiedDiff{ 103 - A: splitLines(c1), 104 - B: splitLines(c2), 105 - FromFile: f1, 106 - ToFile: f2, 107 - Context: 3, 218 + matcher := difflib.NewMatcher(cmpA, cmpB) 219 + ops := matcher.GetOpCodes() 220 + 221 + switch opts.fmtKind { 222 + case formatUnified: 223 + writeUnified(stdout, f1, f2, t1stamp, t2stamp, a, b, matcher.GetGroupedOpCodes(opts.contextLines)) 224 + case formatContext: 225 + writeContext(stdout, f1, f2, t1stamp, t2stamp, a, b, matcher.GetGroupedOpCodes(opts.contextLines)) 226 + case formatEd: 227 + writeEd(stdout, a, b, ops) 228 + case formatForwardEd: 229 + writeForwardEd(stdout, a, b, ops) 230 + default: 231 + writeNormal(stdout, a, b, ops) 232 + } 233 + 234 + return interp.ExitStatus(1) 235 + } 236 + 237 + // diffDirs performs a directory comparison as GNU diff does: list both 238 + // directories, for matching names recurse (when -r) or compare files, 239 + // and emit "Only in" lines for entries unique to one side. 240 + func diffDirs(ec *command.ExecContext, stdout, stderr io.Writer, d1, d2 string, opts compareOptions) error { 241 + entries1, err := readDirNames(ec, d1) 242 + if err != nil { 243 + fmt.Fprintf(stderr, "diff: %s: %s\n", d1, err) 244 + return interp.ExitStatus(2) 108 245 } 109 - out, derr := difflib.GetUnifiedDiffString(udiff) 110 - if derr != nil { 111 - fmt.Fprintf(stderr, "diff: %s\n", derr) 246 + entries2, err := readDirNames(ec, d2) 247 + if err != nil { 248 + fmt.Fprintf(stderr, "diff: %s: %s\n", d2, err) 112 249 return interp.ExitStatus(2) 113 250 } 114 - io.WriteString(stdout, out) 115 - return interp.ExitStatus(1) 251 + 252 + set1 := make(map[string]struct{}, len(entries1)) 253 + for _, n := range entries1 { 254 + set1[n] = struct{}{} 255 + } 256 + set2 := make(map[string]struct{}, len(entries2)) 257 + for _, n := range entries2 { 258 + set2[n] = struct{}{} 259 + } 260 + 261 + all := make(map[string]struct{}, len(entries1)+len(entries2)) 262 + for n := range set1 { 263 + all[n] = struct{}{} 264 + } 265 + for n := range set2 { 266 + all[n] = struct{}{} 267 + } 268 + names := make([]string, 0, len(all)) 269 + for n := range all { 270 + names = append(names, n) 271 + } 272 + sort.Strings(names) 273 + 274 + differed := false 275 + for _, name := range names { 276 + _, in1 := set1[name] 277 + _, in2 := set2[name] 278 + p1 := path.Join(d1, name) 279 + p2 := path.Join(d2, name) 280 + 281 + switch { 282 + case in1 && !in2: 283 + fmt.Fprintf(stdout, "Only in %s: %s\n", d1, name) 284 + differed = true 285 + case !in1 && in2: 286 + fmt.Fprintf(stdout, "Only in %s: %s\n", d2, name) 287 + differed = true 288 + default: 289 + isDir1, _ := isDirectory(ec, p1) 290 + isDir2, _ := isDirectory(ec, p2) 291 + switch { 292 + case isDir1 && isDir2: 293 + if opts.recursive { 294 + if err := diffDirs(ec, stdout, stderr, p1, p2, opts); err != nil { 295 + if code := exitStatus(err); code == 1 { 296 + differed = true 297 + } else { 298 + return err 299 + } 300 + } 301 + } else { 302 + fmt.Fprintf(stdout, "Common subdirectories: %s and %s\n", p1, p2) 303 + } 304 + case isDir1 != isDir2: 305 + if isDir1 { 306 + fmt.Fprintf(stdout, "File %s is a directory while file %s is a regular file\n", p1, p2) 307 + } else { 308 + fmt.Fprintf(stdout, "File %s is a regular file while file %s is a directory\n", p1, p2) 309 + } 310 + differed = true 311 + default: 312 + // Pre-check whether files differ so we can emit the 313 + // "diff X Y" header GNU prints before the body. 314 + if !filesEqual(ec, p1, p2, opts) { 315 + if !opts.brief { 316 + fmt.Fprintf(stdout, "diff %s %s\n", p1, p2) 317 + } 318 + } 319 + if err := diffFiles(ec, stdout, stderr, p1, p2, opts); err != nil { 320 + if code := exitStatus(err); code == 1 { 321 + differed = true 322 + } else { 323 + return err 324 + } 325 + } 326 + } 327 + } 328 + } 329 + 330 + if differed { 331 + return interp.ExitStatus(1) 332 + } 333 + return nil 334 + } 335 + 336 + // filesEqual reports whether two files compare equal under the given options. 337 + // Used by directory traversal to decide whether to emit a "diff X Y" header. 338 + func filesEqual(ec *command.ExecContext, f1, f2 string, opts compareOptions) bool { 339 + c1, _, err := readContent(ec, f1) 340 + if err != nil { 341 + return false 342 + } 343 + c2, _, err := readContent(ec, f2) 344 + if err != nil { 345 + return false 346 + } 347 + a := splitLines(c1) 348 + b := splitLines(c2) 349 + cmpA := normalizeLines(a, opts.ignoreCase, opts.ignoreSpace) 350 + cmpB := normalizeLines(b, opts.ignoreCase, opts.ignoreSpace) 351 + if len(cmpA) != len(cmpB) { 352 + return false 353 + } 354 + for i := range cmpA { 355 + if cmpA[i] != cmpB[i] { 356 + return false 357 + } 358 + } 359 + return true 360 + } 361 + 362 + func exitStatus(err error) int { 363 + if err == nil { 364 + return 0 365 + } 366 + if es, ok := err.(interp.ExitStatus); ok { 367 + return int(es) 368 + } 369 + return -1 370 + } 371 + 372 + func isDirectory(ec *command.ExecContext, p string) (bool, error) { 373 + if p == "-" { 374 + return false, nil 375 + } 376 + if ec.FS == nil { 377 + return false, errors.New("no filesystem") 378 + } 379 + full := resolvePath(ec, p) 380 + info, err := ec.FS.Stat(full) 381 + if err != nil { 382 + return false, err 383 + } 384 + return info.IsDir(), nil 385 + } 386 + 387 + func readDirNames(ec *command.ExecContext, dir string) ([]string, error) { 388 + if ec.FS == nil { 389 + return nil, errors.New("no filesystem") 390 + } 391 + full := resolvePath(ec, dir) 392 + entries, err := ec.FS.ReadDir(full) 393 + if err != nil { 394 + return nil, err 395 + } 396 + names := make([]string, 0, len(entries)) 397 + for _, e := range entries { 398 + names = append(names, e.Name()) 399 + } 400 + return names, nil 116 401 } 117 402 118 403 func splitLines(s string) []string { ··· 126 411 return lines 127 412 } 128 413 129 - func readContent(ec *command.ExecContext, file string) (string, error) { 414 + func normalizeLines(lines []string, ignoreCase, ignoreSpace bool) []string { 415 + if !ignoreCase && !ignoreSpace { 416 + out := make([]string, len(lines)) 417 + copy(out, lines) 418 + return out 419 + } 420 + out := make([]string, len(lines)) 421 + for i, l := range lines { 422 + s := l 423 + if ignoreSpace { 424 + s = collapseSpace(s) 425 + } 426 + if ignoreCase { 427 + s = strings.ToLower(s) 428 + } 429 + out[i] = s 430 + } 431 + return out 432 + } 433 + 434 + func collapseSpace(s string) string { 435 + trailingNL := strings.HasSuffix(s, "\n") 436 + if trailingNL { 437 + s = s[:len(s)-1] 438 + } 439 + s = strings.TrimRightFunc(s, unicode.IsSpace) 440 + var b strings.Builder 441 + b.Grow(len(s)) 442 + prevSpace := false 443 + for _, r := range s { 444 + if unicode.IsSpace(r) { 445 + if !prevSpace { 446 + b.WriteByte(' ') 447 + } 448 + prevSpace = true 449 + continue 450 + } 451 + prevSpace = false 452 + b.WriteRune(r) 453 + } 454 + if trailingNL { 455 + b.WriteByte('\n') 456 + } 457 + return b.String() 458 + } 459 + 460 + func hasNL(s string) bool { return strings.HasSuffix(s, "\n") } 461 + 462 + // formatRange renders a 1-based inclusive line range "lo,hi" or "lo" when single. 463 + func formatRange(lo, hi int) string { 464 + if lo == hi { 465 + return strconv.Itoa(lo) 466 + } 467 + return fmt.Sprintf("%d,%d", lo, hi) 468 + } 469 + 470 + func writeNormal(w io.Writer, a, b []string, ops []difflib.OpCode) { 471 + for _, op := range ops { 472 + switch op.Tag { 473 + case 'e': 474 + continue 475 + case 'r': 476 + lhs := formatRange(op.I1+1, op.I2) 477 + rhs := formatRange(op.J1+1, op.J2) 478 + fmt.Fprintf(w, "%sc%s\n", lhs, rhs) 479 + for i := op.I1; i < op.I2; i++ { 480 + writePrefixed(w, "< ", a[i]) 481 + } 482 + fmt.Fprint(w, "---\n") 483 + for j := op.J1; j < op.J2; j++ { 484 + writePrefixed(w, "> ", b[j]) 485 + } 486 + case 'd': 487 + lhs := formatRange(op.I1+1, op.I2) 488 + fmt.Fprintf(w, "%sd%d\n", lhs, op.J1) 489 + for i := op.I1; i < op.I2; i++ { 490 + writePrefixed(w, "< ", a[i]) 491 + } 492 + case 'i': 493 + rhs := formatRange(op.J1+1, op.J2) 494 + fmt.Fprintf(w, "%da%s\n", op.I1, rhs) 495 + for j := op.J1; j < op.J2; j++ { 496 + writePrefixed(w, "> ", b[j]) 497 + } 498 + } 499 + } 500 + } 501 + 502 + // writePrefixed writes prefix + line (which may or may not end in \n). 503 + // If the line lacks a trailing newline (file with no final \n), GNU diff 504 + // emits "\ No newline at end of file" on the next line. 505 + func writePrefixed(w io.Writer, prefix, line string) { 506 + if hasNL(line) { 507 + io.WriteString(w, prefix) 508 + io.WriteString(w, line) 509 + return 510 + } 511 + io.WriteString(w, prefix) 512 + io.WriteString(w, line) 513 + io.WriteString(w, "\n\\ No newline at end of file\n") 514 + } 515 + 516 + func writeEd(w io.Writer, _, b []string, ops []difflib.OpCode) { 517 + type hunk struct { 518 + header string 519 + body []string 520 + } 521 + var hunks []hunk 522 + for _, op := range ops { 523 + switch op.Tag { 524 + case 'e': 525 + continue 526 + case 'r': 527 + h := hunk{header: fmt.Sprintf("%sc\n", formatRange(op.I1+1, op.I2))} 528 + for j := op.J1; j < op.J2; j++ { 529 + h.body = append(h.body, b[j]) 530 + } 531 + h.body = append(h.body, ".\n") 532 + hunks = append(hunks, h) 533 + case 'd': 534 + hunks = append(hunks, hunk{header: fmt.Sprintf("%sd\n", formatRange(op.I1+1, op.I2))}) 535 + case 'i': 536 + h := hunk{header: fmt.Sprintf("%da\n", op.I1)} 537 + for j := op.J1; j < op.J2; j++ { 538 + h.body = append(h.body, b[j]) 539 + } 540 + h.body = append(h.body, ".\n") 541 + hunks = append(hunks, h) 542 + } 543 + } 544 + for i := len(hunks) - 1; i >= 0; i-- { 545 + io.WriteString(w, hunks[i].header) 546 + for _, line := range hunks[i].body { 547 + s := line 548 + if !hasNL(s) { 549 + s += "\n" 550 + } 551 + io.WriteString(w, s) 552 + } 553 + } 554 + } 555 + 556 + // writeForwardEd emits a forward ed script (-f). Like -e but hunks appear 557 + // in forward order and the command letter precedes the range: 558 + // c<range>, a<line>, d<range> 559 + // Note: output is not directly executable by ed; it's intended for tools 560 + // that want top-to-bottom ordering with ed-like syntax. 561 + func writeForwardEd(w io.Writer, _, b []string, ops []difflib.OpCode) { 562 + for _, op := range ops { 563 + switch op.Tag { 564 + case 'e': 565 + continue 566 + case 'r': 567 + fmt.Fprintf(w, "c%s\n", formatRange(op.I1+1, op.I2)) 568 + for j := op.J1; j < op.J2; j++ { 569 + s := b[j] 570 + if !hasNL(s) { 571 + s += "\n" 572 + } 573 + io.WriteString(w, s) 574 + } 575 + io.WriteString(w, ".\n") 576 + case 'd': 577 + fmt.Fprintf(w, "d%s\n", formatRange(op.I1+1, op.I2)) 578 + case 'i': 579 + fmt.Fprintf(w, "a%d\n", op.I1) 580 + for j := op.J1; j < op.J2; j++ { 581 + s := b[j] 582 + if !hasNL(s) { 583 + s += "\n" 584 + } 585 + io.WriteString(w, s) 586 + } 587 + io.WriteString(w, ".\n") 588 + } 589 + } 590 + } 591 + 592 + func unifiedRange(start, count int) string { 593 + if count == 0 { 594 + return fmt.Sprintf("%d,0", start) 595 + } 596 + if count == 1 { 597 + return strconv.Itoa(start + 1) 598 + } 599 + return fmt.Sprintf("%d,%d", start+1, count) 600 + } 601 + 602 + func writeUnified(w io.Writer, f1, f2, t1, t2 string, a, b []string, groups [][]difflib.OpCode) { 603 + if len(groups) == 0 { 604 + return 605 + } 606 + fmt.Fprintf(w, "--- %s\t%s\n", f1, t1) 607 + fmt.Fprintf(w, "+++ %s\t%s\n", f2, t2) 608 + for _, g := range groups { 609 + first, last := g[0], g[len(g)-1] 610 + fmt.Fprintf(w, "@@ -%s +%s @@\n", 611 + unifiedRange(first.I1, last.I2-first.I1), 612 + unifiedRange(first.J1, last.J2-first.J1), 613 + ) 614 + for _, op := range g { 615 + switch op.Tag { 616 + case 'e': 617 + for i := op.I1; i < op.I2; i++ { 618 + writePrefixed(w, " ", a[i]) 619 + } 620 + case 'r': 621 + for i := op.I1; i < op.I2; i++ { 622 + writePrefixed(w, "-", a[i]) 623 + } 624 + for j := op.J1; j < op.J2; j++ { 625 + writePrefixed(w, "+", b[j]) 626 + } 627 + case 'd': 628 + for i := op.I1; i < op.I2; i++ { 629 + writePrefixed(w, "-", a[i]) 630 + } 631 + case 'i': 632 + for j := op.J1; j < op.J2; j++ { 633 + writePrefixed(w, "+", b[j]) 634 + } 635 + } 636 + } 637 + } 638 + } 639 + 640 + func contextRange(lo, hi int) string { 641 + if hi < lo { 642 + return strconv.Itoa(hi) 643 + } 644 + if lo == hi { 645 + return strconv.Itoa(lo) 646 + } 647 + return fmt.Sprintf("%d,%d", lo, hi) 648 + } 649 + 650 + func writeContext(w io.Writer, f1, f2, t1, t2 string, a, b []string, groups [][]difflib.OpCode) { 651 + if len(groups) == 0 { 652 + return 653 + } 654 + fmt.Fprintf(w, "*** %s\t%s\n", f1, t1) 655 + fmt.Fprintf(w, "--- %s\t%s\n", f2, t2) 656 + for _, g := range groups { 657 + fmt.Fprint(w, "***************\n") 658 + first, last := g[0], g[len(g)-1] 659 + 660 + aLo, aHi := first.I1+1, last.I2 661 + if last.I2 == first.I1 { 662 + aLo, aHi = first.I1, first.I1 663 + } 664 + bLo, bHi := first.J1+1, last.J2 665 + if last.J2 == first.J1 { 666 + bLo, bHi = first.J1, first.J1 667 + } 668 + 669 + hasADel := false 670 + hasBAdd := false 671 + for _, op := range g { 672 + switch op.Tag { 673 + case 'r': 674 + hasADel = true 675 + hasBAdd = true 676 + case 'd': 677 + hasADel = true 678 + case 'i': 679 + hasBAdd = true 680 + } 681 + } 682 + 683 + fmt.Fprintf(w, "*** %s ****\n", contextRange(aLo, aHi)) 684 + if hasADel { 685 + for _, op := range g { 686 + switch op.Tag { 687 + case 'e': 688 + for i := op.I1; i < op.I2; i++ { 689 + writePrefixed(w, " ", a[i]) 690 + } 691 + case 'r': 692 + for i := op.I1; i < op.I2; i++ { 693 + writePrefixed(w, "! ", a[i]) 694 + } 695 + case 'd': 696 + for i := op.I1; i < op.I2; i++ { 697 + writePrefixed(w, "- ", a[i]) 698 + } 699 + } 700 + } 701 + } 702 + fmt.Fprintf(w, "--- %s ----\n", contextRange(bLo, bHi)) 703 + if hasBAdd { 704 + for _, op := range g { 705 + switch op.Tag { 706 + case 'e': 707 + for j := op.J1; j < op.J2; j++ { 708 + writePrefixed(w, " ", b[j]) 709 + } 710 + case 'r': 711 + for j := op.J1; j < op.J2; j++ { 712 + writePrefixed(w, "! ", b[j]) 713 + } 714 + case 'i': 715 + for j := op.J1; j < op.J2; j++ { 716 + writePrefixed(w, "+ ", b[j]) 717 + } 718 + } 719 + } 720 + } 721 + } 722 + } 723 + 724 + func readContent(ec *command.ExecContext, file string) (string, string, error) { 725 + now := nowFunc() 130 726 if file == "-" { 131 727 if ec.Stdin == nil { 132 - return "", nil 728 + return "", formatTime(now), nil 133 729 } 134 730 data, err := io.ReadAll(ec.Stdin) 135 731 if err != nil { 136 - return "", err 732 + return "", "", err 137 733 } 138 - return string(data), nil 734 + return string(data), formatTime(now), nil 139 735 } 140 736 if ec.FS == nil { 141 - return "", errors.New("no filesystem") 737 + return "", "", errors.New("no filesystem") 142 738 } 143 739 full := resolvePath(ec, file) 740 + stamp := formatTime(now) 144 741 f, err := ec.FS.Open(full) 145 742 if err != nil { 146 - return "", err 743 + return "", "", err 147 744 } 148 745 defer f.Close() 149 746 data, err := io.ReadAll(f) 150 747 if err != nil { 151 - return "", err 748 + return "", "", err 152 749 } 153 - return string(data), nil 750 + return string(data), stamp, nil 751 + } 752 + 753 + func formatTime(t time.Time) string { 754 + return t.Format("2006-01-02 15:04:05.000000000 -0700") 154 755 } 155 756 156 757 func resolvePath(ec *command.ExecContext, p string) string {
+277 -11
command/internal/diff/diff_test.go
··· 6 6 "os" 7 7 "strings" 8 8 "testing" 9 + "time" 9 10 10 11 "github.com/go-git/go-billy/v5" 11 12 "github.com/go-git/go-billy/v5/memfs" 12 13 "tangled.org/xeiaso.net/kefka/command" 13 14 ) 15 + 16 + const fixedStamp = "2024-01-02 03:04:05.000000000 +0000" 17 + 18 + func init() { 19 + t, _ := time.Parse("2006-01-02 15:04:05 -0700", "2024-01-02 03:04:05 +0000") 20 + nowFunc = func() time.Time { return t } 21 + } 14 22 15 23 func newFS(t *testing.T) billy.Filesystem { 16 24 t.Helper() ··· 28 36 write("a_copy.txt", []byte("alpha\nbeta\ngamma\n")) 29 37 write("upper.txt", []byte("ALPHA\nBETA\nGAMMA\n")) 30 38 write("lower.txt", []byte("alpha\nbeta\ngamma\n")) 39 + write("ws1.txt", []byte("a b\n")) 40 + write("ws2.txt", []byte("a b\n")) 41 + write("multi1.txt", []byte("a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm\nn\n")) 42 + write("multi2.txt", []byte("a\nB\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\nM\nn\n")) 43 + write("empty.txt", []byte("")) 44 + write("one.txt", []byte("alpha\n")) 45 + // Directories for -r tests 46 + write("dir1/same.txt", []byte("hello\n")) 47 + write("dir1/different.txt", []byte("a\nb\nc\n")) 48 + write("dir1/only1.txt", []byte("only-1\n")) 49 + write("dir1/sub/inner.txt", []byte("nested\n")) 50 + write("dir2/same.txt", []byte("hello\n")) 51 + write("dir2/different.txt", []byte("a\nB\nc\n")) 52 + write("dir2/only2.txt", []byte("only-2\n")) 53 + write("dir2/sub/inner.txt", []byte("nested\n")) 54 + // Identical directory pair for the "same" exit-code path 55 + write("samedir1/x.txt", []byte("x\n")) 56 + write("samedir2/x.txt", []byte("x\n")) 31 57 return fs 32 58 } 33 59 ··· 83 109 wantErr: true, 84 110 }, 85 111 { 86 - name: "different files unified default", 112 + name: "different files default normal format", 87 113 args: []string{"a.txt", "b.txt"}, 88 - wantStdout: "--- a.txt\n" + 89 - "+++ b.txt\n" + 114 + wantStdout: "2c2\n" + 115 + "< beta\n" + 116 + "---\n" + 117 + "> BETA\n", 118 + wantErr: true, 119 + }, 120 + { 121 + name: "explicit -u flag emits unified format", 122 + args: []string{"-u", "a.txt", "b.txt"}, 123 + wantStdout: "--- a.txt\t" + fixedStamp + "\n" + 124 + "+++ b.txt\t" + fixedStamp + "\n" + 90 125 "@@ -1,3 +1,3 @@\n" + 91 126 " alpha\n" + 92 127 "-beta\n" + ··· 95 130 wantErr: true, 96 131 }, 97 132 { 98 - name: "explicit -u flag matches default", 99 - args: []string{"-u", "a.txt", "b.txt"}, 100 - wantStdout: "--- a.txt\n" + 101 - "+++ b.txt\n" + 133 + name: "-U with explicit context size", 134 + args: []string{"-U", "1", "a.txt", "b.txt"}, 135 + wantStdout: "--- a.txt\t" + fixedStamp + "\n" + 136 + "+++ b.txt\t" + fixedStamp + "\n" + 102 137 "@@ -1,3 +1,3 @@\n" + 103 138 " alpha\n" + 104 139 "-beta\n" + ··· 107 142 wantErr: true, 108 143 }, 109 144 { 145 + name: "-U 0 emits no context", 146 + args: []string{"-U", "0", "a.txt", "b.txt"}, 147 + wantStdout: "--- a.txt\t" + fixedStamp + "\n" + 148 + "+++ b.txt\t" + fixedStamp + "\n" + 149 + "@@ -2 +2 @@\n" + 150 + "-beta\n" + 151 + "+BETA\n", 152 + wantErr: true, 153 + }, 154 + { 155 + name: "explicit -c flag emits context format", 156 + args: []string{"-c", "a.txt", "b.txt"}, 157 + wantStdout: "*** a.txt\t" + fixedStamp + "\n" + 158 + "--- b.txt\t" + fixedStamp + "\n" + 159 + "***************\n" + 160 + "*** 1,3 ****\n" + 161 + " alpha\n" + 162 + "! beta\n" + 163 + " gamma\n" + 164 + "--- 1,3 ----\n" + 165 + " alpha\n" + 166 + "! BETA\n" + 167 + " gamma\n", 168 + wantErr: true, 169 + }, 170 + { 171 + name: "-C with explicit context size", 172 + args: []string{"-C", "1", "a.txt", "b.txt"}, 173 + wantStdout: "*** a.txt\t" + fixedStamp + "\n" + 174 + "--- b.txt\t" + fixedStamp + "\n" + 175 + "***************\n" + 176 + "*** 1,3 ****\n" + 177 + " alpha\n" + 178 + "! beta\n" + 179 + " gamma\n" + 180 + "--- 1,3 ----\n" + 181 + " alpha\n" + 182 + "! BETA\n" + 183 + " gamma\n", 184 + wantErr: true, 185 + }, 186 + { 187 + name: "-e ed script for change", 188 + args: []string{"-e", "a.txt", "b.txt"}, 189 + wantStdout: "2c\n" + 190 + "BETA\n" + 191 + ".\n", 192 + wantErr: true, 193 + }, 194 + { 195 + name: "-e ed script in reverse order", 196 + args: []string{"-e", "multi1.txt", "multi2.txt"}, 197 + wantStdout: "13c\n" + 198 + "M\n" + 199 + ".\n" + 200 + "2c\n" + 201 + "B\n" + 202 + ".\n", 203 + wantErr: true, 204 + }, 205 + { 206 + name: "-e ed script for add to empty", 207 + args: []string{"-e", "empty.txt", "one.txt"}, 208 + wantStdout: "0a\n" + 209 + "alpha\n" + 210 + ".\n", 211 + wantErr: true, 212 + }, 213 + { 214 + name: "-e ed script for delete to empty", 215 + args: []string{"-e", "one.txt", "empty.txt"}, 216 + wantStdout: "1d\n", 217 + wantErr: true, 218 + }, 219 + { 220 + name: "-b ignores whitespace amount differences", 221 + args: []string{"-b", "ws1.txt", "ws2.txt"}, 222 + wantStdout: "", 223 + }, 224 + { 110 225 name: "ignore case treats differently-cased files as identical", 111 226 args: []string{"-i", "lower.txt", "upper.txt"}, 112 227 wantStdout: "", ··· 122 237 wantStdout: "", 123 238 }, 124 239 { 125 - name: "stdin via dash for first file", 126 - args: []string{"-", "b.txt"}, 240 + name: "stdin via dash for first file with -u", 241 + args: []string{"-u", "-", "b.txt"}, 127 242 stdin: "alpha\nbeta\ngamma\n", 128 - wantStdout: "--- -\n" + 129 - "+++ b.txt\n" + 243 + wantStdout: "--- -\t" + fixedStamp + "\n" + 244 + "+++ b.txt\t" + fixedStamp + "\n" + 130 245 "@@ -1,3 +1,3 @@\n" + 131 246 " alpha\n" + 132 247 "-beta\n" + ··· 135 250 wantErr: true, 136 251 }, 137 252 { 253 + name: "default normal format for add", 254 + args: []string{"empty.txt", "one.txt"}, 255 + wantStdout: "0a1\n" + 256 + "> alpha\n", 257 + wantErr: true, 258 + }, 259 + { 260 + name: "default normal format for delete", 261 + args: []string{"one.txt", "empty.txt"}, 262 + wantStdout: "1d0\n" + 263 + "< alpha\n", 264 + wantErr: true, 265 + }, 266 + { 138 267 name: "missing operand exits 2", 139 268 args: []string{"a.txt"}, 140 269 wantStderr: "diff: missing operand\n", ··· 162 291 args: []string{"--", "a.txt", "a_copy.txt"}, 163 292 wantStdout: "", 164 293 }, 294 + { 295 + name: "invalid context length errors", 296 + args: []string{"-U", "abc", "a.txt", "b.txt"}, 297 + wantStderr: "diff: invalid context length 'abc'\n", 298 + wantErr: true, 299 + }, 300 + { 301 + name: "-f forward ed script for change", 302 + args: []string{"-f", "a.txt", "b.txt"}, 303 + wantStdout: "c2\n" + 304 + "BETA\n" + 305 + ".\n", 306 + wantErr: true, 307 + }, 308 + { 309 + name: "-f forward ed script preserves forward order", 310 + args: []string{"-f", "multi1.txt", "multi2.txt"}, 311 + wantStdout: "c2\n" + 312 + "B\n" + 313 + ".\n" + 314 + "c13\n" + 315 + "M\n" + 316 + ".\n", 317 + wantErr: true, 318 + }, 319 + { 320 + name: "-f forward ed for add", 321 + args: []string{"-f", "empty.txt", "one.txt"}, 322 + wantStdout: "a0\n" + 323 + "alpha\n" + 324 + ".\n", 325 + wantErr: true, 326 + }, 327 + { 328 + name: "-f forward ed for delete", 329 + args: []string{"-f", "one.txt", "empty.txt"}, 330 + wantStdout: "d1\n", 331 + wantErr: true, 332 + }, 333 + { 334 + name: "directory comparison reports differences", 335 + args: []string{"dir1", "dir2"}, 336 + wantStdout: "diff dir1/different.txt dir2/different.txt\n" + 337 + "2c2\n" + 338 + "< b\n" + 339 + "---\n" + 340 + "> B\n" + 341 + "Only in dir1: only1.txt\n" + 342 + "Only in dir2: only2.txt\n" + 343 + "Common subdirectories: dir1/sub and dir2/sub\n", 344 + wantErr: true, 345 + }, 346 + { 347 + name: "recursive -r descends into common subdirectories", 348 + args: []string{"-r", "dir1", "dir2"}, 349 + wantStdout: "diff dir1/different.txt dir2/different.txt\n" + 350 + "2c2\n" + 351 + "< b\n" + 352 + "---\n" + 353 + "> B\n" + 354 + "Only in dir1: only1.txt\n" + 355 + "Only in dir2: only2.txt\n", 356 + wantErr: true, 357 + }, 358 + { 359 + name: "identical directory tree -r exits 0", 360 + args: []string{"-r", "samedir1", "samedir2"}, 361 + wantStdout: "", 362 + }, 363 + { 364 + name: "identical directories with -s reports identical files", 365 + args: []string{"-s", "samedir1", "samedir2"}, 366 + wantStdout: "Files samedir1/x.txt and samedir2/x.txt are identical\n", 367 + }, 368 + { 369 + name: "directory vs file appends basename to dir", 370 + args: []string{"dir1", "dir2/different.txt"}, 371 + wantStdout: "2c2\n" + 372 + "< b\n" + 373 + "---\n" + 374 + "> B\n", 375 + wantErr: true, 376 + }, 165 377 } 166 378 167 379 for _, tt := range tests { ··· 184 396 } 185 397 } 186 398 399 + func TestExitStatus(t *testing.T) { 400 + tests := []struct { 401 + name string 402 + args []string 403 + wantCode int 404 + }{ 405 + {name: "identical files exit 0", args: []string{"a.txt", "a_copy.txt"}, wantCode: 0}, 406 + {name: "different files exit 1", args: []string{"a.txt", "b.txt"}, wantCode: 1}, 407 + {name: "missing file exit 2", args: []string{"nope.txt", "a.txt"}, wantCode: 2}, 408 + } 409 + for _, tt := range tests { 410 + t.Run(tt.name, func(t *testing.T) { 411 + _, _, err := run(t, tt.args, "", newFS(t)) 412 + got := exitCode(err) 413 + if got != tt.wantCode { 414 + t.Errorf("exit code: want %d, got %d (err=%v)", tt.wantCode, got, err) 415 + } 416 + }) 417 + } 418 + } 419 + 420 + // exitCode extracts the integer exit status from an error returned by Exec. 421 + // 0 = no error; matches mvdan.cc/sh/v3/interp.ExitStatus encoding. 422 + func exitCode(err error) int { 423 + if err == nil { 424 + return 0 425 + } 426 + type exitStatus interface{ Error() string } 427 + _ = exitStatus(nil) 428 + // interp.ExitStatus is uint8 implementing error. 429 + if es, ok := err.(interface{ Error() string }); ok { 430 + s := es.Error() 431 + // "exit status N" 432 + const prefix = "exit status " 433 + if strings.HasPrefix(s, prefix) { 434 + n := 0 435 + for _, c := range s[len(prefix):] { 436 + if c < '0' || c > '9' { 437 + break 438 + } 439 + n = n*10 + int(c-'0') 440 + } 441 + return n 442 + } 443 + } 444 + return -1 445 + } 446 + 187 447 func TestHelp(t *testing.T) { 188 448 stdout, stderr, err := run(t, []string{"--help"}, "", newFS(t)) 189 449 if err != nil { ··· 197 457 } 198 458 if !strings.Contains(stderr, "--brief") { 199 459 t.Errorf("brief flag missing from help: %q", stderr) 460 + } 461 + if !strings.Contains(stderr, "--ignore-space-change") { 462 + t.Errorf("-b help missing: %q", stderr) 463 + } 464 + if !strings.Contains(stderr, "--ed") { 465 + t.Errorf("-e help missing: %q", stderr) 200 466 } 201 467 } 202 468