Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions cmd/git-manager/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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)
}
}
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
}
Expand All @@ -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)
}

Expand Down
207 changes: 207 additions & 0 deletions cmd/git-manager/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<placeholder-config>"
report.Repos[0].DurationMS = 0
report.Repos[0].Path = "<placeholder-path>"
report.Repos[0].Error = "<placeholder-error>"
Expand Down Expand Up @@ -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 = "<placeholder-config>"
report.Repos[0].DurationMS = 0
report.Repos[0].Path = "<placeholder-path>"
report.Repos[0].Error = "<placeholder-error>"
Expand Down Expand Up @@ -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 = "<placeholder-path>"
for i := range report.Repos[0].Remotes.Updated {
Expand Down Expand Up @@ -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 = "<placeholder-path>"
report.Repos[0].Error = "<placeholder-error>"
Expand Down Expand Up @@ -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)
}
}
2 changes: 2 additions & 0 deletions cmd/git-manager/testdata/status_json.golden
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
{
"config": "",
"repos": [
{
"name": "example-project",
"group": "work",
"path": "<placeholder-path>",
"drifted": true,
"cloned": false,
Expand Down
2 changes: 2 additions & 0 deletions cmd/git-manager/testdata/status_json_failure.golden
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
{
"config": "",
"repos": [
{
"name": "no-origin",
"group": "work",
"path": "<placeholder-path>",
"drifted": true,
"cloned": false,
Expand Down
2 changes: 2 additions & 0 deletions cmd/git-manager/testdata/sync_dryrun.golden
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
{
"config": "",
"repos": [
{
"name": "example-project",
"group": "work",
"path": "<placeholder-group-path>/example-project",
"cloned": true,
"remotes": {
Expand Down
2 changes: 2 additions & 0 deletions cmd/git-manager/testdata/sync_failure.golden
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
{
"config": "<placeholder-config>",
"repos": [
{
"name": "no-origin",
"group": "work",
"path": "<placeholder-path>",
"cloned": false,
"remotes": {
Expand Down
2 changes: 2 additions & 0 deletions cmd/git-manager/testdata/sync_partial.golden
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
{
"config": "<placeholder-config>",
"repos": [
{
"name": "example-project",
"group": "work",
"path": "<placeholder-path>",
"cloned": false,
"remotes": {
Expand Down
5 changes: 4 additions & 1 deletion internal/status/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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"`
Expand All @@ -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 {
Expand All @@ -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),
Expand Down
Loading