diff --git a/README.md b/README.md index f5a8f53..9acaf56 100644 --- a/README.md +++ b/README.md @@ -173,9 +173,10 @@ the fields it sets. The built-in Actions are: | `jump` | `enter` | Jumps to the Project, in both key maps and in the vim filter focus | | `files` | `ctrl+o` | `xdg-open {path}` (`open {path}` on macOS), detached | | `editor` | `ctrl+e` | `${VISUAL:-${EDITOR:-vi}} {path}`, on your terminal | +| `remote` | `ctrl+g` | `xdg-open {remote}` (`open {remote}` on macOS), detached | `editor` reads `$VISUAL`, else `$EDITOR`, else `vi`, each time it runs. -Override `run` to pick a program, or set `key = ""` to unbind either: +Override `run` to pick a program, or set `key = ""` to unbind any of them: ```toml [actions.editor] @@ -185,6 +186,21 @@ run = "hx {path}" # replaces the built-in command key = "" # no file manager binding ``` +`{remote}` in a `run` stands for the shell-quoted home page URL of the +Project's git remote: `origin`, else the first remote `git remote` lists, +rewritten to its repository page, never the branch. +`git@host:owner/repo.git` and `ssh://git@host:2222/owner/repo.git` both +become `https://host/owner/repo`, and an `https://` URL loses its `.git`; +GitLab subgroups (`owner/group/repo`) are kept. A Project with no remote, or +that is not a git repository, cannot run an Action that uses `{remote}`: the +Picker stays open and says so on its last line. Set `run` to use another +command or browser: + +```toml +[actions.remote] +run = "firefox {remote}" # replaces the built-in command +``` + Rebind `jump`, or give `enter` to another Action: ```toml @@ -243,8 +259,8 @@ layout = "list" uses the vim key map described above. Defaults to `false`, the default key map. - `[actions.]`: an Action, with `key`, `run`, `jump` and `detach`; - see [Actions](#actions). Not in the defaults above; `jump`, `files` and - `editor` exist unless you override them. + see [Actions](#actions). Not in the defaults above; `jump`, `files`, + `editor` and `remote` exist unless you override them. - `[picker].layout`: which Layout the Picker draws. `"list"`, described above, is the default and, for now, the only one. Any other value is a config error. diff --git a/internal/action/action.go b/internal/action/action.go index b94e7fc..8793ae2 100644 --- a/internal/action/action.go +++ b/internal/action/action.go @@ -22,7 +22,9 @@ type Action struct { Key string // Run is the shell command, with "{path}" standing for the Project's - // shell-quoted absolute path. It may be empty only for an Action that + // shell-quoted absolute path and "{remote}" for the shell-quoted home + // page URL of its git remote, which fails the Action when it has none. + // It may be empty only for an Action that // Jumps. Run string @@ -52,6 +54,7 @@ var builtins = []Action{ // The shell picks the editor each time the Action runs, so a change to // $VISUAL or $EDITOR needs no restart. {Name: "editor", Key: "ctrl+e", Run: "${VISUAL:-${EDITOR:-vi}} {path}"}, + {Name: "remote", Key: "ctrl+g", Run: Opener(runtime.GOOS) + " {remote}", Detach: true}, } // Builtins returns a copy of the built-in Actions. diff --git a/internal/action/remote_test.go b/internal/action/remote_test.go new file mode 100644 index 0000000..ea4490f --- /dev/null +++ b/internal/action/remote_test.go @@ -0,0 +1,73 @@ +package action_test + +import ( + "errors" + "os" + "os/exec" + "runtime" + "testing" + + "github.com/kryft-dev/cdd/internal/action" + "github.com/kryft-dev/cdd/internal/git" +) + +// repoWithRemote makes a git repository whose origin is url, or that has no +// remote when url is empty, and returns its path. +func repoWithRemote(t *testing.T, url string) string { + t.Helper() + dir := t.TempDir() + cmds := [][]string{{"init", "-q"}} + if url != "" { + cmds = append(cmds, []string{"remote", "add", "origin", url}) + } + for _, args := range cmds { + cmd := exec.Command("git", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + return dir +} + +func TestBuiltins_RemoteOpensTheRemoteDetached(t *testing.T) { + got := builtin(t, "remote") + want := action.Action{Name: "remote", Key: "ctrl+g", Run: action.Opener(runtime.GOOS) + " {remote}", Detach: true} + if got != want { + t.Errorf("remote = %+v, want %+v", got, want) + } +} + +func TestExecRunner_RemoteExpandsToTheQuotedHomePage(t *testing.T) { + dir := repoWithRemote(t, "git@github.com:owner/repo.git") + tty := fakeTTY(t) + + a := action.Action{Run: `printf '%s' {remote}`} + if _, err := (action.ExecRunner{TTY: tty}).Run(a, dir); err != nil { + t.Fatal(err) + } + out, _ := os.ReadFile(tty) + if want := "https://github.com/owner/repo"; string(out) != want { + t.Errorf("output = %q, want %q", out, want) + } +} + +func TestExecRunner_StartFailsWithoutARemote(t *testing.T) { + notRepo := t.TempDir() + noRemote := repoWithRemote(t, "") + + a := action.Action{Run: "true {remote}"} + if err := (action.ExecRunner{}).Start(a, notRepo); !errors.Is(err, git.ErrNotRepo) { + t.Errorf("Start outside a repo: err = %v, want ErrNotRepo", err) + } + if err := (action.ExecRunner{}).Start(a, noRemote); !errors.Is(err, git.ErrNoRemote) { + t.Errorf("Start without a remote: err = %v, want ErrNoRemote", err) + } +} + +func TestExecRunner_ARunWithoutRemoteNeverLooksItUp(t *testing.T) { + a := action.Action{Run: "true"} + if err := (action.ExecRunner{}).Start(a, t.TempDir()); err != nil { + t.Errorf("Start = %v, want nil: no {remote} to resolve", err) + } +} diff --git a/internal/action/run.go b/internal/action/run.go index 4e60972..0cf1818 100644 --- a/internal/action/run.go +++ b/internal/action/run.go @@ -1,12 +1,15 @@ package action import ( + "context" "errors" "fmt" "os" "os/exec" "strings" "syscall" + + "github.com/kryft-dev/cdd/internal/git" ) // Runner starts the commands of Actions. The Picker and the Resolve flow @@ -33,13 +36,24 @@ type ExecRunner struct { } // Command builds the "sh -c" command for a on the Project at path: "{path}" -// in Run is replaced by the shell-quoted path, the working directory is -// the Project, and CDD_PATH holds the path. -func Command(a Action, path string) *exec.Cmd { - cmd := exec.Command("sh", "-c", strings.ReplaceAll(a.Run, "{path}", shellQuote(path))) +// in Run is replaced by the shell-quoted path, "{remote}" by the shell-quoted +// home page URL of the Project's git remote, the working directory is the +// Project, and CDD_PATH holds the path. It fails, without a command, when +// Run uses "{remote}" and the Project has none (git.ErrNoRemote, +// git.ErrNotRepo). +func Command(a Action, path string) (*exec.Cmd, error) { + script := strings.ReplaceAll(a.Run, "{path}", shellQuote(path)) + if strings.Contains(script, "{remote}") { + url, err := git.RemoteURL(context.Background(), path) + if err != nil { + return nil, err + } + script = strings.ReplaceAll(script, "{remote}", shellQuote(url)) + } + cmd := exec.Command("sh", "-c", script) cmd.Dir = path cmd.Env = append(os.Environ(), "CDD_PATH="+path) - return cmd + return cmd, nil } // shellQuote wraps s in single quotes for sh, so spaces and metacharacters @@ -51,7 +65,10 @@ func shellQuote(s string) string { // Start implements Runner. The command gets a session of its own, so it // outlives the Picker, and /dev/null for stdio. func (ExecRunner) Start(a Action, path string) error { - cmd := Command(a, path) + cmd, err := Command(a, path) + if err != nil { + return err + } cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} if err := cmd.Start(); err != nil { return err @@ -72,7 +89,10 @@ func (r ExecRunner) Run(a Action, path string) (int, error) { } defer func() { _ = tty.Close() }() - cmd := Command(a, path) + cmd, err := Command(a, path) + if err != nil { + return 0, err + } cmd.Stdin, cmd.Stdout, cmd.Stderr = tty, tty, tty err = cmd.Run() diff --git a/internal/git/remote.go b/internal/git/remote.go new file mode 100644 index 0000000..f5ddae3 --- /dev/null +++ b/internal/git/remote.go @@ -0,0 +1,57 @@ +package git + +import ( + "context" + "errors" + "os" + "os/exec" + "strings" +) + +var ( + // ErrNotRepo is returned for a directory that is not a git repository. + ErrNotRepo = errors.New("not a git repository") + // ErrNoRemote is returned for a repository with no remote. + ErrNoRemote = errors.New("no git remote") +) + +// RemoteURL returns the home page URL (see WebURL) of dir's remote: origin, +// else the first remote git lists. It fails with ErrNotRepo or ErrNoRemote +// when there is nothing to open. +func RemoteURL(ctx context.Context, dir string) (string, error) { + if !hasDotGit(dir) { + return "", ErrNotRepo + } + + out, err := gitOut(ctx, dir, "remote") + if err != nil { + return "", err + } + names := strings.Fields(out) + if len(names) == 0 { + return "", ErrNoRemote + } + name := names[0] + for _, n := range names { + if n == "origin" { + name = n + } + } + + raw, err := gitOut(ctx, dir, "remote", "get-url", name) + if err != nil { + return "", err + } + return WebURL(raw) +} + +// gitOut runs git in dir with args and returns its stdout. +func gitOut(ctx context.Context, dir string, args ...string) (string, error) { + cctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + cmd := exec.CommandContext(cctx, "git", append([]string{"-C", dir}, args...)...) + cmd.Env = append(os.Environ(), "GIT_OPTIONAL_LOCKS=0") + out, err := cmd.Output() + return string(out), err +} diff --git a/internal/git/remote_test.go b/internal/git/remote_test.go new file mode 100644 index 0000000..f515aa8 --- /dev/null +++ b/internal/git/remote_test.go @@ -0,0 +1,54 @@ +package git_test + +import ( + "context" + "errors" + "testing" + + "github.com/kryft-dev/cdd/internal/git" +) + +func TestRemoteURL_PrefersOrigin(t *testing.T) { + dir := newRepo(t) + run(t, dir, "remote", "add", "a-first", "git@example.com:other/repo.git") + run(t, dir, "remote", "add", "origin", "git@github.com:owner/repo.git") + + got, err := git.RemoteURL(context.Background(), dir) + if err != nil || got != "https://github.com/owner/repo" { + t.Errorf("RemoteURL = %q, %v", got, err) + } +} + +func TestRemoteURL_FallsBackToTheFirstRemote(t *testing.T) { + dir := newRepo(t) + run(t, dir, "remote", "add", "upstream", "https://gitlab.com/owner/group/repo.git") + run(t, dir, "remote", "add", "fork", "https://gitlab.com/me/repo.git") + + got, err := git.RemoteURL(context.Background(), dir) + if err != nil || got != "https://gitlab.com/me/repo" { + t.Errorf("RemoteURL = %q, %v", got, err) + } +} + +func TestRemoteURL_NoRemote(t *testing.T) { + _, err := git.RemoteURL(context.Background(), newRepo(t)) + if !errors.Is(err, git.ErrNoRemote) { + t.Errorf("err = %v, want ErrNoRemote", err) + } +} + +func TestRemoteURL_NotARepo(t *testing.T) { + _, err := git.RemoteURL(context.Background(), t.TempDir()) + if !errors.Is(err, git.ErrNotRepo) { + t.Errorf("err = %v, want ErrNotRepo", err) + } +} + +func TestRemoteURL_LocalRemoteHasNoHomePage(t *testing.T) { + dir := newRepo(t) + run(t, dir, "remote", "add", "origin", "/srv/git/repo.git") + + if _, err := git.RemoteURL(context.Background(), dir); err == nil { + t.Error("RemoteURL for a local path remote succeeded, want an error") + } +} diff --git a/internal/git/weburl.go b/internal/git/weburl.go new file mode 100644 index 0000000..13c5a3c --- /dev/null +++ b/internal/git/weburl.go @@ -0,0 +1,53 @@ +package git + +import ( + "fmt" + "net/url" + "strings" +) + +// WebURL rewrites a git remote URL to the home page of its repository: https +// (http stays http), without the ".git" suffix, the port or user ssh used, +// or any credentials. It accepts the scp-style "git@host:owner/repo.git" +// and the ssh, git, http and https URL forms. A local path or file URL has +// no home page and is an error. +func WebURL(remote string) (string, error) { + raw := strings.TrimSpace(remote) + errNotHosted := fmt.Errorf("remote %q is not a hosted repository", raw) + + if !strings.Contains(raw, "://") { + host, path, ok := strings.Cut(raw, ":") + if !ok || host == "" || strings.Contains(host, "/") { + return "", errNotHosted + } + if _, h, found := strings.Cut(host, "@"); found { + host = h + } + return web("https", host, path) + } + + u, err := url.Parse(raw) + if err != nil || u.Hostname() == "" { + return "", errNotHosted + } + switch u.Scheme { + case "http": + return web("http", u.Host, u.Path) + case "https": + return web("https", u.Host, u.Path) + case "ssh", "git": + // The port is the ssh daemon's, not the web server's. + return web("https", u.Hostname(), u.Path) + } + return "", errNotHosted +} + +// web joins the parts of a home page URL, dropping the path's slashes at +// both ends and its ".git" suffix. +func web(scheme, host, path string) (string, error) { + path = strings.TrimSuffix(strings.Trim(path, "/"), ".git") + if path == "" { + return "", fmt.Errorf("remote on %s names no repository", host) + } + return scheme + "://" + host + "/" + path, nil +} diff --git a/internal/git/weburl_test.go b/internal/git/weburl_test.go new file mode 100644 index 0000000..b8cdff2 --- /dev/null +++ b/internal/git/weburl_test.go @@ -0,0 +1,46 @@ +package git_test + +import ( + "testing" + + "github.com/kryft-dev/cdd/internal/git" +) + +func TestWebURL(t *testing.T) { + tests := []struct { + name, remote, want string + }{ + {"scp-style", "git@github.com:owner/repo.git", "https://github.com/owner/repo"}, + {"scp-style without .git", "git@github.com:owner/repo", "https://github.com/owner/repo"}, + {"scp-style without user", "github.com:owner/repo.git", "https://github.com/owner/repo"}, + {"scp-style subgroups", "git@gitlab.com:owner/group/sub/repo.git", "https://gitlab.com/owner/group/sub/repo"}, + {"ssh", "ssh://git@github.com/owner/repo.git", "https://github.com/owner/repo"}, + {"ssh with port", "ssh://git@host.example:2222/owner/repo.git", "https://host.example/owner/repo"}, + {"ssh subgroups", "ssh://git@gitlab.com/owner/group/repo.git", "https://gitlab.com/owner/group/repo"}, + {"git protocol", "git://github.com/owner/repo.git", "https://github.com/owner/repo"}, + {"https", "https://github.com/owner/repo.git", "https://github.com/owner/repo"}, + {"https without .git", "https://github.com/owner/repo", "https://github.com/owner/repo"}, + {"https with port", "https://git.example:8443/owner/repo.git", "https://git.example:8443/owner/repo"}, + {"https subgroups", "https://gitlab.com/owner/group/sub/repo.git", "https://gitlab.com/owner/group/sub/repo"}, + {"https with credentials", "https://user:token@github.com/owner/repo.git", "https://github.com/owner/repo"}, + {"http", "http://git.example/owner/repo.git", "http://git.example/owner/repo"}, + {"trailing slash", "https://github.com/owner/repo/", "https://github.com/owner/repo"}, + {"surrounding space", " git@github.com:owner/repo.git\n", "https://github.com/owner/repo"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := git.WebURL(tt.remote) + if err != nil || got != tt.want { + t.Errorf("WebURL(%q) = %q, %v, want %q", tt.remote, got, err, tt.want) + } + }) + } +} + +func TestWebURL_RejectsWhatIsNotAHostedRemote(t *testing.T) { + for _, remote := range []string{"", "/srv/git/repo.git", "../repo", "file:///srv/repo.git", "repo.git"} { + if got, err := git.WebURL(remote); err == nil { + t.Errorf("WebURL(%q) = %q, want an error", remote, got) + } + } +} diff --git a/internal/picker/actions_test.go b/internal/picker/actions_test.go index ad62466..d37e4d9 100644 --- a/internal/picker/actions_test.go +++ b/internal/picker/actions_test.go @@ -9,6 +9,7 @@ import ( "charm.land/lipgloss/v2" "github.com/kryft-dev/cdd/internal/action" + "github.com/kryft-dev/cdd/internal/git" "github.com/kryft-dev/cdd/internal/picker" ) @@ -220,3 +221,16 @@ func TestModel_Action_NoRowsIsANoOp(t *testing.T) { t.Errorf("started = %v, Cmd = %v, want nothing", r.started, cmd != nil) } } + +func TestModel_Action_RemoteWithoutOneShowsTheErrorAndStaysOpen(t *testing.T) { + r := &fakeRunner{err: git.ErrNoRemote} + m := actionModel(r, false, action.Action{Name: "remote", Key: "ctrl+g", Run: "open {remote}", Detach: true}) + + m, cmd := press(m, tea.KeyPressMsg{Code: 'g', Mod: tea.ModCtrl}) + if v := plain(m.View().Content); !strings.Contains(v, "remote: no git remote") { + t.Errorf("View lacks the error:\n%s", v) + } + if _, chosen := m.Chosen(); chosen || cmd != nil { + t.Errorf("the Picker left (chosen = %v, Cmd = %v), want it open", chosen, cmd != nil) + } +}