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 diff --git a/cmd/gitcd/discover.go b/cmd/gitcd/discover.go new file mode 100644 index 0000000..9d7c9e3 --- /dev/null +++ b/cmd/gitcd/discover.go @@ -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) +} 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..8da2093 --- /dev/null +++ b/cmd/gitcd/open_test.go @@ -0,0 +1,235 @@ +// 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) + } +} + +// 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) + + 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) + } +} 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/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) + } +} 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) + } + }) + } +} 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") + } +}