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
5 changes: 5 additions & 0 deletions cmd/sync/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const (
archiveFlag = "archive"
conflictFlag = "conflict"
contentFlag = "content"
diffFlag = "diff"
dryRunFlag = "dry-run"
formatFlag = "format"
modelConfigKeyFlag = "model-config-key"
Expand Down Expand Up @@ -93,6 +94,7 @@ func runPrompt(client resources.Client, buildAction actionBuilder) func(*cobra.C
}
yes, _ := cmd.Flags().GetBool(yesFlag)
noInput, _ := cmd.Flags().GetBool(noInputFlag)
diff, _ := cmd.Flags().GetBool(diffFlag)
outputKind := cliflags.GetOutputKind(cmd)

err = syncprompt.NewRunner(client).Run(syncprompt.Options{
Expand All @@ -104,6 +106,7 @@ func runPrompt(client resources.Client, buildAction actionBuilder) func(*cobra.C
ConflictPolicy: policy,
Yes: yes,
NoInput: noInput,
Diff: diff,
Context: cmd.Context(),
Input: cmd.InOrStdin(),
Output: cmd.OutOrStdout(),
Expand Down Expand Up @@ -157,6 +160,7 @@ func newAddCmd(client resources.Client) *cobra.Command {
}),
}
cmd.Flags().Bool(dryRunFlag, false, "Preview local files without creating them")
cmd.Flags().Bool(diffFlag, false, "Show full diffs and file content in the plan")
cmd.Flags().Bool(noInputFlag, false, "Fail instead of prompting for missing input")
return cmd
}
Expand Down Expand Up @@ -269,6 +273,7 @@ func addApplyFlags(cmd *cobra.Command, dryRun bool) {
if dryRun {
cmd.Flags().Bool(dryRunFlag, false, "Preview synchronization changes without applying them")
}
cmd.Flags().Bool(diffFlag, false, "Show full diffs in the plan")
cmd.Flags().String(conflictFlag, "", "Default conflict resolution: launchdarkly, local, or abort")
cmd.Flags().StringArray(resolveFlag, nil, "Resolve one variation conflict (project-key/config-key/variation-key=choice)")
cmd.Flags().Bool(yesFlag, false, "Apply changes without confirmation")
Expand Down
27 changes: 27 additions & 0 deletions cmd/sync/prompt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,33 @@ func TestPromptCommandHelpIncludesExamples(t *testing.T) {
}
}

func TestPromptCommandsRegisterDiffFlag(t *testing.T) {
command := NewPromptCmd(nil)
tests := map[string]struct {
path []string
help string
}{
"prompts": {help: "Show full diffs in the plan"},
"watch": {path: []string{"watch"}, help: "Show full diffs in the plan"},
"add": {path: []string{"add"}, help: "Show full diffs and file content in the plan"},
}

for name, test := range tests {
t.Run(name, func(t *testing.T) {
target := command
if len(test.path) != 0 {
var err error
target, _, err = command.Find(test.path)
require.NoError(t, err)
}

flag := target.Flags().Lookup(diffFlag)
require.NotNil(t, flag)
assert.Equal(t, test.help, flag.Usage)
})
}
}

func TestAttachSubcommandsAcceptOptionalKeyAndTarget(t *testing.T) {
command := NewPromptCmd(nil)
tool, _, err := command.Find([]string{"attach", "tool"})
Expand Down
57 changes: 41 additions & 16 deletions internal/sync/bootstrap/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package bootstrap

import (
"bytes"
"cmp"
"errors"
"fmt"
Expand Down Expand Up @@ -42,6 +43,8 @@ type Options struct {
// Initial is true when the workspace has no .launchdarkly directory.
Initial bool
DryRun bool
// Diff shows the content of each file that a dry run lists.
Diff bool
// Selections are the variations to add. If it is empty, Run asks the user.
Selections []syncdomain.ResourceID
NoInput bool
Expand Down Expand Up @@ -228,7 +231,7 @@ func finishSelection(options Options, files []synclocal.VariationFile) error {
if err != nil {
return err
}
writePreviews(options.Output, previews)
writePreviews(options.Output, previews, options.Diff)
return nil
}

Expand Down Expand Up @@ -323,23 +326,45 @@ func writeSummary(output io.Writer, initial bool, count int) {
_ = syncconsole.New(output).Printf("%s %d %s in %s.\n", verb, count, noun, syncdomain.RootDir)
}

// writePreviews prints each file that a dry run would create.
func writePreviews(output io.Writer, previews []synclocal.RenderedFile) {
// writePreviews lists each file that the add creates, with its number of
// lines. With showContent, the content of each file follows the list.
func writePreviews(output io.Writer, previews []synclocal.RenderedFile, showContent bool) {
console := syncconsole.New(output)
const border = "============================================================"
noun := "file"
if len(previews) != 1 {
noun += "s"
}
_ = console.Printf("%d %s to create:\n", len(previews), noun)

paths := make([]string, len(previews))
width := 0
for index, preview := range previews {
if index != 0 {
_ = console.Line("")
}
_ = console.Line(border)
_ = console.Printf(
"File %d of %d\nWould create: %s\n", index+1, len(previews), path.Join(syncdomain.RootDir, preview.Path),
)
_ = console.Line("------------------------------------------------------------")
_ = console.WriteBytes(preview.Content)
if len(preview.Content) == 0 || preview.Content[len(preview.Content)-1] != '\n' {
_ = console.Line("")
paths[index] = path.Join(syncdomain.RootDir, preview.Path)
width = max(width, len(paths[index]))
}
for index, preview := range previews {
_ = console.Printf(" %-*s +%d\n", width, paths[index], lineCount(preview.Content))
}
if !showContent {
return
}
for index, preview := range previews {
_ = console.Printf("\n%s\n", paths[index])
for _, line := range strings.Split(strings.TrimSuffix(string(preview.Content), "\n"), "\n") {
if line != "" {
line = " " + line
}
_ = console.Line(line)
}
_ = console.Line(border)
}
}

// lineCount returns the number of lines in a file. A last line without a
// final newline counts.
func lineCount(content []byte) int {
count := bytes.Count(content, []byte("\n"))
if len(content) != 0 && content[len(content)-1] != '\n' {
count++
}
return count
}
68 changes: 28 additions & 40 deletions internal/sync/bootstrap/bootstrap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ func TestRunUsesExplicitVariationSelectorsWithoutTerminal(t *testing.T) {
})

require.NoError(t, err)
assert.Contains(t, output.String(), "Would create: .launchdarkly/project/configs/config/variation.prompt.md")
assert.Contains(t, output.String(), " .launchdarkly/project/configs/config/variation.prompt.md +9")
}

func TestFinishSelectionDryRunDoesNotCreateFiles(t *testing.T) {
Expand All @@ -138,20 +138,8 @@ func TestFinishSelectionDryRunDoesNotCreateFiles(t *testing.T) {
require.NoError(t, err)
assert.Equal(
t,
`============================================================
File 1 of 1
Would create: .launchdarkly/project/configs/config/variation.prompt.md
------------------------------------------------------------
---
formatVersion: 1
upsert: true
mode: agent
key: variation
name: Variation
---

Be helpful.
============================================================
`1 file to create:
.launchdarkly/project/configs/config/variation.prompt.md +9
`,
output.String(),
)
Expand Down Expand Up @@ -384,32 +372,32 @@ func TestWriteSummary(t *testing.T) {
assert.Contains(t, output.String(), "No variations added")
}

func TestWritePreviewsPrintsEveryFile(t *testing.T) {
var output bytes.Buffer

writePreviews(&output, []synclocal.RenderedFile{
{Path: "project/configs/config/first.prompt.md", Content: []byte("first\n")},
{Path: "project/configs/config/second.prompt.md", Content: []byte("second\n")},
})
func TestWritePreviewsListsEveryFileWithItsSize(t *testing.T) {
previews := []synclocal.RenderedFile{
{Path: "project/tools/search.json", Content: []byte("{\n}")},
{Path: "project/configs/config/first.prompt.md", Content: []byte("---\nkey: first\n---\n\nfirst\n")},
}

assert.Equal(
t,
`============================================================
File 1 of 2
Would create: .launchdarkly/project/configs/config/first.prompt.md
------------------------------------------------------------
first
============================================================

============================================================
File 2 of 2
Would create: .launchdarkly/project/configs/config/second.prompt.md
------------------------------------------------------------
second
============================================================
`,
output.String(),
)
var list, content bytes.Buffer
writePreviews(&list, previews, false)
writePreviews(&content, previews, true)

assert.Equal(t, `2 files to create:
.launchdarkly/project/tools/search.json +2
.launchdarkly/project/configs/config/first.prompt.md +5
`, list.String())
assert.Equal(t, list.String()+`
.launchdarkly/project/tools/search.json
{
}

.launchdarkly/project/configs/config/first.prompt.md
---
key: first
---

first
`, content.String())
}

type fakeCatalog struct {
Expand Down
2 changes: 1 addition & 1 deletion internal/sync/prompt/acceptance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -782,7 +782,7 @@ func TestPromptStaleWorkingCopyPullsInsteadOfReverting(t *testing.T) {
assert.False(t, slices.ContainsFunc(api.requests, func(request string) bool {
return strings.HasPrefix(request, "PATCH ") && strings.Contains(request, "/variations/")
}), "sync wrote the older local file back to LaunchDarkly")
assert.Contains(t, review, "Another working copy synced this variation after your sync.lock.")
assert.Contains(t, review, "Note: Another working copy synced variation production/support/default after your sync.lock.")
resources, err := synclocal.CompileWorkspace(root)
require.NoError(t, err)
require.Len(t, resources, 1)
Expand Down
5 changes: 4 additions & 1 deletion internal/sync/prompt/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,10 @@ type Options struct {
// Yes applies the plan without a confirmation prompt.
Yes bool
// NoInput makes each prompt for missing input an error.
NoInput bool
NoInput bool
// Diff shows the full diff of each planned change, and the content of
// each file that add creates.
Diff bool
Context context.Context
Input io.Reader
Output io.Writer
Expand Down
45 changes: 28 additions & 17 deletions internal/sync/prompt/conflict.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"io"
"slices"
"strings"

syncconsole "github.com/launchdarkly/ldcli/internal/sync/console"
syncinteractive "github.com/launchdarkly/ldcli/internal/sync/interactive"
Expand Down Expand Up @@ -77,8 +78,7 @@ var errConflictAborted = errors.New("sync conflict left unresolved")
// conflictGroup is one conflict choice. Variations that share a changed
// attachment are in one group, because the attachment can have only one state.
type conflictGroup struct {
resources []PlannedResource
attachment bool
resources []PlannedResource
}

type conflictResolutionResult struct {
Expand Down Expand Up @@ -107,6 +107,7 @@ func resolveConflicts(
return conflictResolutionResult{}, nil
}

_ = syncconsole.New(options.ErrorOutput).Line(planSummary(plan))
result := conflictResolutionResult{resolutions: make(map[ResourceID]ConflictResolution)}
for _, group := range groups {
if err := writeConflict(options.ErrorOutput, group); err != nil {
Expand Down Expand Up @@ -149,23 +150,33 @@ func resolveConflicts(
return result, nil
}

// writeConflict shows the diff of each resource in a group that has one.
// writeConflict shows one line that names the resources of the group, and
// then the diff of each resource that has one.
func writeConflict(output io.Writer, group conflictGroup) error {
visible := slices.DeleteFunc(slices.Clone(group.resources), func(resource PlannedResource) bool {
return len(resource.Diff) == 0
})
if len(visible) == 0 {
visible = group.resources[:1]
}
if err := writePlanReview(output, "plaintext", Plan{Resources: visible}, terminalWidth(output)); err != nil {
return err
console := syncconsole.New(output)
header := fmt.Sprintf("\nConflict: %s changed on both sides.", group.resources[0].ID.Label())
if others := group.resources[1:]; len(others) != 0 {
labels := make([]string, 0, len(others))
for _, resource := range others {
labels = append(labels, resource.ID.Label())
}
header += fmt.Sprintf(" It shares a changed tool or skill with %s, so one choice covers them all.", strings.Join(labels, ", "))
}
if group.attachment && len(group.resources) > 1 {
console := syncconsole.New(output)
_ = console.Printf("This attachment conflict affects %d variations:\n", len(group.resources))
for _, resource := range group.resources {
_ = console.Printf("- %s\n", resource.ID)
_ = console.Line(header)

width := terminalWidth(output)
for _, resource := range group.resources {
if len(resource.Diff) == 0 {
continue
}
if len(group.resources) > 1 {
_ = console.Printf("\n %s\n", resource.ID.Label())
}
rendered, err := renderVariationDiff(resource.Diff, outputPlaintext, width, diffPresentation(resource.Action))
if err != nil {
return err
}
_ = console.Write(rendered)
}
return nil
}
Expand Down Expand Up @@ -194,7 +205,7 @@ func groupConflicts(plan Plan) []conflictGroup {
}
}

group := conflictGroup{attachment: len(members) > 1}
var group conflictGroup
for _, index := range members {
group.resources = append(group.resources, plan.Resources[index])
}
Expand Down
21 changes: 19 additions & 2 deletions internal/sync/prompt/conflict_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,25 @@ func TestResolveConflictsShowsDiffBeforePrompt(t *testing.T) {
assert.Less(t, strings.Index(rendered, "LaunchDarkly now"), strings.Index(rendered, "Using local."))
}

func TestWriteConflictNamesTheResourcesThatOneChoiceCovers(t *testing.T) {
conflict := divergentPlan(t).Resources[0]
shared := conflict
shared.ID.LookupKey = "support/shared"
shared.Action = ActionUpdateServer

var single, group bytes.Buffer
require.NoError(t, writeConflict(&single, conflictGroup{resources: []PlannedResource{conflict}}))
require.NoError(t, writeConflict(&group, conflictGroup{resources: []PlannedResource{conflict, shared}}))

assert.True(t, strings.HasPrefix(single.String(),
"\nConflict: variation production/support/default changed on both sides.\n\n Variation content (changed)\n"))
assert.True(t, strings.HasPrefix(group.String(),
"\nConflict: variation production/support/default changed on both sides. "+
"It shares a changed tool or skill with variation production/support/shared, so one choice covers them all.\n"))
assert.Contains(t, group.String(), "\n variation production/support/shared\n")
assert.Equal(t, 2, strings.Count(group.String(), "Variation content (changed)"))
}

func TestResolveConflictsRequiresTerminalEvenWithYes(t *testing.T) {
plan := divergentPlan(t)
input := strings.NewReader("")
Expand Down Expand Up @@ -214,7 +233,6 @@ func TestGroupConflictsDeduplicatesSharedAttachment(t *testing.T) {
groups := groupConflicts(Plan{Resources: []PlannedResource{first, second}})

require.Len(t, groups, 1)
assert.True(t, groups[0].attachment)
assert.Len(t, groups[0].resources, 2)
}

Expand Down Expand Up @@ -247,7 +265,6 @@ func TestGroupConflictsConnectsMixedChangesAcrossSharedAttachments(t *testing.T)
groups := groupConflicts(Plan{Resources: resources})

require.Len(t, groups, 1)
assert.True(t, groups[0].attachment)
assert.Len(t, groups[0].resources, 4)
}

Expand Down
Loading
Loading