From 65bff2fe2e8264f869fc202a282c043b4a336056 Mon Sep 17 00:00:00 2001 From: Simon Ostendorf Date: Fri, 13 Nov 2026 22:38:43 +0100 Subject: [PATCH 1/6] feat(status): read a repository's state from porcelain v2 A cloned empty repository carries an upstream but no branch.ab line, and so does a branch with no upstream at all, so a missing comparison is its own state rather than up to date. Probed against git before the parser was written. Refs #5 --- internal/git/fetch.go | 29 ++++ internal/git/status.go | 26 +++ internal/status/status.go | 152 +++++++++++++++++ internal/status/status_test.go | 290 +++++++++++++++++++++++++++++++++ 4 files changed, 497 insertions(+) create mode 100644 internal/git/fetch.go create mode 100644 internal/git/status.go create mode 100644 internal/status/status.go create mode 100644 internal/status/status_test.go diff --git a/internal/git/fetch.go b/internal/git/fetch.go new file mode 100644 index 0000000..bc69128 --- /dev/null +++ b/internal/git/fetch.go @@ -0,0 +1,29 @@ +// Copyright 2026 Simon Ostendorf +// SPDX-License-Identifier: Apache-2.0 + +package git + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" +) + +// Fetch runs "git -C path fetch" against path's own default remote, +// without a pull, a rebase or a merge: it only ever updates the +// repository's remote tracking refs. Git itself picks the remote, the +// configured upstream of the current branch, or the single remote a +// repository carries when there is no ambiguity, which for a repository +// gitcd cloned is always "origin". +func Fetch(ctx context.Context, path string) error { + var captured bytes.Buffer + cmd := exec.CommandContext(ctx, "git", "-C", path, "fetch") + cmd.Stdout = &captured + cmd.Stderr = &captured + if err := cmd.Run(); err != nil { + return fmt.Errorf("git: fetch %s: %w: %s", path, err, strings.TrimSpace(captured.String())) + } + return nil +} diff --git a/internal/git/status.go b/internal/git/status.go new file mode 100644 index 0000000..738978e --- /dev/null +++ b/internal/git/status.go @@ -0,0 +1,26 @@ +// Copyright 2026 Simon Ostendorf +// SPDX-License-Identifier: Apache-2.0 + +package git + +import ( + "context" + "fmt" + "os/exec" +) + +// StatusPorcelainV2 runs "git -C path status --porcelain=v2 --branch" and +// returns its raw stdout, unparsed: internal/status reads the branch and +// per file lines out of it. Porcelain v2 is used, rather than the plain +// "--short" form, because it is the one format stable across git versions +// and because it reports an unborn HEAD and a branch with no upstream as +// the absence of a line, never as an error, which is exactly the shape +// internal/status needs to tell apart from a crash. +func StatusPorcelainV2(ctx context.Context, path string) (string, error) { + cmd := exec.CommandContext(ctx, "git", "-C", path, "status", "--porcelain=v2", "--branch") + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("git: read the status of %s: %w", path, err) + } + return string(out), nil +} diff --git a/internal/status/status.go b/internal/status/status.go new file mode 100644 index 0000000..a8d518b --- /dev/null +++ b/internal/status/status.go @@ -0,0 +1,152 @@ +// Copyright 2026 Simon Ostendorf +// SPDX-License-Identifier: Apache-2.0 + +// Package status reads one repository's branch, dirty and ahead or +// behind state out of "git status --porcelain=v2 --branch". An unborn +// HEAD, left by cloning an empty repository, and a branch with no +// upstream both omit lines porcelain v2 otherwise prints; State renders +// both as an explicit, named state rather than a blank or missing field. +package status + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/ostendorf-cloud/gitcd/internal/git" +) + +// State is one repository's status, as of one "git status" call. +type State struct { + // Path is the repository's working directory, as given to Inspect. + Path string + // Branch is the current branch name. Empty when Detached is true; + // still set when Unborn is true, since git names the branch a first + // commit will create even before it exists. + Branch string + // Detached reports a detached HEAD, checked out at a commit rather + // than a branch. + Detached bool + // Unborn reports a HEAD with no commit yet, the state a freshly + // cloned empty repository is left in. + Unborn bool + // Upstream is the configured upstream's name, "/", + // or "" when the current branch has none. + Upstream string + // HasUpstreamComparison reports whether git could compare the local + // branch against Upstream at all: false for an unborn HEAD even when + // Upstream is set, since there is no local commit yet to compare. + HasUpstreamComparison bool + // Ahead is the number of commits the local branch carries that + // Upstream does not. Only meaningful when HasUpstreamComparison is + // true. + Ahead int + // Behind is the number of commits Upstream carries that the local + // branch does not. Only meaningful when HasUpstreamComparison is + // true. + Behind int + // Staged reports a change already added to the index. + Staged bool + // Unstaged reports a tracked file changed in the working tree but + // not staged. + Unstaged bool + // Untracked reports a file git does not track at all. + Untracked bool +} + +// Dirty reports whether State carries any uncommitted material at all: +// a staged change, an unstaged change or an untracked file. +func (s State) Dirty() bool { + return s.Staged || s.Unstaged || s.Untracked +} + +// Inspect runs "git status" against path and parses it into a State. +func Inspect(ctx context.Context, path string) (State, error) { + out, err := git.StatusPorcelainV2(ctx, path) + if err != nil { + return State{}, fmt.Errorf("status: inspect %s: %w", path, err) + } + return parse(path, out), nil +} + +// parse reads porcelain v2 output into a State. A line this package does +// not recognise is skipped rather than failing the whole parse: a future +// git version adding a new line kind must never turn a readable status +// into a crash. +func parse(path, out string) State { + s := State{Path: path} + + for _, line := range strings.Split(out, "\n") { + if line == "" { + continue + } + fields := strings.Fields(line) + switch fields[0] { + case "#": + parseBranchLine(&s, fields[1:]) + case "1", "2", "u": + parseChangeLine(&s, fields) + case "?": + s.Untracked = true + } + } + + return s +} + +// parseBranchLine reads one "# branch.*" line's fields, the "#" already +// stripped. +func parseBranchLine(s *State, fields []string) { + if len(fields) < 2 { + return + } + switch fields[0] { + case "branch.oid": + s.Unborn = fields[1] == "(initial)" + case "branch.head": + if fields[1] == "(detached)" { + s.Detached = true + return + } + s.Branch = fields[1] + case "branch.upstream": + s.Upstream = fields[1] + case "branch.ab": + s.HasUpstreamComparison = true + s.Ahead = parseSignedCount(fields[1]) + if len(fields) > 2 { + s.Behind = parseSignedCount(fields[2]) + } + } +} + +// parseSignedCount reads "+3" or "-2" into its absolute value: branch.ab +// always carries the sign itself, "+" for ahead, "-" for behind, so the +// caller already knows which count it is reading. +func parseSignedCount(field string) int { + n, err := strconv.Atoi(strings.TrimPrefix(field, "+")) + if err != nil { + return 0 + } + if n < 0 { + return -n + } + return n +} + +// parseChangeLine reads one ordinary ("1"), rename or copy ("2"), or +// unmerged ("u") entry: every one of those line kinds carries its two +// character XY status at the same position, fields[1]. +func parseChangeLine(s *State, fields []string) { + if len(fields) < 2 || len(fields[1]) != 2 { + return + } + xy := fields[1] + if xy[0] != '.' { + s.Staged = true + } + if xy[1] != '.' { + s.Unstaged = true + } +} diff --git a/internal/status/status_test.go b/internal/status/status_test.go new file mode 100644 index 0000000..f9d2ba4 --- /dev/null +++ b/internal/status/status_test.go @@ -0,0 +1,290 @@ +// Copyright 2026 Simon Ostendorf +// SPDX-License-Identifier: Apache-2.0 + +package status_test + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/ostendorf-cloud/gitcd/internal/status" +) + +// setupGitEnv isolates every git invocation in the test from the real +// user's configuration and from the network. +func setupGitEnv(t *testing.T) { + t.Helper() + home := t.TempDir() + globalConfig := filepath.Join(home, ".gitconfig") + if err := os.WriteFile(globalConfig, []byte("[user]\n\tname = Test User\n\temail = [email protected]\n"), 0o644); err != nil { + t.Fatalf("write %s: %v", globalConfig, err) + } + t.Setenv("HOME", home) + t.Setenv("GIT_CONFIG_GLOBAL", globalConfig) + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") +} + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.CommandContext(context.Background(), "git", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %s: %v: %s", strings.Join(args, " "), err, out) + } +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("create %s: %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +// commitFile adds and commits name with content, in dir, with a +// Conventional Commits subject so the test repository itself stays +// consistent with the organisation's own commit convention. +func commitFile(t *testing.T, dir, name, content string) { + t.Helper() + writeFile(t, filepath.Join(dir, name), content) + runGit(t, dir, "add", name) + runGit(t, dir, "commit", "-m", "chore: add "+name) +} + +func TestInspect_UnbornHEADRendersAsAState(t *testing.T) { + setupGitEnv(t) + + dir := t.TempDir() + runGit(t, dir, "init", "--initial-branch=main") + + got, err := status.Inspect(context.Background(), dir) + if err != nil { + t.Fatalf("Inspect(%q) returned an unexpected error: %v", dir, err) + } + if !got.Unborn { + t.Fatal("Unborn = false, want true for a repository with no commit yet") + } + if got.Branch != "main" { + t.Fatalf("Branch = %q, want %q", got.Branch, "main") + } + if got.HasUpstreamComparison { + t.Fatal("HasUpstreamComparison = true, want false: an unborn HEAD has no commit to compare") + } + if got.Dirty() { + t.Fatal("Dirty() = true, want false for a freshly initialised repository") + } +} + +// TestInspect_ClonedEmptyRepositoryCarriesAnUpstreamButNoComparison guards +// the reported and re-probed behaviour that cloning an empty remote +// leaves the clone's HEAD unborn while "branch.upstream" is still +// printed: a caller must read that as "cannot compare yet", not as "in +// sync" and not as a missing upstream. +func TestInspect_ClonedEmptyRepositoryCarriesAnUpstreamButNoComparison(t *testing.T) { + setupGitEnv(t) + + root := t.TempDir() + bareRemote := filepath.Join(root, "remote.git") + runGit(t, root, "init", "--bare", bareRemote) + + clone := filepath.Join(root, "clone") + runGit(t, root, "clone", bareRemote, clone) + + got, err := status.Inspect(context.Background(), clone) + if err != nil { + t.Fatalf("Inspect(%q) returned an unexpected error: %v", clone, err) + } + if !got.Unborn { + t.Fatal("Unborn = false, want true for a clone of an empty remote") + } + if got.Upstream == "" { + t.Fatal("Upstream = \"\", want a clone of an empty remote to still carry one") + } + if got.HasUpstreamComparison { + t.Fatal("HasUpstreamComparison = true, want false: an unborn HEAD cannot be compared against its upstream") + } +} + +func TestInspect_BranchWithNoUpstreamRendersAsAState(t *testing.T) { + setupGitEnv(t) + + dir := t.TempDir() + runGit(t, dir, "init", "--initial-branch=main") + commitFile(t, dir, "a.txt", "a\n") + + got, err := status.Inspect(context.Background(), dir) + if err != nil { + t.Fatalf("Inspect(%q) returned an unexpected error: %v", dir, err) + } + if got.Unborn { + t.Fatal("Unborn = true, want false: this repository carries a commit") + } + if got.Upstream != "" { + t.Fatalf("Upstream = %q, want \"\" for a branch with no upstream configured", got.Upstream) + } + if got.HasUpstreamComparison { + t.Fatal("HasUpstreamComparison = true, want false for a branch with no upstream") + } +} + +func TestInspect_BranchAheadOfItsUpstream(t *testing.T) { + setupGitEnv(t) + + root := t.TempDir() + bareRemote := filepath.Join(root, "remote.git") + runGit(t, root, "init", "--bare", bareRemote) + + clone := filepath.Join(root, "clone") + runGit(t, root, "clone", bareRemote, clone) + runGit(t, clone, "checkout", "-b", "trunk") + commitFile(t, clone, "a.txt", "a\n") + runGit(t, clone, "push", "origin", "HEAD:trunk") + runGit(t, clone, "branch", "--set-upstream-to=origin/trunk", "trunk") + commitFile(t, clone, "b.txt", "b\n") + + got, err := status.Inspect(context.Background(), clone) + if err != nil { + t.Fatalf("Inspect(%q) returned an unexpected error: %v", clone, err) + } + if !got.HasUpstreamComparison { + t.Fatal("HasUpstreamComparison = false, want true: an upstream is configured and both sides have commits") + } + if got.Ahead != 1 || got.Behind != 0 { + t.Fatalf("Ahead, Behind = %d, %d, want 1, 0", got.Ahead, got.Behind) + } +} + +func TestInspect_BranchInSyncWithItsUpstream(t *testing.T) { + setupGitEnv(t) + + root := t.TempDir() + bareRemote := filepath.Join(root, "remote.git") + runGit(t, root, "init", "--bare", bareRemote) + + clone := filepath.Join(root, "clone") + runGit(t, root, "clone", bareRemote, clone) + runGit(t, clone, "checkout", "-b", "trunk") + commitFile(t, clone, "a.txt", "a\n") + runGit(t, clone, "push", "origin", "HEAD:trunk") + runGit(t, clone, "branch", "--set-upstream-to=origin/trunk", "trunk") + + got, err := status.Inspect(context.Background(), clone) + if err != nil { + t.Fatalf("Inspect(%q) returned an unexpected error: %v", clone, err) + } + if !got.HasUpstreamComparison { + t.Fatal("HasUpstreamComparison = false, want true") + } + if got.Ahead != 0 || got.Behind != 0 { + t.Fatalf("Ahead, Behind = %d, %d, want 0, 0", got.Ahead, got.Behind) + } +} + +func TestInspect_DirtyWorkingTree(t *testing.T) { + setupGitEnv(t) + + dir := t.TempDir() + runGit(t, dir, "init", "--initial-branch=main") + commitFile(t, dir, "a.txt", "a\n") + + // Unstaged modification. + writeFile(t, filepath.Join(dir, "a.txt"), "a\nmodified\n") + // Staged addition. + writeFile(t, filepath.Join(dir, "staged.txt"), "staged\n") + runGit(t, dir, "add", "staged.txt") + // Untracked file, left exactly that way. + writeFile(t, filepath.Join(dir, "untracked.txt"), "untracked\n") + + got, err := status.Inspect(context.Background(), dir) + if err != nil { + t.Fatalf("Inspect(%q) returned an unexpected error: %v", dir, err) + } + if !got.Staged { + t.Fatal("Staged = false, want true") + } + if !got.Unstaged { + t.Fatal("Unstaged = false, want true") + } + if !got.Untracked { + t.Fatal("Untracked = false, want true") + } + if !got.Dirty() { + t.Fatal("Dirty() = false, want true") + } +} + +// TestInspect_UntrackedFileAloneIsDirty guards Dirty() counting an +// untracked file on its own, with no staged or unstaged change +// alongside it: "status --strict" must treat a forgotten new file as +// dirty just as much as an uncommitted edit. +func TestInspect_UntrackedFileAloneIsDirty(t *testing.T) { + setupGitEnv(t) + + dir := t.TempDir() + runGit(t, dir, "init", "--initial-branch=main") + commitFile(t, dir, "a.txt", "a\n") + writeFile(t, filepath.Join(dir, "untracked.txt"), "untracked\n") + + got, err := status.Inspect(context.Background(), dir) + if err != nil { + t.Fatalf("Inspect(%q) returned an unexpected error: %v", dir, err) + } + if got.Staged || got.Unstaged { + t.Fatalf("Staged, Unstaged = %v, %v, want both false: nothing but an untracked file was added", got.Staged, got.Unstaged) + } + if !got.Dirty() { + t.Fatal("Dirty() = false, want true for an untracked file on its own") + } +} + +func TestInspect_CleanWorkingTreeIsNotDirty(t *testing.T) { + setupGitEnv(t) + + dir := t.TempDir() + runGit(t, dir, "init", "--initial-branch=main") + commitFile(t, dir, "a.txt", "a\n") + + got, err := status.Inspect(context.Background(), dir) + if err != nil { + t.Fatalf("Inspect(%q) returned an unexpected error: %v", dir, err) + } + if got.Dirty() { + t.Fatal("Dirty() = true, want false for a clean working tree") + } +} + +func TestInspect_DetachedHEAD(t *testing.T) { + setupGitEnv(t) + + dir := t.TempDir() + runGit(t, dir, "init", "--initial-branch=main") + commitFile(t, dir, "a.txt", "a\n") + commitFile(t, dir, "b.txt", "b\n") + runGit(t, dir, "checkout", "HEAD~1") + + got, err := status.Inspect(context.Background(), dir) + if err != nil { + t.Fatalf("Inspect(%q) returned an unexpected error: %v", dir, err) + } + if !got.Detached { + t.Fatal("Detached = false, want true") + } + if got.Branch != "" { + t.Fatalf("Branch = %q, want \"\" for a detached HEAD", got.Branch) + } +} + +func TestInspect_WrapsTheUnderlyingErrorForANonRepository(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + if _, err := status.Inspect(context.Background(), dir); err == nil { + t.Fatal("Inspect() succeeded, want an error for a directory that is not a git repository") + } +} From 16362898b584e1343598cf9309e433d7872de44b Mon Sep 17 00:00:00 2001 From: Simon Ostendorf Date: Fri, 13 Nov 2026 22:38:44 +0100 Subject: [PATCH 2/6] feat(gitsync): fetch repositories in a bounded pool Results are handed back on the run's own goroutine, so a failure never interleaves another repository's line into nonsense. Refs #5 --- internal/gitsync/gitsync.go | 77 +++++++++++++ internal/gitsync/gitsync_test.go | 182 +++++++++++++++++++++++++++++++ 2 files changed, 259 insertions(+) create mode 100644 internal/gitsync/gitsync.go create mode 100644 internal/gitsync/gitsync_test.go diff --git a/internal/gitsync/gitsync.go b/internal/gitsync/gitsync.go new file mode 100644 index 0000000..338c255 --- /dev/null +++ b/internal/gitsync/gitsync.go @@ -0,0 +1,77 @@ +// Copyright 2026 Simon Ostendorf +// SPDX-License-Identifier: Apache-2.0 + +// Package gitsync fetches a set of repositories through a bounded pool +// of workers. It never pulls, rebases or merges: internal/git.Fetch is +// the only git operation it runs, so a repository's working tree is +// never touched, only its remote tracking refs. +// +// Named "gitsync" rather than "sync" so a caller importing it never +// shadows the standard library package of that name. +package gitsync + +import "context" + +// Repo is one repository to fetch: Path is where it lives on disk, Name +// is how a caller should report it, typically its path relative to the +// configured root. +type Repo struct { + Path string + Name string +} + +// Result is the outcome of fetching one Repo. Err is nil on success. +type Result struct { + Repo Repo + Err error +} + +// Fetcher fetches one repository. internal/git.Fetch implements it; a +// test supplies a fake instead, so this package's own tests never shell +// out to git. +type Fetcher func(ctx context.Context, path string) error + +// Run fetches every repo in repos through fetch, running at most pool +// fetches at once, and calls onResult once per repo as its fetch +// completes. onResult is always called from the single goroutine Run +// itself runs on, one call fully returning before the next begins, so a +// caller that writes a progress line from onResult never has it +// interleaved with another repository's line, however many fetches are +// running concurrently underneath. A pool of 1 or less runs every fetch +// sequentially. +func Run(ctx context.Context, repos []Repo, pool int, fetch Fetcher, onResult func(Result)) { + if pool < 1 { + pool = 1 + } + + jobs := make(chan Repo) + results := make(chan Result) + + workers := pool + if workers > len(repos) { + workers = len(repos) + } + for i := 0; i < workers; i++ { + go worker(ctx, jobs, results, fetch) + } + + go func() { + defer close(jobs) + for _, r := range repos { + jobs <- r + } + }() + + for range repos { + onResult(<-results) + } +} + +// worker fetches every Repo jobs delivers until it is closed, sending +// one Result per job to results. +func worker(ctx context.Context, jobs <-chan Repo, results chan<- Result, fetch Fetcher) { + for r := range jobs { + err := fetch(ctx, r.Path) + results <- Result{Repo: r, Err: err} + } +} diff --git a/internal/gitsync/gitsync_test.go b/internal/gitsync/gitsync_test.go new file mode 100644 index 0000000..c9d7fb4 --- /dev/null +++ b/internal/gitsync/gitsync_test.go @@ -0,0 +1,182 @@ +// Copyright 2026 Simon Ostendorf +// SPDX-License-Identifier: Apache-2.0 + +package gitsync_test + +import ( + "context" + "errors" + "fmt" + "sort" + "sync/atomic" + "testing" + "time" + + "github.com/ostendorf-cloud/gitcd/internal/gitsync" +) + +func repos(n int) []gitsync.Repo { + out := make([]gitsync.Repo, n) + for i := range out { + out[i] = gitsync.Repo{Path: fmt.Sprintf("/repo/%d", i), Name: fmt.Sprintf("repo-%d", i)} + } + return out +} + +func TestRun_FetchesEveryRepositoryExactlyOnce(t *testing.T) { + t.Parallel() + + want := repos(6) + var calls atomic.Int32 + fetch := func(_ context.Context, _ string) error { + calls.Add(1) + return nil + } + + var got []gitsync.Result + gitsync.Run(context.Background(), want, 4, fetch, func(r gitsync.Result) { + got = append(got, r) + }) + + if int(calls.Load()) != len(want) { + t.Fatalf("fetch was called %d times, want %d", calls.Load(), len(want)) + } + if len(got) != len(want) { + t.Fatalf("onResult was called %d times, want %d", len(got), len(want)) + } + + var names []string + for _, r := range got { + if r.Err != nil { + t.Fatalf("Result for %s carries an unexpected error: %v", r.Repo.Name, r.Err) + } + names = append(names, r.Repo.Name) + } + sort.Strings(names) + for i, r := range want { + if names[i] != r.Name { + t.Fatalf("fetched repositories = %v, want every one of %v", names, want) + } + } +} + +// TestRun_ReportsPerRepositoryFailureWithoutAbortingTheRun guards the +// acceptance criterion that one repository's fetch failure neither stops +// the run nor is silently dropped: every repository, including the ones +// after the failing one, must still produce a Result. +func TestRun_ReportsPerRepositoryFailureWithoutAbortingTheRun(t *testing.T) { + t.Parallel() + + want := repos(5) + failing := want[2].Path + boom := errors.New("boom") + fetch := func(_ context.Context, path string) error { + if path == failing { + return boom + } + return nil + } + + results := map[string]error{} + gitsync.Run(context.Background(), want, 2, fetch, func(r gitsync.Result) { + results[r.Repo.Path] = r.Err + }) + + if len(results) != len(want) { + t.Fatalf("got %d results, want one per repository (%d)", len(results), len(want)) + } + if !errors.Is(results[failing], boom) { + t.Fatalf("Result for the failing repository carries %v, want %v", results[failing], boom) + } + for _, r := range want { + if r.Path == failing { + continue + } + if results[r.Path] != nil { + t.Fatalf("Result for %s carries an unexpected error: %v", r.Name, results[r.Path]) + } + } +} + +// TestRun_BoundsConcurrency guards the acceptance criterion that sync +// uses a bounded pool rather than fetching every repository at once: with +// pool 2 and every fetch blocking until released, at most 2 must ever run +// at the same time. +func TestRun_BoundsConcurrency(t *testing.T) { + t.Parallel() + + const pool = 2 + want := repos(6) + + var inFlight, maxInFlight atomic.Int32 + fetch := func(_ context.Context, _ string) error { + n := inFlight.Add(1) + for { + old := maxInFlight.Load() + if n <= old || maxInFlight.CompareAndSwap(old, n) { + break + } + } + time.Sleep(20 * time.Millisecond) + inFlight.Add(-1) + return nil + } + + gitsync.Run(context.Background(), want, pool, fetch, func(gitsync.Result) {}) + + if got := maxInFlight.Load(); got > pool { + t.Fatalf("observed %d fetches in flight at once, want at most %d", got, pool) + } + if got := maxInFlight.Load(); got < pool { + t.Fatalf("observed at most %d fetches in flight at once, want the pool of %d to actually be used", got, pool) + } +} + +// TestRun_OnResultNeverCalledConcurrently guards the "no interleaving" +// requirement sync's progress reporting depends on: even though fetches +// run concurrently, onResult itself must only ever run one call at a +// time. A plain, unsynchronised counter is deliberate here: a concurrent +// call would both race on it, caught by "go test -race", and push it +// above 1. +func TestRun_OnResultNeverCalledConcurrently(t *testing.T) { + t.Parallel() + + want := repos(8) + fetch := func(_ context.Context, _ string) error { + time.Sleep(time.Millisecond) + return nil + } + + active := 0 + maxActive := 0 + gitsync.Run(context.Background(), want, 4, fetch, func(gitsync.Result) { + active++ + if active > maxActive { + maxActive = active + } + time.Sleep(time.Millisecond) + active-- + }) + + if maxActive > 1 { + t.Fatalf("onResult was active %d times at once, want never more than 1", maxActive) + } +} + +func TestRun_EmptyRepositoryListCallsOnResultForNothing(t *testing.T) { + t.Parallel() + + calls := 0 + fetch := func(_ context.Context, _ string) error { + t.Fatal("fetch was called for an empty repository list") + return nil + } + + gitsync.Run(context.Background(), nil, 4, fetch, func(gitsync.Result) { + calls++ + }) + + if calls != 0 { + t.Fatalf("onResult was called %d times, want 0 for an empty repository list", calls) + } +} From bb778dec29729c66cd66e369ef3dc5d8fa386517 Mon Sep 17 00:00:00 2001 From: Simon Ostendorf Date: Fri, 13 Nov 2026 22:38:44 +0100 Subject: [PATCH 3/6] feat(remote): build the web URL a remote points at Refs #5 --- internal/remote/remote.go | 12 ++++++++++ internal/remote/remote_test.go | 43 ++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/internal/remote/remote.go b/internal/remote/remote.go index f7bd009..0cc0822 100644 --- a/internal/remote/remote.go +++ b/internal/remote/remote.go @@ -124,6 +124,18 @@ func (r Remote) URL(protocol string) (string, error) { } } +// WebURL renders r into the URL of its web page: the same shape as its +// "https" clone URL, minus a trailing ".git", since that is the one path +// shape GitHub, GitLab, Bitbucket, Gitea and most other forges share for +// a repository's own page, Azure DevOps's "_git" path already included. +// A host with a different shape of its own is not something this +// package knows: the generic form is reported anyway, so a caller still +// opens something plausible rather than nothing at all. +func (r Remote) WebURL() string { + httpsURL, _ := r.URL("https") + return strings.TrimSuffix(httpsURL, ".git") +} + // classify splits input into a raw, un-lowercased host and a raw path, for // an SSH or HTTPS URL, the scp style "[user@]host:path" and the bare // "host/path" shorthand. It does not resolve aliases or interpret the path. diff --git a/internal/remote/remote_test.go b/internal/remote/remote_test.go index db06d98..76eb571 100644 --- a/internal/remote/remote_test.go +++ b/internal/remote/remote_test.go @@ -214,3 +214,46 @@ func TestRemote_URL(t *testing.T) { }) } } + +func TestRemote_WebURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + remote remote.Remote + want string + }{ + { + name: "github renders the owner and repo path with no dot git suffix", + remote: remote.Remote{Host: "github.com", Owner: "acme", Repo: "widget"}, + want: "https://github.com/acme/widget", + }, + { + name: "azure devops renders its own git path", + remote: remote.Remote{Host: "dev.azure.com", Owner: "acme-corp", Project: "Widget", Repo: "widget-repo"}, + want: "https://dev.azure.com/acme-corp/Widget/_git/widget-repo", + }, + { + // A self hosted forge, or any host this package has no + // special case for, still gets a plausible guess: the + // generic owner/repo shape GitHub, GitLab, Bitbucket and + // Gitea all share. + name: "an unknown host falls back to the generic owner and repo shape", + remote: remote.Remote{Host: "git.example.test", Owner: "acme", Repo: "widget"}, + want: "https://git.example.test/acme/widget", + }, + { + name: "a gitlab subgroup owner is preserved", + remote: remote.Remote{Host: "gitlab.com", Owner: "acme/platform", Repo: "widget"}, + want: "https://gitlab.com/acme/platform/widget", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := tt.remote.WebURL(); got != tt.want { + t.Fatalf("WebURL() = %q, want %q", got, tt.want) + } + }) + } +} From 98b880c82573db0a040b99acbc9c31f3f734d641 Mon Sep 17 00:00:00 2001 From: Simon Ostendorf Date: Fri, 13 Nov 2026 22:38:53 +0100 Subject: [PATCH 4/6] feat(cli): add status, sync and open status renders an unborn HEAD and a missing upstream as states of their own, never as an empty column that reads as up to date. sync fetches only, reports each failure and exits non zero when any failed. open resolves the remote's web URL, or prints it with --print. Refs #5 --- cmd/gitcd/discover.go | 79 ++++++++++++++++ cmd/gitcd/main.go | 9 ++ cmd/gitcd/open.go | 159 +++++++++++++++++++++++++++++++ cmd/gitcd/open_test.go | 189 +++++++++++++++++++++++++++++++++++++ cmd/gitcd/path.go | 39 ++------ cmd/gitcd/status.go | 194 ++++++++++++++++++++++++++++++++++++++ cmd/gitcd/status_test.go | 198 +++++++++++++++++++++++++++++++++++++++ cmd/gitcd/sync.go | 91 ++++++++++++++++++ cmd/gitcd/sync_test.go | 159 +++++++++++++++++++++++++++++++ 9 files changed, 1085 insertions(+), 32 deletions(-) create mode 100644 cmd/gitcd/discover.go create mode 100644 cmd/gitcd/open.go create mode 100644 cmd/gitcd/open_test.go create mode 100644 cmd/gitcd/status.go create mode 100644 cmd/gitcd/status_test.go create mode 100644 cmd/gitcd/sync.go create mode 100644 cmd/gitcd/sync_test.go diff --git a/cmd/gitcd/discover.go b/cmd/gitcd/discover.go new file mode 100644 index 0000000..0c505b4 --- /dev/null +++ b/cmd/gitcd/discover.go @@ -0,0 +1,79 @@ +// Copyright 2026 Simon Ostendorf +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "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, 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, 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) +} diff --git a/cmd/gitcd/main.go b/cmd/gitcd/main.go index fd7c7cb..8e563c5 100644 --- a/cmd/gitcd/main.go +++ b/cmd/gitcd/main.go @@ -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 @@ -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": diff --git a/cmd/gitcd/open.go b/cmd/gitcd/open.go new file mode 100644 index 0000000..6d22184 --- /dev/null +++ b/cmd/gitcd/open.go @@ -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 +} diff --git a/cmd/gitcd/open_test.go b/cmd/gitcd/open_test.go new file mode 100644 index 0000000..94add1b --- /dev/null +++ b/cmd/gitcd/open_test.go @@ -0,0 +1,189 @@ +// Copyright 2026 Simon Ostendorf +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "context" + "errors" + "path/filepath" + "strings" + "testing" + + "github.com/ostendorf-cloud/gitcd/internal/config" +) + +func TestRunOpen_PrintResolvesAnExactRepositoryName(t *testing.T) { + home := setupEnv(t) + root := filepath.Join(home, "Code") + + dest := filepath.Join(root, "github.com", "acme", "widget") + initRepository(t, dest) + runGit(t, dest, "remote", "add", "origin", "https://github.com/acme/widget.git") + + var stdout, stderr bytes.Buffer + code := run([]string{"open", "--print", "widget"}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("open --print widget = %d, want 0, stderr: %s", code, stderr.String()) + } + want := "https://github.com/acme/widget" + if got := strings.TrimSpace(stdout.String()); got != want { + t.Fatalf("open --print widget stdout = %q, want %q", got, want) + } +} + +func TestRunOpen_PrintResolvesAHostOwnerRepoShorthandAlreadyCloned(t *testing.T) { + home := setupEnv(t) + root := filepath.Join(home, "Code") + + dest := filepath.Join(root, "github.com", "acme", "widget") + initRepository(t, dest) + runGit(t, dest, "remote", "add", "origin", "https://github.com/acme/widget.git") + + var stdout, stderr bytes.Buffer + code := run([]string{"open", "--print", "github.com/acme/widget"}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("open --print github.com/acme/widget = %d, want 0, stderr: %s", code, stderr.String()) + } + want := "https://github.com/acme/widget" + if got := strings.TrimSpace(stdout.String()); got != want { + t.Fatalf("open --print stdout = %q, want %q", got, want) + } +} + +func TestRunOpen_PrintDefaultsToTheCurrentDirectory(t *testing.T) { + home := setupEnv(t) + root := filepath.Join(home, "Code") + + dest := filepath.Join(root, "github.com", "acme", "widget") + initRepository(t, dest) + runGit(t, dest, "remote", "add", "origin", "git@github.com:acme/widget.git") + t.Chdir(dest) + + var stdout, stderr bytes.Buffer + code := run([]string{"open", "--print"}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("open --print = %d, want 0, stderr: %s", code, stderr.String()) + } + want := "https://github.com/acme/widget" + if got := strings.TrimSpace(stdout.String()); got != want { + t.Fatalf("open --print stdout = %q, want %q", got, want) + } +} + +// TestRunOpen_PrintFallsBackToAGenericURLForAnUnknownHostShape guards the +// decision that a remote whose host carries no special case still gets a +// plausible web URL, rather than an error: the generic owner/repo shape +// most forges share. +func TestRunOpen_PrintFallsBackToAGenericURLForAnUnknownHostShape(t *testing.T) { + home := setupEnv(t) + root := filepath.Join(home, "Code") + + dest := filepath.Join(root, "git.example.test", "acme", "widget") + initRepository(t, dest) + runGit(t, dest, "remote", "add", "origin", "https://git.example.test/acme/widget.git") + + var stdout, stderr bytes.Buffer + code := run([]string{"open", "--print", "git.example.test/acme/widget"}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("open --print = %d, want 0, stderr: %s", code, stderr.String()) + } + want := "https://git.example.test/acme/widget" + if got := strings.TrimSpace(stdout.String()); got != want { + t.Fatalf("open --print stdout = %q, want %q", got, want) + } +} + +func TestRunOpen_NoMatchFails(t *testing.T) { + setupEnv(t) + + var stdout, stderr bytes.Buffer + code := run([]string{"open", "--print", "nowhere"}, &stdout, &stderr) + + if code == 0 { + t.Fatal("open --print nowhere = 0, want a failure for a name that matches nothing") + } + if stdout.Len() != 0 { + t.Fatalf("open --print nowhere wrote to stdout: %q", stdout.String()) + } +} + +func TestRunOpen_RejectsMoreThanOneArgument(t *testing.T) { + setupEnv(t) + + var stdout, stderr bytes.Buffer + code := run([]string{"open", "one", "two"}, &stdout, &stderr) + + if code != 2 { + t.Fatalf("open with two arguments = %d, want 2", code) + } +} + +// TestOpenRepo_NeverLaunchesARealBrowser exercises the non "--print" path +// through a fake launcher instead of the real one: a test must never +// actually open a browser, and openRepo's launch parameter is exactly +// what lets this test prove that without touching launchBrowser at all. +func TestOpenRepo_NeverLaunchesARealBrowser(t *testing.T) { + home := setupEnv(t) + root := filepath.Join(home, "Code") + + dest := filepath.Join(root, "github.com", "acme", "widget") + initRepository(t, dest) + runGit(t, dest, "remote", "add", "origin", "https://github.com/acme/widget.git") + + var launched string + fakeLaunch := func(_ context.Context, url string) error { + launched = url + return nil + } + + cfg, err := config.Load() + if err != nil { + t.Fatalf("config.Load() returned an unexpected error: %v", err) + } + + var stdout, stderr bytes.Buffer + code := openRepo(context.Background(), &stdout, &stderr, cfg, dest, false, fakeLaunch) + + if code != 0 { + t.Fatalf("openRepo() = %d, want 0, stderr: %s", code, stderr.String()) + } + if launched != "https://github.com/acme/widget" { + t.Fatalf("launch was called with %q, want %q", launched, "https://github.com/acme/widget") + } + if stdout.Len() != 0 { + t.Fatalf("openRepo() wrote to stdout: %q, want everything on stderr", stdout.String()) + } +} + +func TestOpenRepo_ReportsALaunchFailure(t *testing.T) { + home := setupEnv(t) + root := filepath.Join(home, "Code") + + dest := filepath.Join(root, "github.com", "acme", "widget") + initRepository(t, dest) + runGit(t, dest, "remote", "add", "origin", "https://github.com/acme/widget.git") + + boom := errors.New("no display available") + fakeLaunch := func(_ context.Context, _ string) error { return boom } + + cfg, err := config.Load() + if err != nil { + t.Fatalf("config.Load() returned an unexpected error: %v", err) + } + + var stdout, stderr bytes.Buffer + code := openRepo(context.Background(), &stdout, &stderr, cfg, dest, false, fakeLaunch) + + if code == 0 { + t.Fatal("openRepo() = 0, want non-zero when the launcher fails") + } + if !strings.Contains(stderr.String(), "no display available") { + t.Fatalf("openRepo() stderr = %q, want it to name the launcher's error", stderr.String()) + } +} diff --git a/cmd/gitcd/path.go b/cmd/gitcd/path.go index 7437c8d..2932978 100644 --- a/cmd/gitcd/path.go +++ b/cmd/gitcd/path.go @@ -7,13 +7,11 @@ import ( "flag" "fmt" "io" - "io/fs" "os" "path/filepath" "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" ) @@ -99,42 +97,19 @@ func pathFromName(stdout, stderr io.Writer, cfg config.Config, name string) int } } -// findByName walks root for every directory whose own name is exactly -// name and which is itself a repository. It stops descending once it -// finds one, so a submodule or a linked worktree nested inside a match is -// never itself reported as a separate match. +// findByName reports every repository under root whose own directory +// name is exactly name, built on discoverRepos's walk. func findByName(root, name string) ([]string, error) { - if _, err := os.Stat(root); err != nil { - if os.IsNotExist(err) { - return nil, nil - } + repos, err := discoverRepos(root) + if err != nil { return nil, err } var matches []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 + for _, path := range repos { + if filepath.Base(path) == name { + matches = append(matches, path) } - if path == root { - return nil - } - if git.IsRepository(path) { - if d.Name() == name { - matches = append(matches, path) - } - return filepath.SkipDir - } - return nil - }) - if err != nil { - return nil, err } return matches, nil } diff --git a/cmd/gitcd/status.go b/cmd/gitcd/status.go new file mode 100644 index 0000000..2d9c74d --- /dev/null +++ b/cmd/gitcd/status.go @@ -0,0 +1,194 @@ +// Copyright 2026 Simon Ostendorf +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "text/tabwriter" + + "github.com/ostendorf-cloud/gitcd/internal/config" + "github.com/ostendorf-cloud/gitcd/internal/status" +) + +// repoStatus is one repository's status rendering, the shape both the +// table and the "--json" output are built from, so the two never drift +// apart. +type repoStatus struct { + Name string `json:"name"` + Path string `json:"path"` + Branch string `json:"branch,omitempty"` + Detached bool `json:"detached"` + Unborn bool `json:"unborn"` + Upstream string `json:"upstream,omitempty"` + HasSync bool `json:"hasSyncComparison"` + InSync bool `json:"inSync"` + Ahead int `json:"ahead"` + Behind int `json:"behind"` + Staged bool `json:"staged"` + Unstaged bool `json:"unstaged"` + Untracked bool `json:"untracked"` + Dirty bool `json:"dirty"` + Error string `json:"error,omitempty"` +} + +// runStatus implements "gitcd status": every repository under the +// configured root, its branch, its dirty state and how far it is ahead +// or behind its upstream, one line each. "--strict" turns a dirty +// repository, by any of a staged change, an unstaged change or an +// untracked file, into a non zero exit, so a script can gate on a clean +// fleet without parsing the table itself. +func runStatus(args []string, stdout, stderr io.Writer) int { + fset := flag.NewFlagSet("gitcd status", flag.ContinueOnError) + fset.SetOutput(stderr) + jsonOutput := fset.Bool("json", false, "print structured output instead of a table") + strict := fset.Bool("strict", false, "exit non-zero when any repository is dirty") + verbose := fset.Bool("verbose", false, "enable debug logging on stderr") + if err := fset.Parse(args); err != nil { + return 2 + } + if fset.NArg() != 0 { + fmt.Fprintln(stderr, "gitcd status: no arguments are accepted") + fset.Usage() + return 2 + } + logger := newLogger(*verbose, stderr) + ctx := context.Background() + + cfg, err := config.Load() + if err != nil { + fmt.Fprintf(stderr, "gitcd status: %v\n", err) + return 1 + } + + paths, err := discoverRepos(cfg.Root) + if err != nil { + fmt.Fprintf(stderr, "gitcd status: search %s: %v\n", cfg.Root, err) + return 1 + } + logger.Debug("discovered repositories", "root", cfg.Root, "count", len(paths)) + + var results []repoStatus + failed := false + for _, path := range paths { + name := repoName(cfg.Root, path) + st, err := status.Inspect(ctx, path) + if err != nil { + fmt.Fprintf(stderr, "gitcd status: %s: %v\n", name, err) + results = append(results, repoStatus{Name: name, Path: path, Error: err.Error()}) + failed = true + continue + } + results = append(results, toRepoStatus(name, st)) + } + + if *jsonOutput { + if err := json.NewEncoder(stdout).Encode(results); err != nil { + fmt.Fprintf(stderr, "gitcd status: encode output: %v\n", err) + return 1 + } + } else { + writeStatusTable(stdout, results) + } + + if failed { + return 1 + } + if *strict { + for _, r := range results { + if r.Dirty { + return 1 + } + } + } + return 0 +} + +// toRepoStatus renders one status.State into the shape status prints. +func toRepoStatus(name string, st status.State) repoStatus { + return repoStatus{ + Name: name, + Path: st.Path, + Branch: st.Branch, + Detached: st.Detached, + Unborn: st.Unborn, + Upstream: st.Upstream, + HasSync: st.HasUpstreamComparison, + InSync: st.HasUpstreamComparison && st.Ahead == 0 && st.Behind == 0, + Ahead: st.Ahead, + Behind: st.Behind, + Staged: st.Staged, + Unstaged: st.Unstaged, + Untracked: st.Untracked, + Dirty: st.Dirty(), + } +} + +// writeStatusTable renders results as one line per repository: NAME, +// BRANCH, STATE and SYNC, aligned with a tabwriter rather than a fixed +// column width, so a long repository name or a narrow terminal shifts +// later columns instead of breaking the layout or truncating anything. +func writeStatusTable(w io.Writer, results []repoStatus) { + tw := tabwriter.NewWriter(w, 0, 2, 2, ' ', 0) + fmt.Fprintln(tw, "NAME\tBRANCH\tSTATE\tSYNC") + for _, r := range results { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", r.Name, branchColumn(r), stateColumn(r), syncColumn(r)) + } + // tabwriter.Flush can only fail when an earlier Write to w already + // did, and every Write here is a Fprintf whose own error this + // function already ignores by the same convention clone.go and + // profile.go use for stdout and stderr writes throughout this + // package. + _ = tw.Flush() +} + +// branchColumn renders the branch a repository is on, naming a detached +// HEAD explicitly rather than leaving the column blank. +func branchColumn(r repoStatus) string { + if r.Detached { + return "(detached)" + } + return r.Branch +} + +// stateColumn renders a repository's dirty and unborn state as one short +// word, favouring "unborn", since nothing else about the tree is settled +// yet, over whatever dirty flags an empty working tree happens to carry. +func stateColumn(r repoStatus) string { + if r.Error != "" { + return "error" + } + if r.Unborn { + return "unborn" + } + if r.Dirty { + return "dirty" + } + return "clean" +} + +// syncColumn renders how a repository compares to its upstream: "-" when +// that comparison cannot be made at all, an unborn HEAD or no configured +// upstream, never a blank cell that would silently read as "up to date". +func syncColumn(r repoStatus) string { + if r.Error != "" { + return "-" + } + if r.Unborn { + return "-" + } + if r.Upstream == "" { + return "no upstream" + } + if !r.HasSync { + return "-" + } + if r.Ahead == 0 && r.Behind == 0 { + return "up to date" + } + return fmt.Sprintf("+%d -%d", r.Ahead, r.Behind) +} diff --git a/cmd/gitcd/status_test.go b/cmd/gitcd/status_test.go new file mode 100644 index 0000000..e52cd4f --- /dev/null +++ b/cmd/gitcd/status_test.go @@ -0,0 +1,198 @@ +// Copyright 2026 Simon Ostendorf +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// pushToBareRemote creates a bare repository next to repo, pushes +// "main" to it and sets it as "main"'s upstream, so a status test can +// exercise an upstream comparison without reaching the network. +func pushToBareRemote(t *testing.T, repo string) { + t.Helper() + bare := repo + "-bare.git" + runGit(t, filepath.Dir(repo), "init", "--bare", bare) + runGit(t, repo, "remote", "add", "origin", bare) + runGit(t, repo, "push", "origin", "main") + runGit(t, repo, "branch", "--set-upstream-to=origin/main", "main") +} + +func TestRunStatus_RendersEveryRepositoryState(t *testing.T) { + home := setupEnv(t) + root := filepath.Join(home, "Code") + + clean := filepath.Join(root, "host", "acme", "clean") + initRepository(t, clean) + pushToBareRemote(t, clean) + + dirty := filepath.Join(root, "host", "acme", "dirty") + initRepository(t, dirty) + writeFile(t, filepath.Join(dirty, "untracked.txt"), "x\n") + + ahead := filepath.Join(root, "host", "acme", "ahead") + initRepository(t, ahead) + pushToBareRemote(t, ahead) + writeFile(t, filepath.Join(ahead, "more.txt"), "more\n") + runGit(t, ahead, "add", "more.txt") + runGit(t, ahead, "commit", "-m", "add more") + + noUpstream := filepath.Join(root, "host", "acme", "noupstream") + initRepository(t, noUpstream) + + unborn := filepath.Join(root, "host", "acme", "unborn") + runGit(t, filepath.Dir(unborn), "init", unborn) + + var stdout, stderr bytes.Buffer + code := run([]string{"status"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("status = %d, want 0 without --strict, stderr: %s", code, stderr.String()) + } + out := stdout.String() + + for _, want := range []struct{ name, line string }{ + {"clean", "up to date"}, + {"dirty", "dirty"}, + {"ahead", "+1 -0"}, + {"noupstream", "no upstream"}, + {"unborn", "unborn"}, + } { + if !strings.Contains(out, want.name) { + t.Fatalf("status output is missing a line for %q: %s", want.name, out) + } + if !strings.Contains(out, want.line) { + t.Fatalf("status output for %q is missing %q: %s", want.name, want.line, out) + } + } +} + +// TestRunStatus_UnbornHEADRendersAsAStateNotACrash guards the reported +// and re-probed behaviour that a freshly cloned empty repository leaves +// an unborn HEAD, with no "branch.ab" line at all: status must still +// print a row for it, never fail the whole command. +func TestRunStatus_UnbornHEADRendersAsAStateNotACrash(t *testing.T) { + home := setupEnv(t) + root := filepath.Join(home, "Code") + + bare := filepath.Join(home, "remote-bare.git") + runGit(t, home, "init", "--bare", bare) + clone := filepath.Join(root, "host", "acme", "empty") + if err := os.MkdirAll(filepath.Dir(clone), 0o755); err != nil { + t.Fatalf("create %s: %v", filepath.Dir(clone), err) + } + runGit(t, filepath.Dir(clone), "clone", bare, clone) + + var stdout, stderr bytes.Buffer + code := run([]string{"status"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("status = %d, want 0, stderr: %s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "unborn") { + t.Fatalf("status output does not render the unborn HEAD as a state: %s", stdout.String()) + } + if strings.Contains(stdout.String(), "panic") { + t.Fatalf("status output contains a panic: %s", stdout.String()) + } +} + +// TestRunStatus_BranchWithNoUpstreamRendersAsAState guards the second +// reported behaviour: a branch with no upstream at all also omits a +// porcelain line status must not mistake for "up to date". +func TestRunStatus_BranchWithNoUpstreamRendersAsAState(t *testing.T) { + home := setupEnv(t) + root := filepath.Join(home, "Code") + + repo := filepath.Join(root, "host", "acme", "lonely") + initRepository(t, repo) + + var stdout, stderr bytes.Buffer + code := run([]string{"status"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("status = %d, want 0, stderr: %s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "no upstream") { + t.Fatalf("status output does not render the missing upstream as a state: %s", stdout.String()) + } + if strings.Contains(stdout.String(), "up to date") { + t.Fatalf("status output claims a branch with no upstream is up to date: %s", stdout.String()) + } +} + +func TestRunStatus_JSONPrintsStructuredOutput(t *testing.T) { + home := setupEnv(t) + root := filepath.Join(home, "Code") + + dirty := filepath.Join(root, "host", "acme", "dirty") + initRepository(t, dirty) + writeFile(t, filepath.Join(dirty, "untracked.txt"), "x\n") + + var stdout, stderr bytes.Buffer + code := run([]string{"status", "--json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("status --json = %d, want 0, stderr: %s", code, stderr.String()) + } + + var got []repoStatus + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("status --json produced invalid JSON: %v: %s", err, stdout.String()) + } + if len(got) != 1 { + t.Fatalf("status --json reported %d repositories, want 1", len(got)) + } + if !got[0].Dirty || !got[0].Untracked { + t.Fatalf("status --json entry = %+v, want Dirty and Untracked set", got[0]) + } + if got[0].Name != "host/acme/dirty" { + t.Fatalf("status --json name = %q, want %q", got[0].Name, "host/acme/dirty") + } +} + +func TestRunStatus_StrictExitsNonZeroWhenAnyRepositoryIsDirty(t *testing.T) { + home := setupEnv(t) + root := filepath.Join(home, "Code") + + clean := filepath.Join(root, "host", "acme", "clean") + initRepository(t, clean) + + var stdout, stderr bytes.Buffer + if code := run([]string{"status", "--strict"}, &stdout, &stderr); code != 0 { + t.Fatalf("status --strict with only a clean repository = %d, want 0, stderr: %s", code, stderr.String()) + } + + dirty := filepath.Join(root, "host", "acme", "dirty") + initRepository(t, dirty) + writeFile(t, filepath.Join(dirty, "untracked.txt"), "x\n") + + stdout.Reset() + stderr.Reset() + code := run([]string{"status", "--strict"}, &stdout, &stderr) + if code == 0 { + t.Fatal("status --strict = 0, want non-zero with a dirty repository present") + } +} + +func TestRunStatus_NoRepositoriesIsNotAFailure(t *testing.T) { + setupEnv(t) + + var stdout, stderr bytes.Buffer + code := run([]string{"status"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("status with nothing cloned = %d, want 0, stderr: %s", code, stderr.String()) + } +} + +func TestRunStatus_RejectsAnArgument(t *testing.T) { + setupEnv(t) + + var stdout, stderr bytes.Buffer + code := run([]string{"status", "unexpected"}, &stdout, &stderr) + if code != 2 { + t.Fatalf("status with an argument = %d, want 2", code) + } +} diff --git a/cmd/gitcd/sync.go b/cmd/gitcd/sync.go new file mode 100644 index 0000000..73381a1 --- /dev/null +++ b/cmd/gitcd/sync.go @@ -0,0 +1,91 @@ +// Copyright 2026 Simon Ostendorf +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "flag" + "fmt" + "io" + + "github.com/ostendorf-cloud/gitcd/internal/config" + "github.com/ostendorf-cloud/gitcd/internal/git" + "github.com/ostendorf-cloud/gitcd/internal/gitsync" +) + +// syncPoolSize is how many fetches "gitcd sync" runs at once. The issue +// considered serialising the first SSH connection per host, to avoid an +// agent prompt storm, and rejected it: the SSH agent and ControlMaster +// already coalesce that, so a fixed pool of four is all this needs. +const syncPoolSize = 4 + +// runSync implements "gitcd sync": every repository under the +// configured root is fetched, never pulled, rebased or merged, through a +// bounded pool of syncPoolSize workers. One repository's fetch failure +// is reported and does not stop the others; the command exits non-zero +// when any repository failed. "--dry-run" prints the repositories that +// would be fetched on stdout, the dry run's own result, instead of +// fetching anything. +func runSync(args []string, stdout, stderr io.Writer) int { + fset := flag.NewFlagSet("gitcd sync", flag.ContinueOnError) + fset.SetOutput(stderr) + dryRun := fset.Bool("dry-run", false, "print the repositories that would be fetched without fetching them") + verbose := fset.Bool("verbose", false, "enable debug logging on stderr") + if err := fset.Parse(args); err != nil { + return 2 + } + if fset.NArg() != 0 { + fmt.Fprintln(stderr, "gitcd sync: no arguments are accepted") + fset.Usage() + return 2 + } + logger := newLogger(*verbose, stderr) + ctx := context.Background() + + cfg, err := config.Load() + if err != nil { + fmt.Fprintf(stderr, "gitcd sync: %v\n", err) + return 1 + } + + paths, err := discoverRepos(cfg.Root) + if err != nil { + fmt.Fprintf(stderr, "gitcd sync: search %s: %v\n", cfg.Root, err) + return 1 + } + logger.Debug("discovered repositories", "root", cfg.Root, "count", len(paths)) + + repos := make([]gitsync.Repo, len(paths)) + for i, path := range paths { + repos[i] = gitsync.Repo{Path: path, Name: repoName(cfg.Root, path)} + } + + if *dryRun { + for _, r := range repos { + fmt.Fprintln(stdout, r.Name) + } + return 0 + } + + if len(repos) == 0 { + fmt.Fprintln(stderr, "gitcd sync: no repositories found") + return 0 + } + + failed := 0 + gitsync.Run(ctx, repos, syncPoolSize, git.Fetch, func(r gitsync.Result) { + if r.Err != nil { + failed++ + fmt.Fprintf(stderr, "gitcd sync: %s: failed: %v\n", r.Repo.Name, r.Err) + return + } + fmt.Fprintf(stderr, "gitcd sync: %s: fetched\n", r.Repo.Name) + }) + + fmt.Fprintf(stderr, "gitcd sync: %d of %d repositories failed\n", failed, len(repos)) + if failed > 0 { + return 1 + } + return 0 +} diff --git a/cmd/gitcd/sync_test.go b/cmd/gitcd/sync_test.go new file mode 100644 index 0000000..6f13c04 --- /dev/null +++ b/cmd/gitcd/sync_test.go @@ -0,0 +1,159 @@ +// Copyright 2026 Simon Ostendorf +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +// cloneFromBareRemote creates a bare repository with one commit and +// clones it into dest, so "gitcd sync" has a real, local-only remote to +// fetch from. +func cloneFromBareRemote(t *testing.T, root, dest string) { + t.Helper() + bare := dest + "-bare.git" + source := dest + "-source" + initRepository(t, source) + runGit(t, root, "clone", "--bare", source, bare) + + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + t.Fatalf("create %s: %v", filepath.Dir(dest), err) + } + runGit(t, root, "clone", bare, dest) +} + +// advanceBareRemote commits a second file in source and pushes it to +// bare, the clone's own remote, so a later "git fetch" in the clone has +// something new to bring down. +func advanceBareRemote(t *testing.T, source, bare string) { + t.Helper() + writeFile(t, filepath.Join(source, "second.txt"), "second\n") + runGit(t, source, "add", "second.txt") + runGit(t, source, "commit", "-m", "second commit") + runGit(t, source, "push", bare, "HEAD:main") +} + +func TestRunSync_FetchesEveryRepository(t *testing.T) { + home := setupEnv(t) + root := filepath.Join(home, "Code") + + first := filepath.Join(root, "host", "acme", "first") + second := filepath.Join(root, "host", "acme", "second") + cloneFromBareRemote(t, home, first) + cloneFromBareRemote(t, home, second) + + // Advance each remote by one commit after cloning, so the fetch + // "sync" runs has something to actually bring down, rather than + // succeeding regardless of whether it ran at all. + advanceBareRemote(t, first+"-source", first+"-bare.git") + advanceBareRemote(t, second+"-source", second+"-bare.git") + + var stdout, stderr bytes.Buffer + code := run([]string{"sync"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("sync = %d, want 0, stderr: %s", code, stderr.String()) + } + if stdout.Len() != 0 { + t.Fatalf("sync wrote to stdout: %q, want everything on stderr", stdout.String()) + } + + for _, name := range []string{"host/acme/first", "host/acme/second"} { + if !strings.Contains(stderr.String(), name) { + t.Fatalf("sync stderr is missing a line for %q: %s", name, stderr.String()) + } + } + + for _, repo := range []string{first, second} { + out := runGitOutput(t, repo, "log", "-1", "--format=%s", "origin/main") + if out != "second commit" { + t.Fatalf("%s: origin/main = %q, want the fetch to have brought in the remote's new commit", repo, out) + } + } +} + +// TestRunSync_ReportsPerRepositoryFailuresWithoutAbortingTheRun guards +// the acceptance criterion directly: a repository whose remote is gone +// must fail on its own, every other repository must still be fetched, +// and the command as a whole must exit non-zero. +func TestRunSync_ReportsPerRepositoryFailuresWithoutAbortingTheRun(t *testing.T) { + home := setupEnv(t) + root := filepath.Join(home, "Code") + + broken := filepath.Join(root, "host", "acme", "broken") + ok := filepath.Join(root, "host", "acme", "ok") + cloneFromBareRemote(t, home, broken) + cloneFromBareRemote(t, home, ok) + advanceBareRemote(t, ok+"-source", ok+"-bare.git") + + // Remove the broken repository's remote target so its fetch fails, + // without touching its working tree: sync never pulls or merges, so + // nothing about this assertion depends on that. + if err := os.RemoveAll(broken + "-bare.git"); err != nil { + t.Fatalf("remove %s: %v", broken+"-bare.git", err) + } + + var stdout, stderr bytes.Buffer + code := run([]string{"sync"}, &stdout, &stderr) + if code == 0 { + t.Fatal("sync = 0, want non-zero when a repository's fetch fails") + } + if !strings.Contains(stderr.String(), "host/acme/broken") { + t.Fatalf("sync stderr does not name the failing repository: %s", stderr.String()) + } + if !strings.Contains(stderr.String(), "host/acme/ok") { + t.Fatalf("sync stderr does not report the repository that succeeded: %s", stderr.String()) + } + + out := runGitOutput(t, ok, "log", "-1", "--format=%s", "origin/main") + if out != "second commit" { + t.Fatalf("%s: origin/main = %q, want the healthy repository fetched despite the other one failing", ok, out) + } +} + +func TestRunSync_DryRunFetchesNothing(t *testing.T) { + home := setupEnv(t) + root := filepath.Join(home, "Code") + + repo := filepath.Join(root, "host", "acme", "repo") + cloneFromBareRemote(t, home, repo) + advanceBareRemote(t, repo+"-source", repo+"-bare.git") + + var stdout, stderr bytes.Buffer + code := run([]string{"sync", "--dry-run"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("sync --dry-run = %d, want 0, stderr: %s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "host/acme/repo") { + t.Fatalf("sync --dry-run stdout = %q, want it to name the repository it would fetch", stdout.String()) + } + + out := runGitOutput(t, repo, "log", "-1", "--format=%s", "origin/main") + if out == "second commit" { + t.Fatal("sync --dry-run fetched the remote, want it untouched") + } +} + +func TestRunSync_NoRepositoriesIsNotAFailure(t *testing.T) { + setupEnv(t) + + var stdout, stderr bytes.Buffer + code := run([]string{"sync"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("sync with nothing cloned = %d, want 0, stderr: %s", code, stderr.String()) + } +} + +func TestRunSync_RejectsAnArgument(t *testing.T) { + setupEnv(t) + + var stdout, stderr bytes.Buffer + code := run([]string{"sync", "unexpected"}, &stdout, &stderr) + if code != 2 { + t.Fatalf("sync with an argument = %d, want 2", code) + } +} From 0277bfbe905be3a85682a6909149a9cc4886159a Mon Sep 17 00:00:00 2001 From: Simon Ostendorf Date: Fri, 13 Nov 2026 22:38:53 +0100 Subject: [PATCH 5/6] docs(agents): record the status, sync and open packages Including the walk in cmd/gitcd/discover.go, which overlaps internal/scan from #3 and is flagged for reconciliation once both land. Refs #5 --- AGENTS.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1ce4a71..39730d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 From 346c6cdcfd05e4b881662334cc3d532cb7fe4e19 Mon Sep 17 00:00:00 2001 From: Simon Ostendorf Date: Fri, 13 Nov 2026 22:55:20 +0100 Subject: [PATCH 6/6] fix(cli): wrap the discover errors and cover every forge shape Review nits: the walk returned bare errors unlike internal/scan, and the forge URL shapes were only covered through the remote package, not from the command itself. Refs #5 --- cmd/gitcd/discover.go | 5 +++-- cmd/gitcd/open_test.go | 46 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/cmd/gitcd/discover.go b/cmd/gitcd/discover.go index 0c505b4..9d7c9e3 100644 --- a/cmd/gitcd/discover.go +++ b/cmd/gitcd/discover.go @@ -4,6 +4,7 @@ package main import ( + "fmt" "io/fs" "os" "path/filepath" @@ -35,7 +36,7 @@ func discoverRepos(root string) ([]string, error) { if os.IsNotExist(err) { return nil, nil } - return nil, err + return nil, fmt.Errorf("discover: stat %s: %w", root, err) } var repos []string @@ -59,7 +60,7 @@ func discoverRepos(root string) ([]string, error) { return nil }) if err != nil { - return nil, err + return nil, fmt.Errorf("discover: walk %s: %w", root, err) } sort.Strings(repos) return repos, nil diff --git a/cmd/gitcd/open_test.go b/cmd/gitcd/open_test.go index 94add1b..8da2093 100644 --- a/cmd/gitcd/open_test.go +++ b/cmd/gitcd/open_test.go @@ -99,6 +99,52 @@ func TestRunOpen_PrintFallsBackToAGenericURLForAnUnknownHostShape(t *testing.T) } } +// TestRunOpen_PrintResolvesEveryForgeShapeFromTheCommand covers the forge +// shapes end to end rather than only through the remote package: Azure +// DevOps keeps its "_git" segment, while GitLab takes the generic owner +// and repository path even when the owner carries a subgroup. +func TestRunOpen_PrintResolvesEveryForgeShapeFromTheCommand(t *testing.T) { + tests := []struct { + name string + path []string + remote string + query string + want string + }{ + { + name: "gitlab subgroup keeps every owner segment", + path: []string{"gitlab.com", "acme", "team", "widget"}, + remote: "https://gitlab.com/acme/team/widget.git", + query: "gitlab.com/acme/team/widget", + want: "https://gitlab.com/acme/team/widget", + }, + { + name: "azure devops keeps its _git segment", + path: []string{"dev.azure.com", "acme", "Widget", "widget"}, + remote: "https://dev.azure.com/acme/Widget/_git/widget", + query: "widget", + want: "https://dev.azure.com/acme/Widget/_git/widget", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + home := setupEnv(t) + dest := filepath.Join(append([]string{home, "Code"}, tt.path...)...) + initRepository(t, dest) + runGit(t, dest, "remote", "add", "origin", tt.remote) + + var stdout, stderr bytes.Buffer + if code := run([]string{"open", "--print", tt.query}, &stdout, &stderr); code != 0 { + t.Fatalf("open --print = %d, want 0, stderr: %s", code, stderr.String()) + } + if got := strings.TrimSpace(stdout.String()); got != tt.want { + t.Fatalf("open --print stdout = %q, want %q", got, tt.want) + } + }) + } +} + func TestRunOpen_NoMatchFails(t *testing.T) { setupEnv(t)