diff --git a/internal/actions/info.go b/internal/actions/info.go index 9062f7b8..e58235ba 100644 --- a/internal/actions/info.go +++ b/internal/actions/info.go @@ -1,200 +1,181 @@ package actions import ( - "encoding/json" + "context" "fmt" - "strings" "time" - "github.com/getstackit/stackit/internal/app" "github.com/getstackit/stackit/internal/engine" - "github.com/getstackit/stackit/internal/tui/style" ) -// SingleBranchInfo represents JSON-serializable info for a single branch (used by info --json) -type SingleBranchInfo struct { - Name string `json:"name"` - IsCurrent bool `json:"is_current"` - IsTrunk bool `json:"is_trunk"` - IsLocked bool `json:"is_locked"` - IsFrozen bool `json:"is_frozen"` - NeedsRestack bool `json:"needs_restack"` - Scope string `json:"scope"` - CommitDate string `json:"commit_date,omitempty"` - Parent string `json:"parent,omitempty"` - Children []string `json:"children,omitempty"` - PR *SingleBranchPRInfo `json:"pr,omitempty"` - CommitMessages []string `json:"commit_messages"` - DiffStats SingleBranchStats `json:"diff_stats"` - StackTitle string `json:"stack_title,omitempty"` - StackDescription string `json:"stack_description,omitempty"` +// BranchInfoPR represents pull request information for a branch. +type BranchInfoPR struct { + Number *int + Title string + State string + IsDraft bool + URL string + Body string } -// SingleBranchPRInfo represents PR information for JSON output -type SingleBranchPRInfo struct { - Number int `json:"number"` - Title string `json:"title"` - State string `json:"state"` - IsDraft bool `json:"is_draft"` - URL string `json:"url"` -} - -// SingleBranchStats represents diff statistics for a branch +// SingleBranchStats represents summary diff information for a branch. type SingleBranchStats struct { FilesChanged int `json:"files_changed"` Additions int `json:"additions"` Deletions int `json:"deletions"` } -// InfoOptions contains options for the info command -type InfoOptions struct { +// BranchInfoResult contains structured data for `stackit info` on a single branch. +type BranchInfoResult struct { + Name string + IsCurrent bool + IsTrunk bool + IsLocked bool + IsFrozen bool + NeedsRestack bool + Scope string + CommitDate string + Parent string + Children []string + PR *BranchInfoPR + CommitMessages []string + DiffStats SingleBranchStats + StackTitle string + StackDescription string + PatchOutput string + DiffOutput string +} + +// BranchInfoQueryOptions contains options for querying single-branch info. +type BranchInfoQueryOptions struct { BranchName string - Body bool Diff bool Patch bool Stat bool - Stack bool - JSON bool } -// InfoAction displays information about a branch or the entire stack -func InfoAction(ctx *app.Context, opts InfoOptions) error { - if opts.Stack { - return StackInfoAction(ctx, StackInfoOptions{ - JSON: opts.JSON, - }) - } - - eng := ctx.Engine - out := ctx.Output +type branchInfoDebugLogger interface { + Debug(format string, args ...any) +} +// QueryBranchInfo gathers structured information for `stackit info`. +func QueryBranchInfo(ctx context.Context, eng engine.Engine, opts BranchInfoQueryOptions, debug branchInfoDebugLogger) (BranchInfoResult, error) { branchName, err := ResolveBranchName(eng, opts.BranchName) if err != nil { - return err + return BranchInfoResult{}, err } branch := eng.GetBranch(branchName) - if !branch.IsTracked() && !branch.IsTrunk() { - _, err := eng.GetRevision(branch) - if err != nil { - return fmt.Errorf("branch %s does not exist", branchName) - } - - // For remote branches, fetch metadata to show the latest info - if err := eng.Git().FetchMetadataRefs(ctx.Context); err != nil { - out.Debug("Failed to fetch remote metadata: %v", err) - } else { - if err := eng.LoadRemoteMetadataCache(); err != nil { - out.Debug("Failed to load remote metadata cache: %v", err) - } else { - // Apply remote metadata if available - if err := eng.ApplyRemoteMetadataIfExists(branchName); err != nil { - out.Debug("Failed to apply remote metadata for %s: %v", branchName, err) - } - } + if _, err := eng.GetRevision(branch); err != nil { + return BranchInfoResult{}, fmt.Errorf("branch %s does not exist", branchName) } + refreshRemoteMetadataForBranchInfo(ctx, eng, branchName, debug) } - // Handle JSON output for single branch - if opts.JSON { - return outputBranchInfoJSON(ctx, branch) + return buildBranchInfoResult(ctx, eng, branchName, branch, opts) +} + +func refreshRemoteMetadataForBranchInfo(ctx context.Context, eng engine.Engine, branchName string, debug branchInfoDebugLogger) { + if err := eng.Git().FetchMetadataRefs(ctx); err != nil { + debugBranchInfof(debug, "Failed to fetch remote metadata: %v", err) + return } - // If stat is set without diff or patch, it implies diff - effectiveDiff := opts.Diff || (opts.Stat && !opts.Patch) - effectivePatch := opts.Patch && !opts.Diff + if err := eng.LoadRemoteMetadataCache(); err != nil { + debugBranchInfof(debug, "Failed to load remote metadata cache: %v", err) + return + } - var outputLines []string + if err := eng.ApplyRemoteMetadataIfExists(branchName); err != nil { + debugBranchInfof(debug, "Failed to apply remote metadata for %s: %v", branchName, err) + } +} +func buildBranchInfoResult(ctx context.Context, eng engine.Engine, branchName string, branch engine.Branch, opts BranchInfoQueryOptions) (BranchInfoResult, error) { currentBranch := eng.CurrentBranch() - isCurrent := branchName == currentBranch.GetName() + isCurrent := currentBranch != nil && branchName == currentBranch.GetName() isTrunk := branch.IsTrunk() - coloredBranchName := style.ColorBranchNameWithTrunk(branchName, isCurrent, isTrunk) - - if branch.IsLocked() { - coloredBranchName += " " + style.IconLocked() + " " + style.ColorDim("(locked)") - } - if branch.IsFrozen() { - coloredBranchName += " " + style.IconFrozen() + " " + style.ColorDim("(frozen)") - } - - if !isTrunk && !branch.IsBranchUpToDate() { - coloredBranchName += " " + style.ColorNeedsRestack("(needs restack)") + result := BranchInfoResult{ + Name: branchName, + IsCurrent: isCurrent, + IsTrunk: isTrunk, + IsLocked: branch.IsLocked(), + IsFrozen: branch.IsFrozen(), + NeedsRestack: !isTrunk && !branch.IsBranchUpToDate(), + Scope: branch.GetScope().String(), + CommitMessages: []string{}, + Children: []string{}, } - if scope := branch.GetScope(); !scope.IsNone() { - coloredBranchName += " " + style.ColorScope(scope.String()) + commitDate, err := branch.GetCommitDate() + if err == nil { + result.CommitDate = commitDate.Format(time.RFC3339) } - outputLines = append(outputLines, coloredBranchName) - - // Show stack description if present - stackDesc := eng.GetStackDescription(branch) - if stackDesc != nil && !stackDesc.IsEmpty() { - outputLines = append(outputLines, "") - // Render title and description together through glamour for consistent formatting - var markdown string - if stackDesc.Description != "" { - markdown = "# " + stackDesc.Title + "\n\n" + stackDesc.Description - } else { - markdown = "# " + stackDesc.Title - } - rendered := style.RenderMarkdown(markdown) - outputLines = append(outputLines, rendered) + if parent := branch.GetParent(); parent != nil { + result.Parent = parent.GetName() } - commitDate, err := branch.GetCommitDate() - if err == nil { - dateStr := commitDate.Format(time.RFC3339) - outputLines = append(outputLines, style.ColorDim(dateStr)) + graph := eng.Graph(engine.SortStrategyAlphabetical) + for _, child := range graph.ChildBranches(branch) { + result.Children = append(result.Children, child.GetName()) } - var prInfo *engine.PrInfo if !isTrunk { - branch := eng.GetBranch(branchName) - prInfo, _ = branch.GetPrInfo() - if prInfo != nil && prInfo.Number() != nil { - prTitleLine := getPRTitleLine(prInfo) - if prTitleLine != "" { - outputLines = append(outputLines, "") - outputLines = append(outputLines, prTitleLine) - } - if prInfo.URL() != "" { - outputLines = append(outputLines, style.ColorMagenta(prInfo.URL())) + prInfo, _ := branch.GetPrInfo() + if prInfo != nil { + result.PR = &BranchInfoPR{ + Number: prInfo.Number(), + Title: prInfo.Title(), + State: prInfo.State(), + IsDraft: prInfo.IsDraft(), + URL: prInfo.URL(), + Body: prInfo.Body(), } } } - branchObj := eng.GetBranch(branchName) - parentBranch := branchObj.GetParent() - if parentBranch != nil { - outputLines = append(outputLines, "") - outputLines = append(outputLines, fmt.Sprintf("%s: %s", style.ColorCyan("Parent"), style.ColorBranchNameWithTrunk(parentBranch.GetName(), false, parentBranch.IsTrunk()))) + commits, err := branch.GetAllCommits(engine.CommitFormatReadable) + if err == nil { + result.CommitMessages = commits + } + + added, deleted, err := branch.GetDiffStats() + if err == nil { + result.DiffStats.Additions = added + result.DiffStats.Deletions = deleted } - graph := eng.Graph(engine.SortStrategyAlphabetical) - children := graph.ChildBranches(branchObj) - if len(children) > 0 { - outputLines = append(outputLines, fmt.Sprintf("%s:", style.ColorCyan("Children"))) - for _, child := range children { - outputLines = append(outputLines, fmt.Sprintf("▸ %s", style.ColorBranchNameWithTrunk(child.GetName(), false, child.IsTrunk()))) + if result.Parent != "" { + parentRev, err := eng.GetRevision(eng.GetBranch(result.Parent)) + if err == nil { + branchRev, err := branch.GetRevision() + if err == nil { + files, err := eng.GetChangedFiles(ctx, parentRev, branchRev) + if err == nil { + result.DiffStats.FilesChanged = len(files) + } + } } } - if opts.Body && prInfo != nil && prInfo.Body() != "" { - outputLines = append(outputLines, "") - outputLines = append(outputLines, prInfo.Body()) + stackDesc := eng.GetStackDescription(branch) + if stackDesc != nil && !stackDesc.IsEmpty() { + result.StackTitle = stackDesc.Title + result.StackDescription = stackDesc.Description } - outputLines = append(outputLines, "") + effectiveDiff := opts.Diff || (opts.Stat && !opts.Patch) + effectivePatch := opts.Patch && !opts.Diff + if effectivePatch { baseRevision := "" if isTrunk { baseRevision = branchName + "~" } else { - commits, err := branchObj.GetAllCommits(engine.CommitFormatSHA) + commits, err := branch.GetAllCommits(engine.CommitFormatSHA) if err == nil && len(commits) > 0 { oldestSHA := commits[0] baseRevision, _ = eng.GetParentCommitSHA(oldestSHA) @@ -202,180 +183,47 @@ func InfoAction(ctx *app.Context, opts InfoOptions) error { } branchRevision, err := branch.GetRevision() if err == nil { - commitsOutput, err := eng.ShowCommits(ctx.Context, baseRevision, branchRevision, true, opts.Stat) - if err == nil && commitsOutput != "" { - outputLines = append(outputLines, commitsOutput) - } - } - } else { - commits, err := branch.GetAllCommits(engine.CommitFormatReadable) - if err == nil { - for _, commit := range commits { - outputLines = append(outputLines, style.ColorDim(commit)) + patchOutput, err := eng.ShowCommits(ctx, baseRevision, branchRevision, true, opts.Stat) + if err == nil { + result.PatchOutput = patchOutput } } } if effectiveDiff { - outputLines = append(outputLines, "") if isTrunk { headRevision, err := branch.GetRevision() if err == nil { parentSHA, err := eng.GetCommitSHA(branchName, 1) if err == nil { - diffOutput, err := eng.ShowDiff(ctx.Context, parentSHA, headRevision, opts.Stat) - if err == nil && diffOutput != "" { - outputLines = append(outputLines, diffOutput) + diffOutput, err := eng.ShowDiff(ctx, parentSHA, headRevision, opts.Stat) + if err == nil { + result.DiffOutput = diffOutput } } } } else { - commits, err := branchObj.GetAllCommits(engine.CommitFormatSHA) + commits, err := branch.GetAllCommits(engine.CommitFormatSHA) if err == nil && len(commits) > 0 { oldestSHA := commits[0] parentSHA, _ := eng.GetParentCommitSHA(oldestSHA) branchRevision, err := branch.GetRevision() if err == nil { - diffOutput, err := eng.ShowDiff(ctx.Context, parentSHA, branchRevision, opts.Stat) - if err == nil && diffOutput != "" { - outputLines = append(outputLines, diffOutput) + diffOutput, err := eng.ShowDiff(ctx, parentSHA, branchRevision, opts.Stat) + if err == nil { + result.DiffOutput = diffOutput } } } } } - // Apply dimming for merged/closed PRs - const ( - prStateMerged = "MERGED" - prStateClosed = "CLOSED" - ) - if prInfo != nil && (prInfo.State() == prStateMerged || prInfo.State() == prStateClosed) { - for i := range outputLines { - outputLines[i] = style.ColorDim(outputLines[i]) - } - } - - out.Print(strings.Join(outputLines, "\n")) - out.Newline() - - return nil -} - -func getPRTitleLine(prInfo *engine.PrInfo) string { - if prInfo == nil || prInfo.Number() == nil || prInfo.Title() == "" { - return "" - } - - state := prInfo.State() - - const ( - prStateMerged = "MERGED" - prStateClosed = "CLOSED" - ) - - prNumber := style.ColorPRNumber(*prInfo.Number()) - - switch state { - case prStateMerged: - return fmt.Sprintf("%s (Merged) %s", prNumber, prInfo.Title()) - case prStateClosed: - return fmt.Sprintf("%s (Abandoned) %s", prNumber, style.ColorDim(prInfo.Title())) - default: - prState := style.ColorPRState(state, prInfo.IsDraft()) - return fmt.Sprintf("%s %s %s", prNumber, prState, prInfo.Title()) - } + return result, nil } -// outputBranchInfoJSON outputs branch information as JSON -func outputBranchInfoJSON(ctx *app.Context, branch engine.Branch) error { - eng := ctx.Engine - branchName := branch.GetName() - currentBranch := eng.CurrentBranch() - isCurrent := currentBranch != nil && branchName == currentBranch.GetName() - isTrunk := branch.IsTrunk() - - info := SingleBranchInfo{ - Name: branchName, - IsCurrent: isCurrent, - IsTrunk: isTrunk, - IsLocked: branch.IsLocked(), - IsFrozen: branch.IsFrozen(), - NeedsRestack: !isTrunk && !branch.IsBranchUpToDate(), - Scope: branch.GetScope().String(), - CommitMessages: []string{}, - Children: []string{}, - } - - // Commit date - commitDate, err := branch.GetCommitDate() - if err == nil { - info.CommitDate = commitDate.Format(time.RFC3339) - } - - // Parent - if parent := branch.GetParent(); parent != nil { - info.Parent = parent.GetName() - } - - // Children - graph := eng.Graph(engine.SortStrategyAlphabetical) - for _, child := range graph.ChildBranches(branch) { - info.Children = append(info.Children, child.GetName()) - } - - // PR info - if !isTrunk { - prInfo, _ := branch.GetPrInfo() - if prInfo != nil && prInfo.Number() != nil { - info.PR = &SingleBranchPRInfo{ - Number: *prInfo.Number(), - Title: prInfo.Title(), - State: prInfo.State(), - IsDraft: prInfo.IsDraft(), - URL: prInfo.URL(), - } - } - } - - // Commit messages - commits, err := branch.GetAllCommits(engine.CommitFormatReadable) - if err == nil { - info.CommitMessages = commits - } - - // Diff stats - added, deleted, err := branch.GetDiffStats() - if err == nil { - info.DiffStats.Additions = added - info.DiffStats.Deletions = deleted - } - - // Files changed - if info.Parent != "" { - parentRev, err := eng.GetRevision(eng.GetBranch(info.Parent)) - if err == nil { - branchRev, err := branch.GetRevision() - if err == nil { - files, err := eng.GetChangedFiles(ctx.Context, parentRev, branchRev) - if err == nil { - info.DiffStats.FilesChanged = len(files) - } - } - } - } - - // Stack description - stackDesc := eng.GetStackDescription(branch) - if stackDesc != nil && !stackDesc.IsEmpty() { - info.StackTitle = stackDesc.Title - info.StackDescription = stackDesc.Description - } - - data, err := json.MarshalIndent(info, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal branch info to JSON: %w", err) +func debugBranchInfof(debug branchInfoDebugLogger, format string, args ...any) { + if debug == nil { + return } - ctx.Output.Info("%s", string(data)) - return nil + debug.Debug(format, args...) } diff --git a/internal/actions/info_test.go b/internal/actions/info_test.go new file mode 100644 index 00000000..34c2f53c --- /dev/null +++ b/internal/actions/info_test.go @@ -0,0 +1,78 @@ +package actions + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getstackit/stackit/internal/engine" + "github.com/getstackit/stackit/testhelpers" + "github.com/getstackit/stackit/testhelpers/scenario" +) + +func TestQueryBranchInfo(t *testing.T) { + t.Run("returns structured branch info for a tracked branch", func(t *testing.T) { + s := scenario.NewScenario(t, testhelpers.InitialCommitSceneSetup) + + require.NoError(t, s.Scene.Repo.CreateAndCheckoutBranch("feature")) + require.NoError(t, s.Scene.Repo.CreateChange("feature change", "feature.txt", false)) + require.NoError(t, s.Scene.Repo.RunGitCommand("add", ".")) + require.NoError(t, s.Scene.Repo.RunGitCommand("commit", "-m", "feature change")) + require.NoError(t, s.Scene.Repo.CreateAndCheckoutBranch("child")) + require.NoError(t, s.Scene.Repo.CreateChange("child change", "child.txt", false)) + require.NoError(t, s.Scene.Repo.RunGitCommand("add", ".")) + require.NoError(t, s.Scene.Repo.RunGitCommand("commit", "-m", "child change")) + s.Rebuild() + + require.NoError(t, s.Engine.TrackBranch(context.Background(), "feature", "main")) + require.NoError(t, s.Engine.TrackBranch(context.Background(), "child", "feature")) + require.NoError(t, s.Engine.SetScope(context.Background(), s.Engine.GetBranch("feature"), engine.NewScope("PROJ-123"))) + s.Checkout("feature") + + info, err := QueryBranchInfo(context.Background(), s.Engine, BranchInfoQueryOptions{ + BranchName: "feature", + }, nil) + require.NoError(t, err) + + require.Equal(t, "feature", info.Name) + require.True(t, info.IsCurrent) + require.False(t, info.IsTrunk) + require.Equal(t, "PROJ-123", info.Scope) + require.Equal(t, "main", info.Parent) + require.Contains(t, info.Children, "child") + require.Contains(t, strings.Join(info.CommitMessages, "\n"), "feature change") + require.GreaterOrEqual(t, info.DiffStats.FilesChanged, 1) + require.NotEmpty(t, info.CommitDate) + }) + + t.Run("includes diff and patch output when requested", func(t *testing.T) { + s := scenario.NewScenario(t, testhelpers.InitialCommitSceneSetup) + + require.NoError(t, s.Scene.Repo.CreateAndCheckoutBranch("feature")) + require.NoError(t, s.Scene.Repo.CreateChange("feature change", "feature.txt", false)) + require.NoError(t, s.Scene.Repo.RunGitCommand("add", ".")) + require.NoError(t, s.Scene.Repo.RunGitCommand("commit", "-m", "feature change")) + s.Rebuild() + require.NoError(t, s.Engine.TrackBranch(context.Background(), "feature", "main")) + + info, err := QueryBranchInfo(context.Background(), s.Engine, BranchInfoQueryOptions{ + BranchName: "feature", + Patch: true, + }, nil) + require.NoError(t, err) + require.NotEmpty(t, info.PatchOutput) + require.Empty(t, info.DiffOutput) + }) + + t.Run("errors for a missing branch", func(t *testing.T) { + s := scenario.NewScenario(t, testhelpers.InitialCommitSceneSetup) + + _, err := QueryBranchInfo(context.Background(), s.Engine, BranchInfoQueryOptions{ + BranchName: "missing", + }, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "does not exist") + }) +} diff --git a/internal/cli/info.go b/internal/cli/info.go index 00ef4f71..b1e62d0a 100644 --- a/internal/cli/info.go +++ b/internal/cli/info.go @@ -33,20 +33,40 @@ If no branch is specified and --stack is not provided, displays information abou SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { return common.Run(cmd, func(ctx *app.Context) error { - branchName := "" + if stack { + return actions.StackInfoAction(ctx, actions.StackInfoOptions{ + JSON: json, + }) + } + + opts := actions.BranchInfoQueryOptions{ + Diff: diff, + Patch: patch, + Stat: stat, + } if len(args) > 0 { - branchName = args[0] + opts.BranchName = args[0] + } + + info, err := actions.QueryBranchInfo(ctx.Context, ctx.Engine, opts, ctx.Output) + if err != nil { + return err + } + + if json { + data, err := renderBranchInfoJSON(info) + if err != nil { + return err + } + ctx.Output.Info("%s", string(data)) + return nil } - return actions.InfoAction(ctx, actions.InfoOptions{ - BranchName: branchName, - Body: body, - Diff: diff, - Patch: patch, - Stat: stat, - Stack: stack, - JSON: json, - }) + ctx.Output.Print(renderBranchInfoText(info, branchInfoRenderOptions{ + Body: body, + })) + ctx.Output.Newline() + return nil }) }, } diff --git a/internal/cli/info_render.go b/internal/cli/info_render.go new file mode 100644 index 00000000..fbee4b0b --- /dev/null +++ b/internal/cli/info_render.go @@ -0,0 +1,177 @@ +package cli + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/getstackit/stackit/internal/actions" + "github.com/getstackit/stackit/internal/tui/style" +) + +type singleBranchInfoJSON struct { + Name string `json:"name"` + IsCurrent bool `json:"is_current"` + IsTrunk bool `json:"is_trunk"` + IsLocked bool `json:"is_locked"` + IsFrozen bool `json:"is_frozen"` + NeedsRestack bool `json:"needs_restack"` + Scope string `json:"scope"` + CommitDate string `json:"commit_date,omitempty"` + Parent string `json:"parent,omitempty"` + Children []string `json:"children,omitempty"` + PR *singleBranchPRJSON `json:"pr,omitempty"` + CommitMessages []string `json:"commit_messages"` + DiffStats actions.SingleBranchStats `json:"diff_stats"` + StackTitle string `json:"stack_title,omitempty"` + StackDescription string `json:"stack_description,omitempty"` +} + +type singleBranchPRJSON struct { + Number int `json:"number"` + Title string `json:"title"` + State string `json:"state"` + IsDraft bool `json:"is_draft"` + URL string `json:"url"` +} + +type branchInfoRenderOptions struct { + Body bool +} + +func renderBranchInfoText(info actions.BranchInfoResult, opts branchInfoRenderOptions) string { + var outputLines []string + + coloredBranchName := style.ColorBranchNameWithTrunk(info.Name, info.IsCurrent, info.IsTrunk) + if info.IsLocked { + coloredBranchName += " " + style.IconLocked() + " " + style.ColorDim("(locked)") + } + if info.IsFrozen { + coloredBranchName += " " + style.IconFrozen() + " " + style.ColorDim("(frozen)") + } + if info.NeedsRestack { + coloredBranchName += " " + style.ColorNeedsRestack("(needs restack)") + } + if info.Scope != "" { + coloredBranchName += " " + style.ColorScope(info.Scope) + } + outputLines = append(outputLines, coloredBranchName) + + if info.StackTitle != "" { + outputLines = append(outputLines, "") + outputLines = append(outputLines, style.RenderMarkdown(renderStackDescriptionMarkdown(info.StackTitle, info.StackDescription))) + } + + if info.CommitDate != "" { + outputLines = append(outputLines, style.ColorDim(info.CommitDate)) + } + + if prTitleLine := renderPRTitleLine(info.PR); prTitleLine != "" { + outputLines = append(outputLines, "") + outputLines = append(outputLines, prTitleLine) + if info.PR != nil && info.PR.URL != "" { + outputLines = append(outputLines, style.ColorMagenta(info.PR.URL)) + } + } + + if info.Parent != "" { + outputLines = append(outputLines, "") + outputLines = append(outputLines, fmt.Sprintf("%s: %s", style.ColorCyan("Parent"), style.ColorBranchNameWithTrunk(info.Parent, false, false))) + } + + if len(info.Children) > 0 { + outputLines = append(outputLines, fmt.Sprintf("%s:", style.ColorCyan("Children"))) + for _, child := range info.Children { + outputLines = append(outputLines, fmt.Sprintf("▸ %s", style.ColorBranchNameWithTrunk(child, false, false))) + } + } + + if opts.Body && info.PR != nil && info.PR.Body != "" { + outputLines = append(outputLines, "") + outputLines = append(outputLines, info.PR.Body) + } + + outputLines = append(outputLines, "") + if info.PatchOutput != "" { + outputLines = append(outputLines, info.PatchOutput) + } else { + for _, commit := range info.CommitMessages { + outputLines = append(outputLines, style.ColorDim(commit)) + } + } + + if info.DiffOutput != "" { + outputLines = append(outputLines, "") + outputLines = append(outputLines, info.DiffOutput) + } + + if shouldDimBranchInfo(info.PR) { + for i := range outputLines { + outputLines[i] = style.ColorDim(outputLines[i]) + } + } + + return strings.Join(outputLines, "\n") +} + +func renderBranchInfoJSON(info actions.BranchInfoResult) ([]byte, error) { + output := singleBranchInfoJSON{ + Name: info.Name, + IsCurrent: info.IsCurrent, + IsTrunk: info.IsTrunk, + IsLocked: info.IsLocked, + IsFrozen: info.IsFrozen, + NeedsRestack: info.NeedsRestack, + Scope: info.Scope, + CommitDate: info.CommitDate, + Parent: info.Parent, + Children: info.Children, + CommitMessages: info.CommitMessages, + DiffStats: info.DiffStats, + StackTitle: info.StackTitle, + StackDescription: info.StackDescription, + } + + if info.PR != nil && info.PR.Number != nil { + output.PR = &singleBranchPRJSON{ + Number: *info.PR.Number, + Title: info.PR.Title, + State: info.PR.State, + IsDraft: info.PR.IsDraft, + URL: info.PR.URL, + } + } + + return json.MarshalIndent(output, "", " ") +} + +func renderPRTitleLine(prInfo *actions.BranchInfoPR) string { + if prInfo == nil || prInfo.Number == nil || prInfo.Title == "" { + return "" + } + + prNumber := style.ColorPRNumber(*prInfo.Number) + switch prInfo.State { + case "MERGED": + return fmt.Sprintf("%s (Merged) %s", prNumber, prInfo.Title) + case "CLOSED": + return fmt.Sprintf("%s (Abandoned) %s", prNumber, style.ColorDim(prInfo.Title)) + default: + prState := style.ColorPRState(prInfo.State, prInfo.IsDraft) + return fmt.Sprintf("%s %s %s", prNumber, prState, prInfo.Title) + } +} + +func renderStackDescriptionMarkdown(title, description string) string { + if description != "" { + return "# " + title + "\n\n" + description + } + return "# " + title +} + +func shouldDimBranchInfo(prInfo *actions.BranchInfoPR) bool { + if prInfo == nil { + return false + } + return prInfo.State == "MERGED" || prInfo.State == "CLOSED" +} diff --git a/internal/cli/info_test.go b/internal/cli/info_test.go index d3b0fa79..cf710fbf 100644 --- a/internal/cli/info_test.go +++ b/internal/cli/info_test.go @@ -146,6 +146,24 @@ func TestInfoCommand(t *testing.T) { "should contain patch output, got: %s", output) }) + t.Run("info with --json returns structured branch data", func(t *testing.T) { + t.Parallel() + s := scenario.NewScenario(t, testhelpers.InitialCommitSceneSetup).WithInProcess(true) + + if err := s.Scene.Repo.CreateChange("feature change", "test", false); err != nil { + t.Fatal(err) + } + s.RunCli("create", "feature", "-m", "feature change") + + output, err := s.RunCliAndGetOutput("info", "--json") + + require.NoError(t, err, "info command failed: %s", output) + require.Contains(t, output, `"name": "feature"`) + require.Contains(t, output, `"is_current": true`) + require.Contains(t, output, `"commit_messages": [`) + require.Contains(t, output, `"diff_stats": {`) + }) + t.Run("info with --stat flag shows diffstat", func(t *testing.T) { t.Parallel() s := scenario.NewScenario(t, testhelpers.InitialCommitSceneSetup).WithInProcess(true)