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
18 changes: 10 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ go test -v -race ./...

```
main.go CLI entry point — arg parsing only
main_test.go unit test for processArgs
open/
open.go GetURL, InBrowser, path/type/repo parsing
provider.go Provider struct, DefaultProviders, LoadProviders
provider.go Provider struct, defaultProviders, Providers/fromConfig, line-format parsing (parseRawLineFormat, validateLineFormat)
open_test.go integration tests (run against the real checked-out repo)
provider_test.go unit + integration tests for provider loading
gitw/
Expand All @@ -27,16 +28,17 @@ gitw/
## Data flow

```
main() → GetURL(arg) → parseType → parsePath / getRemoteRef → parseRepository → Provider.{Commit,Path,Root}URL
main() → GetURL(arg) → parseType → parsePath (→ stripLine) / getRemoteRef → parseRepository → Provider.{Commit,Path,Root}URL + Provider.lineAnchor
```

1. Find repo root: `gitw.Toplevel` → falls back to `gitw.AbsoluteGitDir` (bare repos, worktrees).
2. Classify `arg`: empty → `Root`; 7–64 hex chars → `Commit`; anything else → `Path`. If `Commit` exists on disk as a file, reclassify to `Path`.
3. Resolve `Path` arg to a repo-relative slash path via `parsePath`; if it fails (non-existent, outside repo), silently falls back to root URL.
3. Resolve `Path` arg to a repo-relative slash path via `parsePath`, which runs `stripLine` first to split a trailing `:line`/`:start-end` suffix (only treated as a line spec if the literal arg doesn't exist on disk; dropped for directories). Failures — missing, outside repo, or an ancestor being a file (`ancestorIsFile`, unifying POSIX `ENOTDIR` and Windows `ERROR_PATH_NOT_FOUND`) — silently fall back to root URL.
4. Get remote URL + current ref via `getRemoteRef` (delegates to `gitw`).
5. Parse host + repo path with `parseRepository` → delegates to `git-get`'s `get.ParseURL`.
6. Match provider by exact host comparison: `DefaultProviders` first, then `LoadProviders()` results. URL patterns: `{BaseURL}/{repo}[/{CommitPrefix|PathPrefix}/{ref/sha}[/{path}]]`; all URLs run through `escapePath` (`url.PathEscape`, unescape `/`, trim trailing `/`).
7. Build and return the URL.
6. Match provider by exact host comparison: `defaultProviders` first, then `fromConfig()` results (both via `Providers()`). URL patterns: `{BaseURL}/{repo}[/{CommitPrefix|PathPrefix}/{ref/sha}[/{path}]]`; all URLs run through `escapePath` (`url.PathEscape`, unescape `/`, trim trailing `/`).
7. For `Path` with line numbers, append `Provider.lineAnchor(start, end)`, formatting `lineFormat`/`lineFormatRange`'s `%d` verb(s) (converted from raw `%l` by `parseRawLineFormat`); falls back to the single-verb `lineFormat` (dropping `end`) when `end` is 0 or `lineFormatRange` is absent.
8. Build and return the URL.

## Hard constraints

Expand All @@ -46,13 +48,13 @@ main() → GetURL(arg) → parseType → parsePath / getRemoteRef → parseRepos

**Error handling:** `fmt.Errorf("context: %w", err)`. Explicit errors: `"local remotes are not supported"`, `"unable to find provider for: <host>"`.

**Providers:** default providers are `github.com`, `gitlab.com`, `bitbucket.org`. Custom providers can be added via `git config open.<https-url>.{commitprefix,pathprefix}` (local repo config takes precedence over global); non-http/https or missing host → stderr warning + skip. `BaseURL` must be an `http`/`https` URL with a non-empty host — `TestDefaultProviders` enforces this for built-in defaults.
**Providers:** default providers are `github.com`, `gitlab.com`, `bitbucket.org`, each with a `rawLineFormat` (fake `%l` verb) converted to `lineFormat`/`lineFormatRange` by `parseRawLineFormat`. Custom providers: `git config open.<https-url>.{commitprefix,pathprefix,lineformat}` (local repo config wins over global). `commitprefix`/`pathprefix` are required and `BaseURL` must be `http`/`https` with a non-empty host — any violation skips the provider entirely. `lineformat` is optional: zero, one, or two `%l` verbs are valid (more, or any other verb, is not) — a missing/invalid `lineformat` drops only that provider's line-anchor support (stderr warning), not the provider itself. `TestDefaultProviders` and `TestDefaultProvidersLineFormat` enforce this for built-in defaults.

**Tests:** `TestGetURL` and `TestParsePath` in `open/open_test.go` run against the real checked-out repo — do not move `open/` without updating them. `TestGetURLErrors`, `TestGetURLBareRepo`, and `TestGetURLWorktree` spin up hermetic temp repos. `TestLoadProviders` isolates global git config via `GIT_CONFIG_GLOBAL`. `gitw` functions are thin wrappers over `go-git-cmd-wrapper`: `Toplevel`, `AbsoluteGitDir`, `RemoteURL`, `CurrentRef`, `ConfigGetRegexp`; empty `path` arg → cwd.
**Tests:** `TestGetURL` and `TestParsePath` in `open/open_test.go` run against the real checked-out repo — do not move `open/` without updating them. `TestGetURLErrors`, `TestGetURLBareRepo`, and `TestGetURLWorktree` spin up hermetic temp repos. `TestGetURLWindowsAbsolutePath` is Windows-only (`t.Skip` elsewhere) and covers drive-letter path parsing. `TestFromConfig` and `TestFromConfigLocal` isolate global/local git config via `GIT_CONFIG_GLOBAL`. `gitw` functions are thin wrappers over `go-git-cmd-wrapper`: `Toplevel`, `AbsoluteGitDir`, `RemoteURL`, `CurrentRef`, `ConfigGetRegexp`; empty `path` arg → cwd.

## Do not

- Use `exec.Command("git", ...)` directly — use `gitw` or `go-git-cmd-wrapper`.
- Add logic to `main.go` beyond arg parsing and error printing.
- Add a default provider without cases in `TestCommitURL`, `TestPathURL`, `TestRootURL` — for user-specific instances prefer `git config` over a hardcoded addition.
- Add a default provider without cases in `TestCommitURL`, `TestPathURL`, `TestLineAnchor`, `TestRootURL`, `TestDefaultProvidersLineFormat` — for user-specific instances prefer `git config` over a hardcoded addition.
- Hardcode path separators — use `filepath.ToSlash` when building URLs from filesystem paths.
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ $ git open
Open a specific file or folder of a repository.

```console
$ git open LICENSE
$ git open main.go
```

Open a specific commit of a repository.
Expand All @@ -22,6 +22,13 @@ Open a specific commit of a repository.
$ git open 7605d91
```

Open a specific line, or range of lines, of a file.

```console
$ git open main.go:42
$ git open main.go:42-50
```

Open a different repository than `cwd`.

```console
Expand All @@ -38,13 +45,15 @@ To add custom Git providers and their URLs, set their values within the global `
[open "https://git.mydomain.dev"]
commitprefix = commit
pathprefix = tree
lineformat = L%l-L%l
```

This can also be set using the `git` CLI.

```console
$ git config --global open.https://git.mydomain.dev.commitprefix commit
$ git config --global open.https://git.mydomain.dev.pathprefix tree
$ git config --global open.https://git.mydomain.dev.lineformat "L%l-L%l"
```

`commitprefix` and `pathprefix` are used to template the URI for your provider.
Expand All @@ -57,6 +66,16 @@ fmt.Println(host + "/" + repository + "/" + pathprefix )
// https://git.mydomain.dev/<repository>/tree
```

`lineformat` templates the line anchor where `%l` denotes the line number. Up to two
`%l` may be provided, denoting the start and end lines. 3 or more `%l` or unsupported
Go-style verbs like `%v` will disable the line anchor templating and issue a warning.

```go
fmt.Sprintf(lineformat, startLine) // one %l
fmt.Sprintf(lineformat, startLine, endLine) // two %l
// L42 or L42-L50
```

## Installation

Install with `brew`.
Expand Down
93 changes: 78 additions & 15 deletions open/open.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"

"github.com/arbourd/git-get/get"
Expand Down Expand Up @@ -62,9 +63,10 @@ func GetURL(arg string) (string, error) {
t = Path
}
}
var lstart, lend int
if t == Path {
// Ignore parsePath errors: invalid or out-of-repo paths fall back to the root URL.
arg, _ = parsePath(arg, gitroot)
// Ignore parsePath errors: invalid or out-of-repo paths fall back to the root URL
arg, lstart, lend, _ = parsePath(arg, gitroot)
}

remote, ref, err := getRemoteRef(gitroot)
Expand All @@ -80,12 +82,12 @@ func GetURL(arg string) (string, error) {
return "", fmt.Errorf("local remotes are not supported")
}

providers := append(DefaultProviders, LoadProviders()...)
providers := Providers()

// Find the provider by exact host comparison.
var p Provider
for _, provider := range providers {
u, err := url.Parse(provider.BaseURL)
u, err := url.Parse(provider.BaseURL())
if err != nil {
continue
}
Expand All @@ -95,7 +97,7 @@ func GetURL(arg string) (string, error) {
}
}

if len(p.BaseURL) == 0 {
if len(p.BaseURL()) == 0 {
return "", fmt.Errorf("unable to find provider for: \"%s\"", host)
}

Expand All @@ -104,25 +106,38 @@ func GetURL(arg string) (string, error) {
case Commit:
openURL = p.CommitURL(repo, arg)
case Path:
openURL = p.PathURL(repo, ref, arg)
openURL = p.PathURL(repo, ref, arg, lstart, lend)
case Root:
openURL = p.RootURL(repo)
}

return openURL, nil
}

// parsePath returns a cleaned path if the file and folder exist and belong to the root Git repository
func parsePath(path, gitroot string) (string, error) {
// parsePath returns the cleaned path, relative to the gitroot, and the parsed start and end line numbers
func parsePath(path, gitroot string) (string, int, int, error) {
if path == "" {
return "", nil
return "", 0, 0, nil
}

var lstart, lend int
if stripped, start, end := stripLine(path); stripped != path {
// Prefer the literal, colon-suffixed argument when it names a real
// file or directory; otherwise treat the suffix as a line spec.
if _, statErr := os.Stat(path); statErr != nil {
path, lstart, lend = stripped, start, end
}
}
path = filepath.Clean(path)

_, err := os.Stat(path)
if os.IsNotExist(err) {
return "", err
info, err := os.Stat(path)
if err != nil && (os.IsNotExist(err) || ancestorIsFile(path)) {
return "", 0, 0, err
}
if err == nil && info.IsDir() {
lstart, lend = 0, 0
}

path, _ = filepath.Abs(path)
if resolved, err := filepath.EvalSymlinks(path); err == nil {
path = resolved
Expand All @@ -131,15 +146,32 @@ func parsePath(path, gitroot string) (string, error) {
// Check if path is within Git root
rel, err := filepath.Rel(gitroot, path)
if err != nil || strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("path does not contain gitroot: %s; %s", path, gitroot)
return "", 0, 0, fmt.Errorf("path does not contain gitroot: %s; %s", path, gitroot)
}

if rel == "." {
return "", nil
return "", 0, 0, nil
}

// Convert all path separators to `/` and trim trailing `/`
return filepath.ToSlash(rel), nil
return filepath.ToSlash(rel), lstart, lend, nil
}

// ancestorIsFile reports whether path is invalid directory because an ancestor
// is a file -- POSIX's ENOTDIR or Windows' ERROR_PATH_NOT_FOUND
func ancestorIsFile(path string) bool {
dir := filepath.Dir(path)
for {
parent, err := os.Stat(dir)
if err == nil {
return !parent.IsDir()
}
next := filepath.Dir(dir)
if next == dir {
return false
}
dir = next
}
}

// getRemoteRef returns the Git remote and reference (branch, tag, commit), for a provided Git repository
Expand All @@ -164,6 +196,37 @@ func parseRepository(remote string) (host string, repo string, err error) {
return url.Host, repo, nil
}

// lineSuffixRegex matches trailing line numbers
var lineSuffixRegex = regexp.MustCompile(`^[0-9-]+$`)

// stripLine splits a trailing line suffix from arg, returning the path and line numbers
func stripLine(arg string) (path string, start int, end int) {
i := strings.LastIndex(arg, ":")
if i <= 0 {
return arg, 0, 0
}
path, suffix := arg[:i], arg[i+1:]
if !lineSuffixRegex.MatchString(suffix) {
return arg, 0, 0
}

before, after, isRange := strings.Cut(suffix, "-")

start, err := strconv.Atoi(before)
if err != nil || start < 1 {
return path, 0, 0
}
if !isRange {
return path, start, 0
}

end, err = strconv.Atoi(after)
if err != nil || end < 1 {
return path, start, 0
}
return path, start, end
}

var commitSHARegex = regexp.MustCompile(`^[0-9a-f]{7,64}$`)

// parseType parses and returns the Type of argument
Expand Down
Loading