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 touch from just-bash

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

Xe Iaso 03a3ced9 04aa2bce

+471
+166
command/internal/touch/touch.go
··· 1 + package touch 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "os" 9 + "path" 10 + "strings" 11 + "time" 12 + 13 + "github.com/go-git/go-billy/v5" 14 + "github.com/pborman/getopt/v2" 15 + "mvdan.cc/sh/v3/interp" 16 + "tangled.org/xeiaso.net/kefka/command" 17 + ) 18 + 19 + type Impl struct{} 20 + 21 + func (Impl) Exec(ctx context.Context, ec *command.ExecContext, args []string) error { 22 + if ec == nil { 23 + return errors.New("touch: nil ExecContext") 24 + } 25 + if ec.FS == nil { 26 + return errors.New("touch: ExecContext has no filesystem") 27 + } 28 + 29 + stderr := ec.Stderr 30 + if stderr == nil { 31 + stderr = io.Discard 32 + } 33 + 34 + set := getopt.New() 35 + set.SetProgram("touch") 36 + set.SetParameters("FILE...") 37 + 38 + usage := func() { 39 + fmt.Fprint(stderr, "Usage: touch [OPTION]... FILE...\n") 40 + fmt.Fprint(stderr, "Update the access and modification times of each FILE to the current time.\n\n") 41 + fmt.Fprint(stderr, "A FILE argument that does not exist is created empty, unless -c is supplied.\n\n") 42 + fmt.Fprint(stderr, " -a (ignored) change only the access time\n") 43 + fmt.Fprint(stderr, " -c, --no-create do not create any files\n") 44 + fmt.Fprint(stderr, " -d, --date=STRING parse STRING and use it instead of current time\n") 45 + fmt.Fprint(stderr, " -m (ignored) change only the modification time\n") 46 + fmt.Fprint(stderr, " -r, --reference=FILE (ignored) use this file's times instead of current time\n") 47 + fmt.Fprint(stderr, " -t STAMP (ignored) use [[CC]YY]MMDDhhmm[.ss] instead of current time\n") 48 + fmt.Fprint(stderr, " --help display this help and exit\n") 49 + } 50 + set.SetUsage(usage) 51 + 52 + noCreate := set.BoolLong("no-create", 'c', "do not create any files") 53 + dateStr := set.StringLong("date", 'd', "", "parse STRING and use it instead of current time") 54 + _ = set.Bool('a', "(ignored) change only the access time") 55 + _ = set.Bool('m', "(ignored) change only the modification time") 56 + _ = set.StringLong("reference", 'r', "", "(ignored) use this file's times instead of current time") 57 + _ = set.String('t', "", "(ignored) use [[CC]YY]MMDDhhmm[.ss] instead of current time") 58 + help := set.BoolLong("help", 0, "display this help and exit") 59 + 60 + if err := set.Getopt(append([]string{"touch"}, args...), nil); err != nil { 61 + fmt.Fprintf(stderr, "touch: %s\n", err) 62 + usage() 63 + return interp.ExitStatus(1) 64 + } 65 + if *help { 66 + usage() 67 + return nil 68 + } 69 + 70 + files := set.Args() 71 + if len(files) == 0 { 72 + fmt.Fprint(stderr, "touch: missing file operand\n") 73 + return interp.ExitStatus(1) 74 + } 75 + 76 + var targetTime *time.Time 77 + if *dateStr != "" { 78 + parsed, ok := parseDateString(*dateStr) 79 + if !ok { 80 + fmt.Fprintf(stderr, "touch: invalid date format '%s'\n", *dateStr) 81 + return interp.ExitStatus(1) 82 + } 83 + targetTime = &parsed 84 + } 85 + 86 + exitCode := 0 87 + for _, file := range files { 88 + full := resolvePath(ec, file) 89 + 90 + _, err := ec.FS.Stat(full) 91 + exists := err == nil 92 + if !exists { 93 + if *noCreate { 94 + continue 95 + } 96 + f, createErr := ec.FS.OpenFile(full, os.O_CREATE|os.O_WRONLY, 0o644) 97 + if createErr != nil { 98 + fmt.Fprintf(stderr, "touch: cannot touch '%s': %s\n", file, createErr) 99 + exitCode = 1 100 + continue 101 + } 102 + f.Close() 103 + } 104 + 105 + if changer, ok := ec.FS.(billy.Change); ok { 106 + mtime := time.Now() 107 + if targetTime != nil { 108 + mtime = *targetTime 109 + } 110 + if err := changer.Chtimes(full, mtime, mtime); err != nil { 111 + fmt.Fprintf(stderr, "touch: cannot touch '%s': %s\n", file, err) 112 + exitCode = 1 113 + continue 114 + } 115 + } 116 + } 117 + 118 + if exitCode != 0 { 119 + return interp.ExitStatus(uint8(exitCode)) 120 + } 121 + return nil 122 + } 123 + 124 + func parseDateString(s string) (time.Time, bool) { 125 + normalized := strings.ReplaceAll(s, "/", "-") 126 + 127 + for _, layout := range []string{ 128 + time.RFC3339Nano, 129 + time.RFC3339, 130 + "2006-01-02T15:04:05", 131 + } { 132 + if t, err := time.Parse(layout, normalized); err == nil { 133 + return t, true 134 + } 135 + } 136 + 137 + for _, layout := range []string{ 138 + "2006-01-02 15:04:05", 139 + "2006-01-02", 140 + } { 141 + if t, err := time.ParseInLocation(layout, normalized, time.Local); err == nil { 142 + return t, true 143 + } 144 + } 145 + 146 + return time.Time{}, false 147 + } 148 + 149 + func resolvePath(ec *command.ExecContext, p string) string { 150 + dir := ec.Dir 151 + if dir == "" { 152 + dir = "." 153 + } 154 + if path.IsAbs(p) { 155 + p = strings.TrimPrefix(p, "/") 156 + if p == "" { 157 + return "." 158 + } 159 + return path.Clean(p) 160 + } 161 + joined := path.Join(dir, p) 162 + if joined == "" { 163 + return "." 164 + } 165 + return joined 166 + }
+305
command/internal/touch/touch_test.go
··· 1 + package touch 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 + f, err := fs.OpenFile("hello.txt", os.O_CREATE|os.O_WRONLY, 0o644) 19 + if err != nil { 20 + t.Fatal(err) 21 + } 22 + f.Write([]byte("hello\n")) 23 + f.Close() 24 + return fs 25 + } 26 + 27 + func run(t *testing.T, args []string, fs billy.Filesystem) (string, string, error) { 28 + t.Helper() 29 + var stdout, stderr bytes.Buffer 30 + ec := &command.ExecContext{ 31 + Stdout: &stdout, 32 + Stderr: &stderr, 33 + Dir: ".", 34 + FS: fs, 35 + } 36 + err := Impl{}.Exec(context.Background(), ec, args) 37 + return stdout.String(), stderr.String(), err 38 + } 39 + 40 + func exists(t *testing.T, fs billy.Filesystem, name string) bool { 41 + t.Helper() 42 + _, err := fs.Stat(name) 43 + return err == nil 44 + } 45 + 46 + func TestTouch(t *testing.T) { 47 + tests := []struct { 48 + name string 49 + args []string 50 + wantStdout string 51 + wantErrSub string 52 + wantErr bool 53 + check func(t *testing.T, fs billy.Filesystem) 54 + }{ 55 + { 56 + name: "create new empty file", 57 + args: []string{"new.txt"}, 58 + check: func(t *testing.T, fs billy.Filesystem) { 59 + if !exists(t, fs, "new.txt") { 60 + t.Errorf("new.txt was not created") 61 + } 62 + info, err := fs.Stat("new.txt") 63 + if err != nil { 64 + t.Fatal(err) 65 + } 66 + if info.Size() != 0 { 67 + t.Errorf("new.txt size = %d, want 0", info.Size()) 68 + } 69 + }, 70 + }, 71 + { 72 + name: "create multiple files", 73 + args: []string{"a.txt", "b.txt", "c.txt"}, 74 + check: func(t *testing.T, fs billy.Filesystem) { 75 + for _, f := range []string{"a.txt", "b.txt", "c.txt"} { 76 + if !exists(t, fs, f) { 77 + t.Errorf("%s was not created", f) 78 + } 79 + } 80 + }, 81 + }, 82 + { 83 + name: "touch existing file does not truncate", 84 + args: []string{"hello.txt"}, 85 + check: func(t *testing.T, fs billy.Filesystem) { 86 + info, err := fs.Stat("hello.txt") 87 + if err != nil { 88 + t.Fatal(err) 89 + } 90 + if info.Size() != int64(len("hello\n")) { 91 + t.Errorf("hello.txt size = %d, want %d", info.Size(), len("hello\n")) 92 + } 93 + }, 94 + }, 95 + { 96 + name: "no-create skips missing file", 97 + args: []string{"-c", "missing.txt"}, 98 + check: func(t *testing.T, fs billy.Filesystem) { 99 + if exists(t, fs, "missing.txt") { 100 + t.Errorf("missing.txt should not have been created with -c") 101 + } 102 + }, 103 + }, 104 + { 105 + name: "long no-create skips missing file", 106 + args: []string{"--no-create", "missing.txt"}, 107 + check: func(t *testing.T, fs billy.Filesystem) { 108 + if exists(t, fs, "missing.txt") { 109 + t.Errorf("missing.txt should not have been created with --no-create") 110 + } 111 + }, 112 + }, 113 + { 114 + name: "no-create still touches existing file", 115 + args: []string{"-c", "hello.txt"}, 116 + check: func(t *testing.T, fs billy.Filesystem) { 117 + if !exists(t, fs, "hello.txt") { 118 + t.Errorf("hello.txt should still exist") 119 + } 120 + }, 121 + }, 122 + { 123 + name: "ignored short flags are accepted", 124 + args: []string{"-am", "new.txt"}, 125 + check: func(t *testing.T, fs billy.Filesystem) { 126 + if !exists(t, fs, "new.txt") { 127 + t.Errorf("new.txt was not created") 128 + } 129 + }, 130 + }, 131 + { 132 + name: "ignored -r consumes its argument", 133 + args: []string{"-r", "hello.txt", "new.txt"}, 134 + check: func(t *testing.T, fs billy.Filesystem) { 135 + if !exists(t, fs, "new.txt") { 136 + t.Errorf("new.txt was not created") 137 + } 138 + }, 139 + }, 140 + { 141 + name: "ignored -t consumes its argument", 142 + args: []string{"-t", "202504300000", "new.txt"}, 143 + check: func(t *testing.T, fs billy.Filesystem) { 144 + if !exists(t, fs, "new.txt") { 145 + t.Errorf("new.txt was not created") 146 + } 147 + }, 148 + }, 149 + { 150 + name: "date short flag with valid date creates file", 151 + args: []string{"-d", "2024-01-15", "new.txt"}, 152 + check: func(t *testing.T, fs billy.Filesystem) { 153 + if !exists(t, fs, "new.txt") { 154 + t.Errorf("new.txt was not created") 155 + } 156 + }, 157 + }, 158 + { 159 + name: "date long flag with equals form", 160 + args: []string{"--date=2024-01-15 12:30:45", "new.txt"}, 161 + check: func(t *testing.T, fs billy.Filesystem) { 162 + if !exists(t, fs, "new.txt") { 163 + t.Errorf("new.txt was not created") 164 + } 165 + }, 166 + }, 167 + { 168 + name: "date with slash separators", 169 + args: []string{"-d", "2024/01/15", "new.txt"}, 170 + check: func(t *testing.T, fs billy.Filesystem) { 171 + if !exists(t, fs, "new.txt") { 172 + t.Errorf("new.txt was not created") 173 + } 174 + }, 175 + }, 176 + { 177 + name: "date ISO 8601", 178 + args: []string{"-d", "2024-01-15T12:30:45Z", "new.txt"}, 179 + check: func(t *testing.T, fs billy.Filesystem) { 180 + if !exists(t, fs, "new.txt") { 181 + t.Errorf("new.txt was not created") 182 + } 183 + }, 184 + }, 185 + { 186 + name: "invalid date format", 187 + args: []string{"-d", "not-a-date", "new.txt"}, 188 + wantErrSub: "invalid date format", 189 + wantErr: true, 190 + }, 191 + { 192 + name: "missing file operand", 193 + args: []string{}, 194 + wantErrSub: "missing file operand", 195 + wantErr: true, 196 + }, 197 + { 198 + name: "unknown flag", 199 + args: []string{"--no-such-flag", "foo"}, 200 + wantErr: true, 201 + wantErrSub: "touch:", 202 + }, 203 + { 204 + name: "double dash separates flags from files", 205 + args: []string{"--", "-weird-name.txt"}, 206 + check: func(t *testing.T, fs billy.Filesystem) { 207 + if !exists(t, fs, "-weird-name.txt") { 208 + t.Errorf("-weird-name.txt was not created") 209 + } 210 + }, 211 + }, 212 + { 213 + name: "absolute path resolves under fs root", 214 + args: []string{"/abs.txt"}, 215 + check: func(t *testing.T, fs billy.Filesystem) { 216 + if !exists(t, fs, "abs.txt") { 217 + t.Errorf("abs.txt was not created") 218 + } 219 + }, 220 + }, 221 + } 222 + 223 + for _, tt := range tests { 224 + t.Run(tt.name, func(t *testing.T) { 225 + fs := newFS(t) 226 + stdout, stderr, err := run(t, tt.args, fs) 227 + if tt.wantErr { 228 + if err == nil { 229 + t.Fatalf("expected error, got nil; stdout=%q stderr=%q", stdout, stderr) 230 + } 231 + } else if err != nil { 232 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 233 + } 234 + if tt.wantStdout != "" && stdout != tt.wantStdout { 235 + t.Errorf("stdout = %q, want %q", stdout, tt.wantStdout) 236 + } 237 + if tt.wantErrSub != "" && !strings.Contains(stderr, tt.wantErrSub) { 238 + t.Errorf("stderr = %q, want substring %q", stderr, tt.wantErrSub) 239 + } 240 + if tt.check != nil { 241 + tt.check(t, fs) 242 + } 243 + }) 244 + } 245 + } 246 + 247 + func TestHelp(t *testing.T) { 248 + fs := newFS(t) 249 + stdout, stderr, err := run(t, []string{"--help"}, fs) 250 + if err != nil { 251 + t.Fatalf("unexpected error: %v", err) 252 + } 253 + if stdout != "" { 254 + t.Errorf("expected empty stdout, got %q", stdout) 255 + } 256 + if !strings.Contains(stderr, "Usage: touch [OPTION]... FILE...") { 257 + t.Errorf("usage line missing from stderr: %q", stderr) 258 + } 259 + if !strings.Contains(stderr, "--no-create") { 260 + t.Errorf("--no-create flag missing from help output: %q", stderr) 261 + } 262 + if !strings.Contains(stderr, "--date") { 263 + t.Errorf("--date flag missing from help output: %q", stderr) 264 + } 265 + } 266 + 267 + func TestNoFilesystem(t *testing.T) { 268 + var stdout, stderr bytes.Buffer 269 + ec := &command.ExecContext{ 270 + Stdout: &stdout, 271 + Stderr: &stderr, 272 + Dir: ".", 273 + } 274 + err := Impl{}.Exec(context.Background(), ec, []string{"foo"}) 275 + if err == nil { 276 + t.Fatal("expected error when ExecContext.FS is nil") 277 + } 278 + } 279 + 280 + func TestParseDateString(t *testing.T) { 281 + tests := []struct { 282 + name string 283 + input string 284 + ok bool 285 + }{ 286 + {"YYYY-MM-DD", "2024-01-15", true}, 287 + {"YYYY/MM/DD", "2024/01/15", true}, 288 + {"YYYY-MM-DD HH:MM:SS", "2024-01-15 12:30:45", true}, 289 + {"YYYY/MM/DD HH:MM:SS", "2024/01/15 12:30:45", true}, 290 + {"ISO 8601 UTC", "2024-01-15T12:30:45Z", true}, 291 + {"ISO 8601 with offset", "2024-01-15T12:30:45+05:00", true}, 292 + {"empty", "", false}, 293 + {"garbage", "not-a-date", false}, 294 + {"partial", "2024-01", false}, 295 + } 296 + 297 + for _, tt := range tests { 298 + t.Run(tt.name, func(t *testing.T) { 299 + _, ok := parseDateString(tt.input) 300 + if ok != tt.ok { 301 + t.Errorf("parseDateString(%q) ok = %v, want %v", tt.input, ok, tt.ok) 302 + } 303 + }) 304 + } 305 + }