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(command): port diff from just-bash

Compare two files line by line. Mirrors the just-bash behavior: -i
only governs the equality test (the unified diff itself is computed
against the original-case content), -q produces the brief differ-
or-not summary, and exit codes follow GNU diff (0 same, 1 differ,
2 trouble) rather than the coreutils-style 0/1/2 split. The
unified diff is generated via pmezard/go-difflib instead of the
jsdiff JS library, so the hunk algorithm and header line lack the
jsdiff-only "===" banner — the output is still a valid unified
patch.

Signed-off-by: Xe Iaso <me@xeiaso.net>
Assisted-by: Claude Opus 4.7 via Claude Code

Xe Iaso dce3bcc4 a8ec6db6

+383
+173
command/internal/diff/diff.go
··· 1 + package diff 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "path" 9 + "strings" 10 + 11 + "github.com/pborman/getopt/v2" 12 + "github.com/pmezard/go-difflib/difflib" 13 + "mvdan.cc/sh/v3/interp" 14 + "tangled.org/xeiaso.net/kefka/command" 15 + ) 16 + 17 + type Impl struct{} 18 + 19 + func (Impl) Exec(ctx context.Context, ec *command.ExecContext, args []string) error { 20 + if ec == nil { 21 + return errors.New("diff: nil ExecContext") 22 + } 23 + 24 + stdout := ec.Stdout 25 + if stdout == nil { 26 + stdout = io.Discard 27 + } 28 + stderr := ec.Stderr 29 + if stderr == nil { 30 + stderr = io.Discard 31 + } 32 + 33 + set := getopt.New() 34 + set.SetProgram("diff") 35 + set.SetParameters("FILE1 FILE2") 36 + 37 + usage := func() { 38 + fmt.Fprint(stderr, "Usage: diff [OPTION]... FILE1 FILE2\n") 39 + fmt.Fprint(stderr, "Compare files line by line.\n\n") 40 + fmt.Fprint(stderr, " -u, --unified output unified diff format (default)\n") 41 + fmt.Fprint(stderr, " -q, --brief report only whether files differ\n") 42 + fmt.Fprint(stderr, " -s, --report-identical-files report when files are the same\n") 43 + fmt.Fprint(stderr, " -i, --ignore-case ignore case differences\n") 44 + fmt.Fprint(stderr, " --help display this help and exit\n") 45 + } 46 + set.SetUsage(usage) 47 + 48 + unified := set.BoolLong("unified", 'u', "output unified diff format (default)") 49 + brief := set.BoolLong("brief", 'q', "report only whether files differ") 50 + reportSame := set.BoolLong("report-identical-files", 's', "report when files are the same") 51 + ignoreCase := set.BoolLong("ignore-case", 'i', "ignore case differences") 52 + help := set.BoolLong("help", 0, "display this help and exit") 53 + 54 + if err := set.Getopt(append([]string{"diff"}, args...), nil); err != nil { 55 + fmt.Fprintf(stderr, "diff: %s\n", err) 56 + usage() 57 + return interp.ExitStatus(2) 58 + } 59 + if *help { 60 + usage() 61 + return nil 62 + } 63 + _ = unified 64 + 65 + files := set.Args() 66 + if len(files) < 2 { 67 + fmt.Fprint(stderr, "diff: missing operand\n") 68 + return interp.ExitStatus(2) 69 + } 70 + 71 + f1, f2 := files[0], files[1] 72 + 73 + c1, err := readContent(ec, f1) 74 + if err != nil { 75 + fmt.Fprintf(stderr, "diff: %s: No such file or directory\n", f1) 76 + return interp.ExitStatus(2) 77 + } 78 + c2, err := readContent(ec, f2) 79 + if err != nil { 80 + fmt.Fprintf(stderr, "diff: %s: No such file or directory\n", f2) 81 + return interp.ExitStatus(2) 82 + } 83 + 84 + t1, t2 := c1, c2 85 + if *ignoreCase { 86 + t1 = strings.ToLower(t1) 87 + t2 = strings.ToLower(t2) 88 + } 89 + 90 + if t1 == t2 { 91 + if *reportSame { 92 + fmt.Fprintf(stdout, "Files %s and %s are identical\n", f1, f2) 93 + } 94 + return nil 95 + } 96 + 97 + if *brief { 98 + fmt.Fprintf(stdout, "Files %s and %s differ\n", f1, f2) 99 + return interp.ExitStatus(1) 100 + } 101 + 102 + udiff := difflib.UnifiedDiff{ 103 + A: splitLines(c1), 104 + B: splitLines(c2), 105 + FromFile: f1, 106 + ToFile: f2, 107 + Context: 3, 108 + } 109 + out, derr := difflib.GetUnifiedDiffString(udiff) 110 + if derr != nil { 111 + fmt.Fprintf(stderr, "diff: %s\n", derr) 112 + return interp.ExitStatus(2) 113 + } 114 + io.WriteString(stdout, out) 115 + return interp.ExitStatus(1) 116 + } 117 + 118 + func splitLines(s string) []string { 119 + if s == "" { 120 + return nil 121 + } 122 + lines := strings.SplitAfter(s, "\n") 123 + if lines[len(lines)-1] == "" { 124 + return lines[:len(lines)-1] 125 + } 126 + return lines 127 + } 128 + 129 + func readContent(ec *command.ExecContext, file string) (string, error) { 130 + if file == "-" { 131 + if ec.Stdin == nil { 132 + return "", nil 133 + } 134 + data, err := io.ReadAll(ec.Stdin) 135 + if err != nil { 136 + return "", err 137 + } 138 + return string(data), nil 139 + } 140 + if ec.FS == nil { 141 + return "", errors.New("no filesystem") 142 + } 143 + full := resolvePath(ec, file) 144 + f, err := ec.FS.Open(full) 145 + if err != nil { 146 + return "", err 147 + } 148 + defer f.Close() 149 + data, err := io.ReadAll(f) 150 + if err != nil { 151 + return "", err 152 + } 153 + return string(data), nil 154 + } 155 + 156 + func resolvePath(ec *command.ExecContext, p string) string { 157 + dir := ec.Dir 158 + if dir == "" { 159 + dir = "." 160 + } 161 + if path.IsAbs(p) { 162 + p = strings.TrimPrefix(p, "/") 163 + if p == "" { 164 + return "." 165 + } 166 + return path.Clean(p) 167 + } 168 + joined := path.Join(dir, p) 169 + if joined == "" { 170 + return "." 171 + } 172 + return joined 173 + }
+207
command/internal/diff/diff_test.go
··· 1 + package diff 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "os" 7 + "strings" 8 + "testing" 9 + 10 + "github.com/go-git/go-billy/v5" 11 + "github.com/go-git/go-billy/v5/memfs" 12 + "tangled.org/xeiaso.net/kefka/command" 13 + ) 14 + 15 + func newFS(t *testing.T) billy.Filesystem { 16 + t.Helper() 17 + fs := memfs.New() 18 + write := func(name string, data []byte) { 19 + f, err := fs.OpenFile(name, os.O_CREATE|os.O_WRONLY, 0o644) 20 + if err != nil { 21 + t.Fatal(err) 22 + } 23 + f.Write(data) 24 + f.Close() 25 + } 26 + write("a.txt", []byte("alpha\nbeta\ngamma\n")) 27 + write("b.txt", []byte("alpha\nBETA\ngamma\n")) 28 + write("a_copy.txt", []byte("alpha\nbeta\ngamma\n")) 29 + write("upper.txt", []byte("ALPHA\nBETA\nGAMMA\n")) 30 + write("lower.txt", []byte("alpha\nbeta\ngamma\n")) 31 + return fs 32 + } 33 + 34 + func run(t *testing.T, args []string, stdin string, fs billy.Filesystem) (string, string, error) { 35 + t.Helper() 36 + var stdout, stderr bytes.Buffer 37 + ec := &command.ExecContext{ 38 + Stdin: strings.NewReader(stdin), 39 + Stdout: &stdout, 40 + Stderr: &stderr, 41 + Dir: ".", 42 + FS: fs, 43 + } 44 + err := Impl{}.Exec(context.Background(), ec, args) 45 + return stdout.String(), stderr.String(), err 46 + } 47 + 48 + func TestDiff(t *testing.T) { 49 + tests := []struct { 50 + name string 51 + args []string 52 + stdin string 53 + wantStdout string 54 + wantStderr string 55 + wantErr bool 56 + }{ 57 + { 58 + name: "identical files no output", 59 + args: []string{"a.txt", "a_copy.txt"}, 60 + wantStdout: "", 61 + wantStderr: "", 62 + }, 63 + { 64 + name: "identical files with -s reports identical", 65 + args: []string{"-s", "a.txt", "a_copy.txt"}, 66 + wantStdout: "Files a.txt and a_copy.txt are identical\n", 67 + }, 68 + { 69 + name: "long form report-identical-files", 70 + args: []string{"--report-identical-files", "a.txt", "a_copy.txt"}, 71 + wantStdout: "Files a.txt and a_copy.txt are identical\n", 72 + }, 73 + { 74 + name: "different files brief mode", 75 + args: []string{"-q", "a.txt", "b.txt"}, 76 + wantStdout: "Files a.txt and b.txt differ\n", 77 + wantErr: true, 78 + }, 79 + { 80 + name: "long form brief", 81 + args: []string{"--brief", "a.txt", "b.txt"}, 82 + wantStdout: "Files a.txt and b.txt differ\n", 83 + wantErr: true, 84 + }, 85 + { 86 + name: "different files unified default", 87 + args: []string{"a.txt", "b.txt"}, 88 + wantStdout: "--- a.txt\n" + 89 + "+++ b.txt\n" + 90 + "@@ -1,3 +1,3 @@\n" + 91 + " alpha\n" + 92 + "-beta\n" + 93 + "+BETA\n" + 94 + " gamma\n", 95 + wantErr: true, 96 + }, 97 + { 98 + name: "explicit -u flag matches default", 99 + args: []string{"-u", "a.txt", "b.txt"}, 100 + wantStdout: "--- a.txt\n" + 101 + "+++ b.txt\n" + 102 + "@@ -1,3 +1,3 @@\n" + 103 + " alpha\n" + 104 + "-beta\n" + 105 + "+BETA\n" + 106 + " gamma\n", 107 + wantErr: true, 108 + }, 109 + { 110 + name: "ignore case treats differently-cased files as identical", 111 + args: []string{"-i", "lower.txt", "upper.txt"}, 112 + wantStdout: "", 113 + }, 114 + { 115 + name: "ignore case with -s reports identical", 116 + args: []string{"-i", "-s", "lower.txt", "upper.txt"}, 117 + wantStdout: "Files lower.txt and upper.txt are identical\n", 118 + }, 119 + { 120 + name: "long form ignore-case", 121 + args: []string{"--ignore-case", "lower.txt", "upper.txt"}, 122 + wantStdout: "", 123 + }, 124 + { 125 + name: "stdin via dash for first file", 126 + args: []string{"-", "b.txt"}, 127 + stdin: "alpha\nbeta\ngamma\n", 128 + wantStdout: "--- -\n" + 129 + "+++ b.txt\n" + 130 + "@@ -1,3 +1,3 @@\n" + 131 + " alpha\n" + 132 + "-beta\n" + 133 + "+BETA\n" + 134 + " gamma\n", 135 + wantErr: true, 136 + }, 137 + { 138 + name: "missing operand exits 2", 139 + args: []string{"a.txt"}, 140 + wantStderr: "diff: missing operand\n", 141 + wantErr: true, 142 + }, 143 + { 144 + name: "missing first file errors", 145 + args: []string{"nope.txt", "a.txt"}, 146 + wantStderr: "diff: nope.txt: No such file or directory\n", 147 + wantErr: true, 148 + }, 149 + { 150 + name: "missing second file errors", 151 + args: []string{"a.txt", "nope.txt"}, 152 + wantStderr: "diff: nope.txt: No such file or directory\n", 153 + wantErr: true, 154 + }, 155 + { 156 + name: "unknown flag errors", 157 + args: []string{"--no-such-flag", "a.txt", "b.txt"}, 158 + wantErr: true, 159 + }, 160 + { 161 + name: "double dash terminator", 162 + args: []string{"--", "a.txt", "a_copy.txt"}, 163 + wantStdout: "", 164 + }, 165 + } 166 + 167 + for _, tt := range tests { 168 + t.Run(tt.name, func(t *testing.T) { 169 + stdout, stderr, err := run(t, tt.args, tt.stdin, newFS(t)) 170 + if tt.wantErr { 171 + if err == nil { 172 + t.Fatalf("expected error, got nil; stdout=%q stderr=%q", stdout, stderr) 173 + } 174 + } else if err != nil { 175 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 176 + } 177 + if stdout != tt.wantStdout { 178 + t.Errorf("stdout mismatch\nwant: %q\ngot: %q", tt.wantStdout, stdout) 179 + } 180 + if tt.wantStderr != "" && stderr != tt.wantStderr { 181 + t.Errorf("stderr mismatch\nwant: %q\ngot: %q", tt.wantStderr, stderr) 182 + } 183 + }) 184 + } 185 + } 186 + 187 + func TestHelp(t *testing.T) { 188 + stdout, stderr, err := run(t, []string{"--help"}, "", newFS(t)) 189 + if err != nil { 190 + t.Fatalf("unexpected error: %v", err) 191 + } 192 + if stdout != "" { 193 + t.Errorf("expected empty stdout, got %q", stdout) 194 + } 195 + if !strings.Contains(stderr, "Usage: diff [OPTION]... FILE1 FILE2") { 196 + t.Errorf("usage line missing from stderr: %q", stderr) 197 + } 198 + if !strings.Contains(stderr, "--brief") { 199 + t.Errorf("brief flag missing from help: %q", stderr) 200 + } 201 + } 202 + 203 + func TestNilExecContext(t *testing.T) { 204 + if err := (Impl{}).Exec(context.Background(), nil, nil); err == nil { 205 + t.Fatal("expected error for nil ExecContext") 206 + } 207 + }
+2
command/registry/coreutils/coreutils.go
··· 9 9 "tangled.org/xeiaso.net/kefka/command/internal/cp" 10 10 "tangled.org/xeiaso.net/kefka/command/internal/cut" 11 11 "tangled.org/xeiaso.net/kefka/command/internal/date" 12 + "tangled.org/xeiaso.net/kefka/command/internal/diff" 12 13 "tangled.org/xeiaso.net/kefka/command/internal/falsecmd" 13 14 "tangled.org/xeiaso.net/kefka/command/internal/hostname" 14 15 "tangled.org/xeiaso.net/kefka/command/internal/ls" ··· 25 26 reg.Register("cp", cp.Impl{}) 26 27 reg.Register("cut", cut.Impl{}) 27 28 reg.Register("date", date.Impl{}) 29 + reg.Register("diff", diff.Impl{}) 28 30 reg.Register("false", falsecmd.Impl{}) 29 31 reg.Register("hostname", hostname.Impl{}) 30 32 reg.Register("ls", ls.Impl{})
+1
go.mod
··· 5 5 require ( 6 6 github.com/go-git/go-billy/v5 v5.8.0 7 7 github.com/pborman/getopt/v2 v2.1.0 8 + github.com/pmezard/go-difflib v1.0.0 8 9 github.com/spf13/pflag v1.0.10 9 10 github.com/tetratelabs/wazero v1.11.0 10 11 golang.org/x/term v0.41.0