diff --git a/cmd/git-manager/main.go b/cmd/git-manager/main.go index a847b08..b262647 100644 --- a/cmd/git-manager/main.go +++ b/cmd/git-manager/main.go @@ -117,7 +117,7 @@ func runSync(args []string, stdout, stderr io.Writer) int { return 1 } - opts := synccli.Options{DryRun: *dryRun, Overwrite: *overwrite || *prune, Concurrency: *parallel} + opts := synccli.Options{DryRun: *dryRun, Overwrite: *overwrite || *prune, Concurrency: *parallel, ConfigPath: *configPath} // Set progress callback: default shows progress on stderr, -q/--quiet suppresses, // --json also suppresses (and outputs JSON only). @@ -127,7 +127,11 @@ func runSync(args []string, stdout, stderr io.Writer) int { opts.ProgressCallback = func(e synccli.RepoEvent) { mu.Lock() defer mu.Unlock() - fmt.Fprintf(stderr, "sync: %s (%s)\n", e.Name, e.Result.Outcome) + if e.Result.Error != "" { + fmt.Fprintf(stderr, "sync: %s (%s): %s\n", e.Name, e.Result.Outcome, e.Result.Error) + } else { + fmt.Fprintf(stderr, "sync: %s (%s)\n", e.Name, e.Result.Outcome) + } } } @@ -178,7 +182,7 @@ func runStatus(args []string, stdout, stderr io.Writer) int { return 2 } - syncReport := synccli.Run(context.Background(), gitcli.NewClient(), cfg, synccli.Options{DryRun: true, Concurrency: *parallel}) + syncReport := synccli.Run(context.Background(), gitcli.NewClient(), cfg, synccli.Options{DryRun: true, Concurrency: *parallel, ConfigPath: *configPath}) report := status.FromSyncReport(syncReport) if *jsonOut { @@ -235,10 +239,18 @@ func printReport(w io.Writer, report synccli.Report) { } for _, r := range report.Repos { if r.Error != "" { - fmt.Fprintf(w, "FAIL %s: %s [%s, %dms]\n", r.Name, r.Error, r.Outcome, r.DurationMS) + path := fmt.Sprintf("(%s)", r.Path) + if r.Group != "" { + path = fmt.Sprintf("(%s, group %q)", r.Path, r.Group) + } + fmt.Fprintf(w, "FAIL %s %s: %s [%s, %dms]\n", r.Name, path, r.Error, r.Outcome, r.DurationMS) continue } - fmt.Fprintf(w, "OK %s (%s) [%s, %s, %dms]\n", r.Name, r.Path, verb, r.Outcome, r.DurationMS) + groupStr := "" + if r.Group != "" { + groupStr = fmt.Sprintf(", group %q", r.Group) + } + fmt.Fprintf(w, "OK %s (%s%s) [%s, %s, %dms]\n", r.Name, r.Path, groupStr, verb, r.Outcome, r.DurationMS) if r.Cloned { fmt.Fprintln(w, " cloned") } @@ -258,6 +270,9 @@ func printReport(w io.Writer, report synccli.Report) { fmt.Fprintf(w, " fetched: %s (%s)\n", f.Remote, f.Report.Mode) } } + if report.Config != "" { + fmt.Fprintf(w, "config: %s\n", report.Config) + } fmt.Fprintf(w, "%d repo(s), %d error(s)\n", len(report.Repos), report.ErrorCount) } diff --git a/cmd/git-manager/main_test.go b/cmd/git-manager/main_test.go index 600c844..536c676 100644 --- a/cmd/git-manager/main_test.go +++ b/cmd/git-manager/main_test.go @@ -344,6 +344,7 @@ func TestRunSync_JSONOutcomeFailureGolden(t *testing.T) { t.Fatalf("report.Repos[0].DurationMS = %d, want >= 0", report.Repos[0].DurationMS) } + report.Config = "" report.Repos[0].DurationMS = 0 report.Repos[0].Path = "" report.Repos[0].Error = "" @@ -390,6 +391,7 @@ func TestRunSync_JSONOutcomePartialGolden(t *testing.T) { t.Fatalf("report.Repos[0].Remotes.Updated = %+v, want the origin URL update recorded", report.Repos[0].Remotes.Updated) } + report.Config = "" report.Repos[0].DurationMS = 0 report.Repos[0].Path = "" report.Repos[0].Error = "" @@ -603,6 +605,7 @@ func TestRunStatus_JSONOutcomeGolden(t *testing.T) { t.Fatalf("negative duration_ms: report=%d repo=%d", report.DurationMS, report.Repos[0].DurationMS) } + report.Config = "" report.Repos[0].DurationMS = 0 report.Repos[0].Path = "" for i := range report.Repos[0].Remotes.Updated { @@ -649,6 +652,7 @@ func TestRunStatus_JSONOutcomeFailureGolden(t *testing.T) { t.Fatal("report.Repos[0].Error is empty, want the no-origin error") } + report.Config = "" report.Repos[0].DurationMS = 0 report.Repos[0].Path = "" report.Repos[0].Error = "" @@ -979,3 +983,206 @@ func TestRunSync_ProgressCallbackThreadSafety(t *testing.T) { t.Fatalf("report.Repos has %d repos, want 4", len(report.Repos)) } } + +// TestRunSync_ConfigPathInReport_Success verifies AC8: the resolved config +// path appears in the JSON report on a successful run. +func TestRunSync_ConfigPathInReport_Success(t *testing.T) { + origin := initBareRepo(t) + groupPath := t.TempDir() + configPath := writeConfig(t, groupPath, fileURL(origin)) + + var stdout, stderr bytes.Buffer + code := run([]string{"sync", "-config", configPath, "--json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String()) + } + + var report sync.Report + if err := json.Unmarshal(stdout.Bytes(), &report); err != nil { + t.Fatalf("stdout is not valid JSON: %v\nstdout: %s", err, stdout.String()) + } + + // AC8: config path must be in the report and must match the real config path + if report.Config == "" { + t.Fatal("report.Config is empty, want the resolved config path") + } + if report.Config != configPath { + t.Fatalf("report.Config = %q, want %q", report.Config, configPath) + } +} + +// TestRunSync_ConfigPathInReport_Failure verifies AC8: the resolved config +// path appears in the JSON report even on a failing run. +func TestRunSync_ConfigPathInReport_Failure(t *testing.T) { + groupPath := t.TempDir() + configPath := filepath.Join(t.TempDir(), "config.toml") + contents := "[[groups]]\n" + + "name = \"work\"\n" + + "path = \"" + filepath.ToSlash(groupPath) + "\"\n\n" + + " [[groups.repos]]\n" + + " name = \"no-origin\"\n" + if err := os.WriteFile(configPath, []byte(contents), 0o644); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + code := run([]string{"sync", "-config", configPath, "--json"}, &stdout, &stderr) + if code == 0 { + t.Fatal("expected non-zero exit code when a repo fails to sync") + } + + var report sync.Report + if err := json.Unmarshal(stdout.Bytes(), &report); err != nil { + t.Fatalf("stdout is not valid JSON: %v\nstdout: %s", err, stdout.String()) + } + + // AC8: config path must be in the report even on failure + if report.Config == "" { + t.Fatal("report.Config is empty on failure, want the resolved config path") + } + if report.Config != configPath { + t.Fatalf("report.Config = %q, want %q", report.Config, configPath) + } +} + +// TestRunSync_FailLineIncludesPathAndGroup verifies AC2: the FAIL line in +// human-readable output includes path and group, matching the OK line format. +func TestRunSync_FailLineIncludesPathAndGroup(t *testing.T) { + groupPath := t.TempDir() + configPath := filepath.Join(t.TempDir(), "config.toml") + contents := "[[groups]]\n" + + "name = \"work\"\n" + + "path = \"" + filepath.ToSlash(groupPath) + "\"\n\n" + + " [[groups.repos]]\n" + + " name = \"no-origin\"\n" + if err := os.WriteFile(configPath, []byte(contents), 0o644); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + code := run([]string{"sync", "-config", configPath}, &stdout, &stderr) + if code == 0 { + t.Fatal("expected non-zero exit code when a repo fails to sync") + } + + output := stdout.String() + // AC2: FAIL line must include path and group like OK does + if !strings.Contains(output, "FAIL") { + t.Fatalf("expected FAIL line in output, got: %s", output) + } + if !strings.Contains(output, groupPath) { + t.Fatalf("FAIL line missing path %q: %s", groupPath, output) + } + if !strings.Contains(output, "work") { + t.Fatalf("FAIL line missing group 'work': %s", output) + } +} + +// TestRunSync_ProgressCallbackIncludesErrorText verifies AC3: the live +// progress line includes error text on failure, not just the outcome. +func TestRunSync_ProgressCallbackIncludesErrorText(t *testing.T) { + groupPath := t.TempDir() + configPath := filepath.Join(t.TempDir(), "config.toml") + // Use "example-project" (not "no-origin") so repo name doesn't contain + // substrings from the error text, proving the error text is actually printed. + contents := "[[groups]]\n" + + "name = \"work\"\n" + + "path = \"" + filepath.ToSlash(groupPath) + "\"\n\n" + + " [[groups.repos]]\n" + + " name = \"example-project\"\n" + if err := os.WriteFile(configPath, []byte(contents), 0o644); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + code := run([]string{"sync", "-config", configPath}, &stdout, &stderr) + if code == 0 { + t.Fatal("expected non-zero exit code when a repo fails to sync") + } + + stderrOutput := stderr.String() + // AC3: progress line must include error text, not just outcome. + // Assert the progress prefix exists: + if !strings.Contains(stderrOutput, "sync:") { + t.Fatalf("expected progress line with 'sync:' prefix, got: %s", stderrOutput) + } + // Assert the repo name appears: + if !strings.Contains(stderrOutput, "example-project") { + t.Fatalf("expected progress line with repo name 'example-project', got: %s", stderrOutput) + } + // Assert the actual error text is present (not just outcome "failure"). + // The error for a repo with no origin and no checkout is: + // "sync: repo %q has no origin remote declared and no local checkout exists to clone" + // Assert on the unique substring from the actual error, not just outcome or repo name. + if !strings.Contains(stderrOutput, "has no origin remote declared") { + t.Fatalf("expected error text 'has no origin remote declared' in progress line, got: %s", stderrOutput) + } +} + +// TestRunStatus_ConfigPathInReport_Success verifies AC8: the resolved config +// path appears in the status report on a successful (in-sync) run. +func TestRunStatus_ConfigPathInReport_Success(t *testing.T) { + origin := initBareRepo(t) + groupPath := t.TempDir() + repoPath := filepath.Join(groupPath, "example-project") + if err := os.MkdirAll(repoPath, 0o755); err != nil { + t.Fatal(err) + } + runFixture(t, repoPath, "init", "-b", "main") + runFixture(t, repoPath, "remote", "add", "origin", fileURL(origin)) + configPath := writeConfig(t, groupPath, fileURL(origin)) + + var stdout, stderr bytes.Buffer + code := run([]string{"status", "-config", configPath, "--json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String()) + } + + var report status.Report + if err := json.Unmarshal(stdout.Bytes(), &report); err != nil { + t.Fatalf("stdout is not valid JSON: %v\nstdout: %s", err, stdout.String()) + } + + // AC8: config path must be in the status report and must match the real config path + if report.Config == "" { + t.Fatal("report.Config is empty, want the resolved config path") + } + if report.Config != configPath { + t.Fatalf("report.Config = %q, want %q", report.Config, configPath) + } +} + +// TestRunStatus_ConfigPathInReport_Failure verifies AC8: the resolved config +// path appears in the status report even when a repo has errors/drift. +func TestRunStatus_ConfigPathInReport_Failure(t *testing.T) { + origin := initBareRepo(t) + groupPath := t.TempDir() + repoPath := filepath.Join(groupPath, "example-project") + if err := os.MkdirAll(repoPath, 0o755); err != nil { + t.Fatal(err) + } + runFixture(t, repoPath, "init", "-b", "main") + // Point origin at a different URL to create drift + runFixture(t, repoPath, "remote", "add", "origin", "https://example.com/stale.git") + configPath := writeConfig(t, groupPath, fileURL(origin)) + + var stdout, stderr bytes.Buffer + code := run([]string{"status", "-config", configPath, "--json"}, &stdout, &stderr) + // Status returns 1 when there is drift + if code != 1 { + t.Fatalf("exit code = %d, want 1 for drift; stderr: %s", code, stderr.String()) + } + + var report status.Report + if err := json.Unmarshal(stdout.Bytes(), &report); err != nil { + t.Fatalf("stdout is not valid JSON: %v\nstdout: %s", err, stdout.String()) + } + + // AC8: config path must be in the status report even with drift + if report.Config == "" { + t.Fatal("report.Config is empty on drift, want the resolved config path") + } + if report.Config != configPath { + t.Fatalf("report.Config = %q, want %q", report.Config, configPath) + } +} diff --git a/cmd/git-manager/testdata/status_json.golden b/cmd/git-manager/testdata/status_json.golden index d7435b0..a2faa9e 100644 --- a/cmd/git-manager/testdata/status_json.golden +++ b/cmd/git-manager/testdata/status_json.golden @@ -1,7 +1,9 @@ { + "config": "", "repos": [ { "name": "example-project", + "group": "work", "path": "", "drifted": true, "cloned": false, diff --git a/cmd/git-manager/testdata/status_json_failure.golden b/cmd/git-manager/testdata/status_json_failure.golden index 042605d..23b37ba 100644 --- a/cmd/git-manager/testdata/status_json_failure.golden +++ b/cmd/git-manager/testdata/status_json_failure.golden @@ -1,7 +1,9 @@ { + "config": "", "repos": [ { "name": "no-origin", + "group": "work", "path": "", "drifted": true, "cloned": false, diff --git a/cmd/git-manager/testdata/sync_dryrun.golden b/cmd/git-manager/testdata/sync_dryrun.golden index 8d78ad9..25dab69 100644 --- a/cmd/git-manager/testdata/sync_dryrun.golden +++ b/cmd/git-manager/testdata/sync_dryrun.golden @@ -1,7 +1,9 @@ { + "config": "", "repos": [ { "name": "example-project", + "group": "work", "path": "/example-project", "cloned": true, "remotes": { diff --git a/cmd/git-manager/testdata/sync_failure.golden b/cmd/git-manager/testdata/sync_failure.golden index 22e9062..f48940d 100644 --- a/cmd/git-manager/testdata/sync_failure.golden +++ b/cmd/git-manager/testdata/sync_failure.golden @@ -1,7 +1,9 @@ { + "config": "", "repos": [ { "name": "no-origin", + "group": "work", "path": "", "cloned": false, "remotes": { diff --git a/cmd/git-manager/testdata/sync_partial.golden b/cmd/git-manager/testdata/sync_partial.golden index bf20dac..df50844 100644 --- a/cmd/git-manager/testdata/sync_partial.golden +++ b/cmd/git-manager/testdata/sync_partial.golden @@ -1,7 +1,9 @@ { + "config": "", "repos": [ { "name": "example-project", + "group": "work", "path": "", "cloned": false, "remotes": { diff --git a/internal/status/status.go b/internal/status/status.go index 4962081..417609d 100644 --- a/internal/status/status.go +++ b/internal/status/status.go @@ -35,6 +35,7 @@ type IdentityDrift struct { // JSON shape is additive/forward-compatible; see docs/json-schema.md. type RepoResult struct { Name string `json:"name"` + Group string `json:"group,omitempty"` Path string `json:"path"` Drifted bool `json:"drifted"` Cloned bool `json:"cloned"` @@ -53,6 +54,7 @@ type RepoResult struct { // // JSON shape is additive/forward-compatible; see docs/json-schema.md. type Report struct { + Config string `json:"config"` Repos []RepoResult `json:"repos"` Drifted bool `json:"drifted"` ErrorCount int `json:"error_count"` @@ -67,7 +69,7 @@ type Report struct { // compute exactly this drift as a side effect of planning; this just renames // "what would be applied" as "what has drifted". func FromSyncReport(sr sync.Report) Report { - report := Report{ErrorCount: sr.ErrorCount, DurationMS: sr.DurationMS} + report := Report{Config: sr.Config, ErrorCount: sr.ErrorCount, DurationMS: sr.DurationMS} for _, rr := range sr.Repos { repo := repoResultFrom(rr) if repo.Drifted { @@ -81,6 +83,7 @@ func FromSyncReport(sr sync.Report) Report { func repoResultFrom(rr sync.RepoResult) RepoResult { repo := RepoResult{ Name: rr.Name, + Group: rr.Group, Path: rr.Path, Cloned: rr.Cloned, Remotes: remoteDriftFrom(rr.Remotes), diff --git a/internal/sync/identity.go b/internal/sync/identity.go index 418cb5e..25307b6 100644 --- a/internal/sync/identity.go +++ b/internal/sync/identity.go @@ -28,8 +28,9 @@ type IdentityReport struct { // only (git config --local, per the project's identity-scope invariant — a // field is never applied at --global or --system). A nil field on identity // was never declared at any config level and is left untouched: no read, no -// write, no key in the report. -func ApplyIdentity(ctx context.Context, c identityClient, repo string, identity config.ResolvedIdentity) (IdentityReport, error) { +// write, no key in the report. repoName and groupName are used only for error +// context wrapping; pass empty string for groupName if the repo is not in a group. +func ApplyIdentity(ctx context.Context, c identityClient, repo, repoName, groupName string, identity config.ResolvedIdentity) (IdentityReport, error) { var report IdentityReport if identity.UserName != nil { @@ -50,7 +51,14 @@ func ApplyIdentity(ctx context.Context, c identityClient, repo string, identity if identity.SigningMethod != nil { key, value, err := signingMethodConfig(*identity.SigningMethod) if err != nil { - return report, err + // Wrap error with identifying context: repo name, path, and group when applicable. + var contextStr string + if groupName != "" { + contextStr = fmt.Sprintf("repo %q in group %q (%s)", repoName, groupName, repo) + } else { + contextStr = fmt.Sprintf("repo %q (%s)", repoName, repo) + } + return report, fmt.Errorf("sync: %s: %w", contextStr, err) } if err := setIfChanged(ctx, c, repo, key, value, &report); err != nil { return report, err diff --git a/internal/sync/identity_test.go b/internal/sync/identity_test.go index be7d9eb..a09df52 100644 --- a/internal/sync/identity_test.go +++ b/internal/sync/identity_test.go @@ -27,7 +27,7 @@ func TestApplyIdentity_UserNameAndEmail(t *testing.T) { UserEmail: strPtr("octocat@example.com"), } - report, err := ApplyIdentity(context.Background(), c, repo, identity) + report, err := ApplyIdentity(context.Background(), c, repo, "example-project", "work", identity) if err != nil { t.Fatalf("ApplyIdentity: %v", err) } @@ -50,7 +50,7 @@ func TestApplyIdentity_SigningMethodGPG(t *testing.T) { c := gitcli.NewClient() identity := config.ResolvedIdentity{SigningMethod: strPtr("gpg")} - if _, err := ApplyIdentity(context.Background(), c, repo, identity); err != nil { + if _, err := ApplyIdentity(context.Background(), c, repo, "example-project", "work", identity); err != nil { t.Fatalf("ApplyIdentity: %v", err) } @@ -68,7 +68,7 @@ func TestApplyIdentity_SigningMethodSSH(t *testing.T) { c := gitcli.NewClient() identity := config.ResolvedIdentity{SigningMethod: strPtr("ssh")} - if _, err := ApplyIdentity(context.Background(), c, repo, identity); err != nil { + if _, err := ApplyIdentity(context.Background(), c, repo, "example-project", "work", identity); err != nil { t.Fatalf("ApplyIdentity: %v", err) } @@ -83,7 +83,7 @@ func TestApplyIdentity_SigningMethodNone(t *testing.T) { c := gitcli.NewClient() identity := config.ResolvedIdentity{SigningMethod: strPtr("none")} - if _, err := ApplyIdentity(context.Background(), c, repo, identity); err != nil { + if _, err := ApplyIdentity(context.Background(), c, repo, "example-project", "work", identity); err != nil { t.Fatalf("ApplyIdentity: %v", err) } @@ -101,7 +101,7 @@ func TestApplyIdentity_SigningKey(t *testing.T) { c := gitcli.NewClient() identity := config.ResolvedIdentity{SigningKey: strPtr("ABCDEF1234567890")} - if _, err := ApplyIdentity(context.Background(), c, repo, identity); err != nil { + if _, err := ApplyIdentity(context.Background(), c, repo, "example-project", "work", identity); err != nil { t.Fatalf("ApplyIdentity: %v", err) } @@ -115,7 +115,7 @@ func TestApplyIdentity_FullyNilWritesNothing(t *testing.T) { repo := identityRepo(t) c := gitcli.NewClient() - report, err := ApplyIdentity(context.Background(), c, repo, config.ResolvedIdentity{}) + report, err := ApplyIdentity(context.Background(), c, repo, "example-project", "work", config.ResolvedIdentity{}) if err != nil { t.Fatalf("ApplyIdentity: %v", err) } @@ -135,7 +135,7 @@ func TestApplyIdentity_PartiallyNilWritesOnlySetFields(t *testing.T) { c := gitcli.NewClient() identity := config.ResolvedIdentity{UserEmail: strPtr("octocat@work.example.com")} - report, err := ApplyIdentity(context.Background(), c, repo, identity) + report, err := ApplyIdentity(context.Background(), c, repo, "example-project", "work", identity) if err != nil { t.Fatalf("ApplyIdentity: %v", err) } @@ -164,7 +164,7 @@ func TestApplyIdentity_IdempotentSecondRunReportsNoChanges(t *testing.T) { SigningKey: strPtr("ABCDEF1234567890"), } - first, err := ApplyIdentity(context.Background(), c, repo, identity) + first, err := ApplyIdentity(context.Background(), c, repo, "example-project", "work", identity) if err != nil { t.Fatalf("first ApplyIdentity: %v", err) } @@ -172,7 +172,7 @@ func TestApplyIdentity_IdempotentSecondRunReportsNoChanges(t *testing.T) { t.Fatalf("first report.Written = %+v, want 4 entries", first.Written) } - second, err := ApplyIdentity(context.Background(), c, repo, identity) + second, err := ApplyIdentity(context.Background(), c, repo, "example-project", "work", identity) if err != nil { t.Fatalf("second ApplyIdentity: %v", err) } diff --git a/internal/sync/run.go b/internal/sync/run.go index 86804aa..81f04f9 100644 --- a/internal/sync/run.go +++ b/internal/sync/run.go @@ -24,6 +24,7 @@ type Client interface { // RepoEvent carries the completion event for a single repo's sync. type RepoEvent struct { Name string + Group string Result RepoResult } @@ -44,6 +45,9 @@ type Options struct { // beyond a simple format-and-print, as it is invoked concurrently from // worker goroutines. ProgressCallback func(RepoEvent) + // ConfigPath is the resolved path to the config file that drove this run. + // It is included in the report for traceability. + ConfigPath string } // FetchResult pairs one declared remote with what fetching it did or, in @@ -61,6 +65,7 @@ type FetchResult struct { // JSON shape is additive/forward-compatible; see docs/json-schema.md. type RepoResult struct { Name string `json:"name"` + Group string `json:"group,omitempty"` Path string `json:"path"` Cloned bool `json:"cloned"` Remotes RemoteReport `json:"remotes"` @@ -82,6 +87,7 @@ type RepoResult struct { // // JSON shape is additive/forward-compatible; see docs/json-schema.md. type Report struct { + Config string `json:"config"` Repos []RepoResult `json:"repos"` DryRun bool `json:"dry_run"` Overwrite bool `json:"overwrite"` @@ -102,7 +108,7 @@ func Run(ctx context.Context, real Client, cfg *config.Config, opts Options) Rep start := time.Now() resolved, _ := cfg.Resolve() // Resolve never actually returns a non-nil error today. - report := Report{DryRun: opts.DryRun, Overwrite: opts.Overwrite} + report := Report{Config: opts.ConfigPath, DryRun: opts.DryRun, Overwrite: opts.Overwrite} // Count total repos to pre-allocate results slice (no append races, deterministic order). totalRepos := 0 @@ -131,6 +137,7 @@ func Run(ctx context.Context, real Client, cfg *config.Config, opts Options) Rep // Launch goroutines for each repo, respecting the concurrency limit. repoIdx := 0 for _, g := range cfg.Groups { + groupName := g.Name // Capture group name for the progress callback. for _, r := range g.Repos { idx := repoIdx // Capture for the goroutine. rr := resolved[idx] @@ -143,10 +150,10 @@ func Run(ctx context.Context, real Client, cfg *config.Config, opts Options) Rep // Acquire a semaphore token; block if at capacity. semaphore <- struct{}{} defer func() { <-semaphore }() // Release the token. - result := syncRepo(ctx, real, g.Path, repoName, rr, opts) + result := syncRepo(ctx, real, g.Path, repoName, groupName, rr, opts) results[idx] = result if opts.ProgressCallback != nil { - opts.ProgressCallback(RepoEvent{Name: repoName, Result: result}) + opts.ProgressCallback(RepoEvent{Name: repoName, Group: groupName, Result: result}) } }() } @@ -166,9 +173,9 @@ func Run(ctx context.Context, real Client, cfg *config.Config, opts Options) Rep return report } -func syncRepo(ctx context.Context, real Client, groupPath, repoName string, rr config.ResolvedRepo, opts Options) (result RepoResult) { +func syncRepo(ctx context.Context, real Client, groupPath, repoName, groupName string, rr config.ResolvedRepo, opts Options) (result RepoResult) { start := time.Now() - result = RepoResult{Name: repoName} + result = RepoResult{Name: repoName, Group: groupName} defer func() { result.DurationMS = time.Since(start).Milliseconds() result.Outcome = computeOutcome(result) @@ -235,7 +242,7 @@ func syncRepo(ctx context.Context, real Client, groupPath, repoName string, rr c } result.Remotes = remotesReport - identityReport, err := ApplyIdentity(ctx, client, path, rr.Identity) + identityReport, err := ApplyIdentity(ctx, client, path, repoName, groupName, rr.Identity) if err != nil { result.Error = err.Error() return result diff --git a/internal/sync/run_test.go b/internal/sync/run_test.go index ba4b8ac..954a841 100644 --- a/internal/sync/run_test.go +++ b/internal/sync/run_test.go @@ -718,3 +718,77 @@ func TestRun_NoProgressCallbackIsNilSafe(t *testing.T) { t.Fatalf("len(report.Repos) = %d, want 1", len(report.Repos)) } } + +// TestRun_RepoResultCarriesGroupName verifies that RepoResult includes the +// group name the repo belongs to, enabling traceability in reports. +func TestRun_RepoResultCarriesGroupName(t *testing.T) { + origin := initBareRepo(t) + groupPath := t.TempDir() + cfg := &config.Config{ + Groups: []config.GroupConfig{ + { + Name: "work", + Path: groupPath, + Repos: []config.RepoConfig{ + {Name: "example-project", Remotes: map[string]config.RemoteConfig{"origin": {URL: fileURL(origin)}}}, + }, + }, + }, + } + c := gitcli.NewClient() + + report := Run(context.Background(), c, cfg, Options{}) + + if len(report.Repos) != 1 { + t.Fatalf("len(report.Repos) = %d, want 1", len(report.Repos)) + } + rr := report.Repos[0] + if rr.Group != "work" { + t.Fatalf("rr.Group = %q, want %q", rr.Group, "work") + } +} + +// TestRun_BadSigningMethodErrorCarriesContext verifies that a bad +// signing_method in the config produces an error that includes repo name, +// path, and group context, making it traceable. +func TestRun_BadSigningMethodErrorCarriesContext(t *testing.T) { + origin := initBareRepo(t) + groupPath := t.TempDir() + badMethod := "invalid-method" + cfg := &config.Config{ + Groups: []config.GroupConfig{ + { + Name: "work", + Path: groupPath, + Repos: []config.RepoConfig{ + { + Name: "example-project", + Remotes: map[string]config.RemoteConfig{"origin": {URL: fileURL(origin)}}, + IdentityConfig: config.IdentityConfig{ + SigningMethod: &badMethod, + }, + }, + }, + }, + }, + } + c := gitcli.NewClient() + + report := Run(context.Background(), c, cfg, Options{}) + + if report.ErrorCount != 1 { + t.Fatalf("report.ErrorCount = %d, want 1 for a bad signing_method", report.ErrorCount) + } + rr := report.Repos[0] + if rr.Error == "" { + t.Fatal("rr.Error is empty, want an error for an invalid signing_method") + } + // Error should include repo name and group name for traceability + errStr := rr.Error + if !strings.Contains(errStr, "example-project") { + t.Fatalf("error missing repo name: %q", errStr) + } + if !strings.Contains(errStr, "work") { + t.Fatalf("error missing group name: %q", errStr) + } +}