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
12 changes: 10 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,18 @@ not repeated here.
repository on disk for the profile it actually carries.
- `internal/gitconfig/`: renders the profile files and the include file `apply` writes; it
never writes a file itself.
- `internal/status/`: reads one repository's branch, dirty and ahead or behind state out of
`git status --porcelain=v2 --branch`, an unborn HEAD and a branch with no upstream rendered
as an explicit state rather than a missing field.
- `internal/gitsync/`: fetches a set of repositories through a bounded pool of workers; never
pulls, rebases or merges. Named apart from `sync` so no caller shadows the standard library
package of that name.
- `internal/shell/`: the per shell init snippet `shell-init` prints.

`list`, `cd`, `doctor`, `adopt`, `status` and `sync` are later milestones, tracked as issues;
their own packages land with them.
`list`, `cd`, `doctor` and `adopt` are later milestones, tracked as issues; their own packages
land with them. `cmd/gitcd/discover.go` walks the configured root for `status`, `sync` and
`open`; issue #3's `internal/scan` walks the same ground for `list`, `doctor` and `adopt` with
a richer value, and the two should be reconciled into one once both have landed.

## Commands

Expand Down
80 changes: 80 additions & 0 deletions cmd/gitcd/discover.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Copyright 2026 Simon Ostendorf
// SPDX-License-Identifier: Apache-2.0

package main

import (
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"

"github.com/ostendorf-cloud/gitcd/internal/git"
)

// discoverRepos walks root for every repository under it, stopping its
// descent the moment it finds one, so a submodule or a linked worktree
// nested inside a match is never itself reported as a second one. It
// treats a ".git" entry, a directory for an ordinary clone or a file for
// a linked worktree, as a repository boundary, the same rule
// git.IsRepository applies everywhere else in this command. A root that
// does not exist yet yields no repositories, not an error: a fleet with
// nothing cloned into it yet is an empty fleet, not a failure.
//
// This is deliberately small and local to cmd/gitcd: issue #3 introduces
// internal/scan, a walk built for "list", "doctor" and "adopt" with a
// richer Repo value than "status" and "sync" need here. The two walks
// cover the same ground and should be reconciled into one once both have
// landed, rather than carried as two copies of the same traversal.
//
// findByName already walked root this same way for "gitcd path", filtered
// to one name; it is rewritten here to call discoverRepos instead of
// repeating the walk a second time within this package.
func discoverRepos(root string) ([]string, error) {
if _, err := os.Stat(root); err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("discover: stat %s: %w", root, err)
}

var repos []string
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
return nil
}
if d.Name() == ".git" {
return filepath.SkipDir
}
if path == root {
return nil
}
if git.IsRepository(path) {
repos = append(repos, path)
return filepath.SkipDir
}
return nil
})
if err != nil {
return nil, fmt.Errorf("discover: walk %s: %w", root, err)
}
sort.Strings(repos)
return repos, nil
}

// repoName renders path as the name "status", "sync" and "open" report
// it under: path relative to root, "/" separated regardless of platform,
// so a long host/owner/repo path still reads as one recognisable unit
// rather than an ambiguous bare repository name two different owners
// could both carry.
func repoName(root, path string) string {
rel, err := filepath.Rel(root, path)
if err != nil {
return path
}
return filepath.ToSlash(rel)
}
9 changes: 9 additions & 0 deletions cmd/gitcd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ Commands:
init add the identity profile include to the global git config
apply render and write the identity profile files
profile pin, remove or inspect a repository's identity profile
status print branch, dirty and ahead or behind state for every repository
sync fetch every repository with a bounded pool, never pulling or merging
open open a repository's remote in a browser
shell-init print the shell integration snippet for zsh, bash or fish
version print the version

Expand Down Expand Up @@ -56,6 +59,12 @@ func run(args []string, stdout, stderr io.Writer) int {
return runApply(args[1:], stdout, stderr)
case "profile":
return runProfile(args[1:], stdout, stderr)
case "status":
return runStatus(args[1:], stdout, stderr)
case "sync":
return runSync(args[1:], stdout, stderr)
case "open":
return runOpen(args[1:], stdout, stderr)
case "shell-init":
return runShellInit(args[1:], stdout, stderr)
case "help", "-h", "--help":
Expand Down
159 changes: 159 additions & 0 deletions cmd/gitcd/open.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// Copyright 2026 Simon Ostendorf
// SPDX-License-Identifier: Apache-2.0

package main

import (
"context"
"flag"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"

"github.com/ostendorf-cloud/gitcd/internal/config"
"github.com/ostendorf-cloud/gitcd/internal/git"
"github.com/ostendorf-cloud/gitcd/internal/layout"
"github.com/ostendorf-cloud/gitcd/internal/remote"
)

// runOpen implements "gitcd open [query]": it resolves query the same
// way "gitcd path" does, an exact repository name or an already cloned
// "host/owner/repo" shorthand, defaulting to the current directory when
// query is left out, and opens that repository's remote in a browser.
// "--print" prints the URL on stdout instead of launching anything, the
// only path a test exercises: launching a real browser is never
// something a test does.
func runOpen(args []string, stdout, stderr io.Writer) int {
fset := flag.NewFlagSet("gitcd open", flag.ContinueOnError)
fset.SetOutput(stderr)
printOnly := fset.Bool("print", false, "print the URL instead of opening a browser")
verbose := fset.Bool("verbose", false, "enable debug logging on stderr")
if err := fset.Parse(args); err != nil {
return 2
}
if fset.NArg() > 1 {
fmt.Fprintln(stderr, "gitcd open: at most one query is accepted")
fset.Usage()
return 2
}
logger := newLogger(*verbose, stderr)
ctx := context.Background()

cfg, err := config.Load()
if err != nil {
fmt.Fprintf(stderr, "gitcd open: %v\n", err)
return 1
}

path, err := resolveOpenTarget(cfg, argOrDefault(fset, "."))
if err != nil {
fmt.Fprintf(stderr, "gitcd open: %v\n", err)
return 1
}
logger.Debug("resolved repository", "path", path)

return openRepo(ctx, stdout, stderr, cfg, path, *printOnly, launchBrowser)
}

// argOrDefault returns fset's one positional argument, or def when none
// was given.
func argOrDefault(fset *flag.FlagSet, def string) string {
if fset.NArg() == 1 {
return fset.Arg(0)
}
return def
}

// resolveOpenTarget resolves query to a repository path, exactly as
// "gitcd path" would: a literal path or "." is used as given, a query
// containing "/" is a "host/owner/repo" shorthand that must already be
// cloned, and anything else is an exact repository name searched for
// under cfg.Root.
func resolveOpenTarget(cfg config.Config, query string) (string, error) {
if query == "." || query == "" {
return query, nil
}
if !strings.Contains(query, "/") {
matches, err := findByName(cfg.Root, query)
if err != nil {
return "", fmt.Errorf("search %s: %w", cfg.Root, err)
}
switch len(matches) {
case 0:
return "", fmt.Errorf("no repository named %q under %s", query, cfg.Root)
case 1:
return matches[0], nil
default:
return "", fmt.Errorf("%q matches more than one repository: %s", query, strings.Join(matches, ", "))
}
}

r, err := remote.Parse(query, cfg.AliasMap())
if err != nil {
return "", err
}
rel, err := layout.Render(cfg.LayoutFor(r.Host), r)
if err != nil {
return "", err
}
dest := filepath.Join(cfg.Root, rel)
if _, err := os.Stat(dest); err != nil {
return "", fmt.Errorf("%s has not been cloned yet", dest)
}
return dest, nil
}

// openRepo reads path's own remote, renders its web URL and either
// prints it or hands it to launch.
func openRepo(
ctx context.Context, stdout, stderr io.Writer, cfg config.Config, path string, printOnly bool,
launch func(context.Context, string) error,
) int {
remoteURL, err := git.RemoteURL(ctx, path)
if err != nil {
fmt.Fprintf(stderr, "gitcd open: %v\n", err)
return 1
}
r, err := remote.Parse(remoteURL, cfg.AliasMap())
if err != nil {
fmt.Fprintf(stderr, "gitcd open: parse the remote of %s: %v\n", path, err)
return 1
}
url := r.WebURL()

if printOnly {
fmt.Fprintln(stdout, url)
return 0
}

if err := launch(ctx, url); err != nil {
fmt.Fprintf(stderr, "gitcd open: %v\n", err)
return 1
}
fmt.Fprintf(stderr, "gitcd open: opened %s\n", url)
return 0
}

// launchBrowser opens url in the platform's own browser command: "open"
// on macOS, "xdg-open" on Linux and the other Unix targets, and "start"
// through "cmd" on Windows. None of these are a dependency gitcd carries;
// every platform already ships the command that opens a URL.
func launchBrowser(ctx context.Context, url string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.CommandContext(ctx, "open", url)
case "windows":
cmd = exec.CommandContext(ctx, "cmd", "/c", "start", "", url)
default:
cmd = exec.CommandContext(ctx, "xdg-open", url)
}
if err := cmd.Run(); err != nil {
return fmt.Errorf("open: launch a browser for %s: %w", url, err)
}
return nil
}
Loading
Loading