Skip to content
Open
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
9 changes: 8 additions & 1 deletion cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
)

type RunFlags struct {
Target string `json:"target"`

Check failure on line 27 in cmd/run.go

View workflow job for this annotation

GitHub Actions / golangci-lint

File is not properly formatted (gofmt)
Source string `json:"source"`
InstallationURL string `json:"installationURL"`
InstallationURLs map[string]string `json:"installationURLs"`
Expand All @@ -49,7 +49,8 @@
Minimal bool `json:"minimal"`
Dependent string `json:"dependent"`
SourceLocation string `json:"source-location"`
AutoYes bool `json:"auto-yes"`
AutoYes bool `json:"auto-yes"`
OutputMergeConflicts bool `json:"output-merge-conflicts"`
}

const runLong = "# Run \n Execute the workflow(s) defined in your `.speakeasy/workflow.yaml` file." + `
Expand Down Expand Up @@ -200,6 +201,10 @@
Shorthand: "y",
Description: "auto confirm all prompts",
},
flag.BooleanFlag{
Name: "output-merge-conflicts",
Description: "write merge conflict markers to files and continue instead of failing when persistent edit conflicts are detected",
},
},
}

Expand Down Expand Up @@ -443,6 +448,7 @@
run.WithSkipCleanup(), // The studio won't work if we clean up before it launches
run.WithSourceLocation(flags.SourceLocation),
run.WithAutoYes(flags.AutoYes),
run.WithOutputMergeConflicts(flags.OutputMergeConflicts),
run.WithAllowPrompts(false), // Non-interactive mode
}

Expand Down Expand Up @@ -508,6 +514,7 @@
run.WithSkipCleanup(), // The studio won't work if we clean up before it launches
run.WithSourceLocation(flags.SourceLocation),
run.WithAutoYes(flags.AutoYes),
run.WithOutputMergeConflicts(flags.OutputMergeConflicts),
}

if flags.Minimal {
Expand Down
120 changes: 120 additions & 0 deletions internal/git/plumbing.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package git
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
Expand Down Expand Up @@ -502,3 +503,122 @@ func (r *Repository) setConflictStateGoGit(path string, base, ours, theirs []byt

return nil
}

// ResolveConflictState resolves a conflicted file in the git index by replacing
// stages 1/2/3 with a normal stage 0 entry using the file's current content on disk.
// This allows the file (which may contain conflict markers) to be committed.
func (r *Repository) ResolveConflictState(path string) error {
if r.repo == nil {
return fmt.Errorf("git repository not initialized")
}

// Ensure path is relative to the repository root
if filepath.IsAbs(path) {
relPath, err := filepath.Rel(r.Root(), path)
if err == nil {
path = relPath
}
}

// Git index always uses forward slashes, even on Windows
path = filepath.ToSlash(path)

// On Windows, use native git commands due to go-git file locking issues
if runtime.GOOS == "windows" {
return r.resolveConflictStateNative(path)
}

return r.resolveConflictStateGoGit(path)
}

// resolveConflictStateNative resolves a conflict using native git commands (for Windows).
func (r *Repository) resolveConflictStateNative(path string) error {
repoRoot := r.Root()
if repoRoot == "" {
return fmt.Errorf("repository root not found")
}

cmd := exec.Command("git", "add", "--", path)
cmd.Dir = repoRoot
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("git add failed for %s: %w: %s", path, err, string(output))
}

return nil
}

// resolveConflictStateGoGit resolves a conflict using go-git by reading the file
// from disk, writing a blob, and replacing conflict stage entries with a stage 0 entry.
func (r *Repository) resolveConflictStateGoGit(path string) error {
// Read current file content from disk
absPath := filepath.Join(r.Root(), filepath.FromSlash(path))
content, err := os.ReadFile(absPath)
if err != nil {
return fmt.Errorf("failed to read file %s: %w", path, err)
}

// Write blob to object database
blobHash, err := r.WriteBlob(content)
if err != nil {
return fmt.Errorf("failed to write blob for %s: %w", path, err)
}

// Read the current index
idx, err := r.repo.Storer.Index()
if err != nil {
return fmt.Errorf("failed to read index: %w", err)
}

// Determine file mode from existing entries (default to regular)
mode := filemode.Regular
for _, e := range idx.Entries {
if e.Name == path {
mode = e.Mode
break
}
}

// Remove all entries for this path (stages 1, 2, 3)
newEntries := make([]*index.Entry, 0, len(idx.Entries))
for _, e := range idx.Entries {
if e.Name != path {
newEntries = append(newEntries, e)
}
}

// Get file info for timestamps and size
fi, err := os.Stat(absPath)
if err != nil {
return fmt.Errorf("failed to stat file %s: %w", path, err)
}

now := fi.ModTime()

// Add stage 0 (resolved) entry
newEntries = append(newEntries, &index.Entry{
Name: path,
Hash: plumbing.NewHash(blobHash),
Mode: mode,
Stage: index.Merged, // Stage 0
CreatedAt: now,
ModifiedAt: now,
Size: uint32(fi.Size()),
})

idx.Entries = newEntries

// Sort entries by (Name, Stage) as required by git index format
sort.Slice(idx.Entries, func(i, j int) bool {
if idx.Entries[i].Name != idx.Entries[j].Name {
return idx.Entries[i].Name < idx.Entries[j].Name
}
return idx.Entries[i].Stage < idx.Entries[j].Stage
})

// Write the index back
if err := r.repo.Storer.SetIndex(idx); err != nil {
return fmt.Errorf("failed to write index: %w", err)
}

return nil
}
1 change: 1 addition & 0 deletions internal/run/target.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ func (w *Workflow) runTarget(ctx context.Context, target string) (*SourceResult,
TargetName: target,
SkipVersioning: w.SkipVersioning,
AllowPrompts: w.AllowPrompts,
OutputMergeConflicts: w.OutputMergeConflicts,
CancellableGeneration: w.CancellableGeneration,
StreamableGeneration: w.StreamableGeneration,
ReleaseNotes: changelogContent,
Expand Down
11 changes: 9 additions & 2 deletions internal/run/workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,11 @@

// Enable if target testing should be explicitly disabled, regardless of the
// workflow configuration enabling testing.
SkipTesting bool

Check failure on line 46 in internal/run/workflow.go

View workflow job for this annotation

GitHub Actions / golangci-lint

File is not properly formatted (gofmt)
BoostrapTests bool
AutoYes bool
AllowPrompts bool
AutoYes bool
AllowPrompts bool
OutputMergeConflicts bool

// Internal
workflowName string
Expand Down Expand Up @@ -286,6 +287,12 @@
}
}

func WithOutputMergeConflicts(outputMergeConflicts bool) Opt {
return func(w *Workflow) {
w.OutputMergeConflicts = outputMergeConflicts
}
}

func WithFromQuickstart(fromQuickstart bool) Opt {
return func(w *Workflow) {
w.FromQuickstart = fromQuickstart
Expand Down
69 changes: 59 additions & 10 deletions internal/sdkgen/sdkgen.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
}

type GenerateOptions struct {
CustomerID string

Check failure on line 61 in internal/sdkgen/sdkgen.go

View workflow job for this annotation

GitHub Actions / golangci-lint

File is not properly formatted (gofmt)
WorkspaceID string
Language string
SchemaPath string
Expand All @@ -76,8 +76,9 @@
Verbose bool
Compile bool
TargetName string
SkipVersioning bool
AllowPrompts bool
SkipVersioning bool
AllowPrompts bool
OutputMergeConflicts bool

CancellableGeneration *CancellableGeneration
StreamableGeneration *StreamableGeneration
Expand Down Expand Up @@ -258,6 +259,8 @@
}, err
}

hasConflictWarning := false

err = events.Telemetry(ctx, shared.InteractionTypeTargetGenerate, func(ctx context.Context, event *shared.CliEvent) error {
event.GenerateTargetName = &opts.TargetName

Expand All @@ -275,22 +278,44 @@
}

if len(errs) > 0 {
var conflictErr *merge.ConflictsError
var hasOtherErrors bool

for _, err := range errs {
// Check if it's a ConflictsError - render pretty conflict message
var conflictErr *merge.ConflictsError
if stderrors.As(err, &conflictErr) {
renderConflictsError(logger, conflictErr)
// Don't log the generic error for conflicts - we rendered a nice message
continue
}
logger.Error("", zap.Error(err))
hasOtherErrors = true
}

if failedStepMessage != "" {
return fmt.Errorf("generation failed for %q during step %q", opts.Language, failedStepMessage)
if conflictErr != nil {
if opts.OutputMergeConflicts && !hasOtherErrors {
// Resolve git conflict state so files can be committed
renderConflictsWarning(logger, conflictErr)
if repoErr == nil && !repo.IsNil() {
for _, file := range conflictErr.Files {
gitPath := filepath.Join(opts.OutDir, file)
if err := repo.ResolveConflictState(gitPath); err != nil {
logger.Warnf("Failed to resolve conflict state for %s: %v", file, err)
}
}
}
hasConflictWarning = true
// Fall through — don't return error
} else {
// Default: keep existing behavior
renderConflictsError(logger, conflictErr)
hasOtherErrors = true
}
}

return fmt.Errorf("failed to generate %q", opts.Language)
if hasOtherErrors {
if failedStepMessage != "" {
return fmt.Errorf("generation failed for %q during step %q", opts.Language, failedStepMessage)
}
return fmt.Errorf("failed to generate %q", opts.Language)
}
}

return nil
Expand All @@ -316,7 +341,11 @@
}
}

logger.Successf("\nSDK for %s generated successfully ✓", opts.Language)
if hasConflictWarning {
logger.Warnf("\nSDK for %s generated with merge conflicts ⚠", opts.Language)
} else {
logger.Successf("\nSDK for %s generated successfully ✓", opts.Language)
}
logger.WithStyle(styles.HeavilyEmphasized).Printf("For docs on customising the SDK check out: %s", sdkDocsLink)

if !generationAccess {
Expand Down Expand Up @@ -372,6 +401,26 @@
return nil
}

// renderConflictsWarning renders a warning message for merge conflicts when --output-merge-conflicts is used.
func renderConflictsWarning(logger log.Logger, conflictErr *merge.ConflictsError) {
var fileLines strings.Builder
for _, file := range conflictErr.Files {
fileLines.WriteString(fmt.Sprintf(" both modified: %s\n", file))
}

msg := styles.RenderWarningMessage(
"Merge Conflicts Detected",
fmt.Sprintf("%d file(s) have conflicts:\n%s", len(conflictErr.Files), fileLines.String()),
"Conflict markers have been written to these files. Please resolve them in your PR.",
)
logger.Println("\n" + msg)

// Emit GitHub Actions warning annotations for each conflicting file
for _, file := range conflictErr.Files {
logger.Printf("::warning file=%s::Merge conflict detected - resolve conflict markers in PR", file)
}
}

// renderConflictsError renders a git-status style error message for merge conflicts.
func renderConflictsError(logger log.Logger, conflictErr *merge.ConflictsError) {
// Build file list with "both modified:" prefix like git status
Expand Down
Loading