Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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
Expand Down Expand Up @@ -243,8 +259,8 @@ layout = "list"
uses the vim key map described above. Defaults to `false`, the default
key map.
- `[actions.<name>]`: 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.
Expand Down
5 changes: 4 additions & 1 deletion internal/action/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
73 changes: 73 additions & 0 deletions internal/action/remote_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
34 changes: 27 additions & 7 deletions internal/action/run.go
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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()

Expand Down
57 changes: 57 additions & 0 deletions internal/git/remote.go
Original file line number Diff line number Diff line change
@@ -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
}
54 changes: 54 additions & 0 deletions internal/git/remote_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
53 changes: 53 additions & 0 deletions internal/git/weburl.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading