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

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

Xe Iaso 4d474c84 a7559a84

+309
+135
command/internal/pwd/pwd.go
··· 1 + package pwd 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "path" 9 + "strings" 10 + 11 + "github.com/go-git/go-billy/v5" 12 + "github.com/pborman/getopt/v2" 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("pwd: 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("pwd") 35 + 36 + usage := func() { 37 + fmt.Fprint(stderr, "Usage: pwd [OPTION]...\n") 38 + fmt.Fprint(stderr, "Print the full filename of the current working directory.\n\n") 39 + fmt.Fprint(stderr, " -L use PWD from environment, even if it contains symlinks (default)\n") 40 + fmt.Fprint(stderr, " -P avoid all symlinks\n") 41 + fmt.Fprint(stderr, " --help display this help and exit\n") 42 + } 43 + set.SetUsage(usage) 44 + 45 + set.Bool('P', "avoid all symlinks") 46 + set.Bool('L', "use logical path even if it contains symlinks") 47 + help := set.BoolLong("help", 0, "display this help and exit") 48 + 49 + physical := false 50 + parseErr := set.Getopt(append([]string{"pwd"}, args...), func(opt getopt.Option) bool { 51 + switch opt.ShortName() { 52 + case "P": 53 + physical = true 54 + case "L": 55 + physical = false 56 + } 57 + return true 58 + }) 59 + if parseErr != nil { 60 + fmt.Fprintf(stderr, "pwd: %s\n", parseErr) 61 + usage() 62 + return interp.ExitStatus(1) 63 + } 64 + 65 + if *help { 66 + usage() 67 + return nil 68 + } 69 + 70 + logical := absolutize(ec.Dir) 71 + out := logical 72 + 73 + if physical { 74 + if ec.FS != nil { 75 + if resolved, err := realpath(ec.FS, ec.Dir); err == nil { 76 + out = absolutize(resolved) 77 + } 78 + } 79 + } 80 + 81 + io.WriteString(stdout, out) 82 + io.WriteString(stdout, "\n") 83 + return nil 84 + } 85 + 86 + // absolutize renders an fsys-relative path as a shell-absolute path with a 87 + // leading slash. The fsys root maps to "/". 88 + func absolutize(dir string) string { 89 + dir = strings.TrimPrefix(dir, "/") 90 + if dir == "" || dir == "." { 91 + return "/" 92 + } 93 + return "/" + dir 94 + } 95 + 96 + // realpath resolves any symlinks along p using the billy.Symlink capability, 97 + // if available. Returns an error if any path component does not exist. 98 + // Filesystems without Symlink support return p unchanged. 99 + func realpath(fsys billy.Filesystem, p string) (string, error) { 100 + if _, err := fsys.Stat(p); err != nil { 101 + return "", err 102 + } 103 + sym, ok := fsys.(billy.Symlink) 104 + if !ok { 105 + return path.Clean(p), nil 106 + } 107 + 108 + resolved := "" 109 + parts := strings.Split(path.Clean(p), "/") 110 + for steps := 0; len(parts) > 0 && steps < 64; steps++ { 111 + part := parts[0] 112 + parts = parts[1:] 113 + if part == "" || part == "." { 114 + continue 115 + } 116 + candidate := path.Join(resolved, part) 117 + if candidate == "" { 118 + candidate = "." 119 + } 120 + target, err := sym.Readlink(candidate) 121 + if err != nil { 122 + resolved = candidate 123 + continue 124 + } 125 + if path.IsAbs(target) { 126 + resolved = "" 127 + target = strings.TrimPrefix(target, "/") 128 + } 129 + parts = append(strings.Split(path.Clean(target), "/"), parts...) 130 + } 131 + if resolved == "" { 132 + return ".", nil 133 + } 134 + return resolved, nil 135 + }
+174
command/internal/pwd/pwd_test.go
··· 1 + package pwd 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 + if err := fs.MkdirAll("home/user", 0o755); err != nil { 19 + t.Fatalf("mkdir: %v", err) 20 + } 21 + f, err := fs.OpenFile("home/user/notes.txt", os.O_CREATE|os.O_WRONLY, 0o644) 22 + if err != nil { 23 + t.Fatalf("create: %v", err) 24 + } 25 + f.Close() 26 + return fs 27 + } 28 + 29 + func run(t *testing.T, dir string, args []string) (string, string, error) { 30 + t.Helper() 31 + var stdout, stderr bytes.Buffer 32 + if dir == "" { 33 + dir = "." 34 + } 35 + ec := &command.ExecContext{ 36 + Stdout: &stdout, 37 + Stderr: &stderr, 38 + Dir: dir, 39 + FS: newFS(t), 40 + } 41 + err := Impl{}.Exec(context.Background(), ec, args) 42 + return stdout.String(), stderr.String(), err 43 + } 44 + 45 + func TestPwd(t *testing.T) { 46 + tests := []struct { 47 + name string 48 + dir string 49 + args []string 50 + wantStdout string 51 + wantErr bool 52 + }{ 53 + { 54 + name: "default at fsys root", 55 + dir: ".", 56 + args: []string{}, 57 + wantStdout: "/\n", 58 + }, 59 + { 60 + name: "default in subdir", 61 + dir: "home/user", 62 + args: []string{}, 63 + wantStdout: "/home/user\n", 64 + }, 65 + { 66 + name: "logical flag explicit", 67 + dir: "home/user", 68 + args: []string{"-L"}, 69 + wantStdout: "/home/user\n", 70 + }, 71 + { 72 + name: "physical flag without symlinks matches logical", 73 + dir: "home/user", 74 + args: []string{"-P"}, 75 + wantStdout: "/home/user\n", 76 + }, 77 + { 78 + name: "P then L: last wins (logical)", 79 + dir: "home/user", 80 + args: []string{"-P", "-L"}, 81 + wantStdout: "/home/user\n", 82 + }, 83 + { 84 + name: "L then P: last wins (physical)", 85 + dir: "home/user", 86 + args: []string{"-L", "-P"}, 87 + wantStdout: "/home/user\n", 88 + }, 89 + { 90 + name: "double-dash terminator", 91 + dir: ".", 92 + args: []string{"--"}, 93 + wantStdout: "/\n", 94 + }, 95 + { 96 + name: "unknown flag returns error", 97 + dir: ".", 98 + args: []string{"-Z"}, 99 + wantErr: true, 100 + }, 101 + { 102 + name: "unknown long flag returns error", 103 + dir: ".", 104 + args: []string{"--no-such-flag"}, 105 + wantErr: true, 106 + }, 107 + } 108 + 109 + for _, tt := range tests { 110 + t.Run(tt.name, func(t *testing.T) { 111 + stdout, stderr, err := run(t, tt.dir, tt.args) 112 + if tt.wantErr { 113 + if err == nil { 114 + t.Fatalf("expected error, got nil; stdout=%q stderr=%q", stdout, stderr) 115 + } 116 + return 117 + } 118 + if err != nil { 119 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 120 + } 121 + if stdout != tt.wantStdout { 122 + t.Errorf("stdout = %q, want %q", stdout, tt.wantStdout) 123 + } 124 + }) 125 + } 126 + } 127 + 128 + func TestEmptyDir(t *testing.T) { 129 + stdout, _, err := run(t, "", nil) 130 + if err != nil { 131 + t.Fatalf("unexpected error: %v", err) 132 + } 133 + if stdout != "/\n" { 134 + t.Errorf("stdout = %q, want %q", stdout, "/\n") 135 + } 136 + } 137 + 138 + func TestHelp(t *testing.T) { 139 + stdout, stderr, err := run(t, ".", []string{"--help"}) 140 + if err != nil { 141 + t.Fatalf("unexpected error: %v", err) 142 + } 143 + if stdout != "" { 144 + t.Errorf("expected empty stdout, got %q", stdout) 145 + } 146 + if !strings.Contains(stderr, "Usage: pwd") { 147 + t.Errorf("usage line missing from stderr: %q", stderr) 148 + } 149 + if !strings.Contains(stderr, "--help") { 150 + t.Errorf("help flag missing from help output: %q", stderr) 151 + } 152 + } 153 + 154 + func TestNilExecContext(t *testing.T) { 155 + if err := (Impl{}).Exec(context.Background(), nil, nil); err == nil { 156 + t.Fatal("expected error for nil ExecContext") 157 + } 158 + } 159 + 160 + func TestPhysicalWithoutFS(t *testing.T) { 161 + var stdout, stderr bytes.Buffer 162 + ec := &command.ExecContext{ 163 + Stdout: &stdout, 164 + Stderr: &stderr, 165 + Dir: "home/user", 166 + FS: nil, 167 + } 168 + if err := (Impl{}).Exec(context.Background(), ec, []string{"-P"}); err != nil { 169 + t.Fatalf("unexpected error: %v", err) 170 + } 171 + if got := stdout.String(); got != "/home/user\n" { 172 + t.Errorf("stdout = %q, want %q", got, "/home/user\n") 173 + } 174 + }