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
39 changes: 36 additions & 3 deletions docs/src/content/docs/tools/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,12 @@ The update command intelligently determines how to update based on the current r
The update process:
1. Parses the source field to extract repository, path, and current ref
2. Resolves the latest compatible version/commit based on the ref type
3. Downloads the updated workflow content from GitHub
4. Performs a 3-way merge, preserving the source field with the new ref
5. Automatically recompiles the updated workflow
3. Downloads the base version (original from source) and new version from GitHub
4. Performs a 3-way merge using `git merge-file` to intelligently combine changes:
- Preserves both local modifications and upstream improvements when possible
- Detects conflicts when both versions modify the same content
- Uses diff3-style conflict markers for manual resolution when needed
5. Automatically recompiles the updated workflow (skips compilation if conflicts exist)

**Source Field Format:**

Expand All @@ -147,6 +150,36 @@ Examples:
- `githubnext/agentics/workflows/ci-doctor.md@main` (branch)
- `githubnext/agentics/workflows/ci-doctor.md` (no ref, uses default branch)

**Merge Behavior and Conflict Resolution:**

The update command uses a 3-way merge algorithm (via `git merge-file`) to intelligently combine changes:

- **Clean Merge**: When local and upstream changes don't overlap, both are automatically preserved
- Example: Local adds markdown section, upstream adds frontmatter field → both included

- **Conflicts**: When both versions modify the same content, conflict markers are added:
```yaml
<<<<<<< current (local changes)
permissions:
issues: write
||||||| base (original)
=======
permissions:
pull-requests: write
>>>>>>> new (upstream)
```

To resolve conflicts:
1. Review the conflict markers in the updated workflow file
2. Manually edit to keep desired changes from both sides
3. Remove conflict markers (`<<<<<<<`, `|||||||`, `=======`, `>>>>>>>`)
4. Run `gh aw compile` to recompile the resolved workflow

- **Conflict Notification**: When conflicts occur, the update command displays a warning:
```
⚠ Updated ci-doctor.md from v1.0.0 to v1.1.0 with CONFLICTS - please review and resolve manually
```

## 🔧 Workflow Recompilation

The `compile` command transforms natural language workflow markdown files into executable GitHub Actions YAML files. This is the core functionality that converts your agentic workflow descriptions into automated GitHub workflows.
Expand Down
175 changes: 125 additions & 50 deletions pkg/cli/update_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,16 @@ func updateWorkflow(wf *workflowWithSource, allowMajor, force, verbose bool, eng
return nil
}

// Download the base version (current ref from source)
if verbose {
fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(fmt.Sprintf("Downloading base version from %s/%s@%s", sourceSpec.Repo, sourceSpec.Path, currentRef)))
}

baseContent, err := downloadWorkflowContent(sourceSpec.Repo, sourceSpec.Path, currentRef, verbose)
if err != nil {
return fmt.Errorf("failed to download base workflow: %w", err)
}

// Download the latest version
if verbose {
fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(fmt.Sprintf("Downloading latest version from %s/%s@%s", sourceSpec.Repo, sourceSpec.Path, latestRef)))
Expand All @@ -389,8 +399,8 @@ func updateWorkflow(wf *workflowWithSource, allowMajor, force, verbose bool, eng
return fmt.Errorf("failed to read current workflow: %w", err)
}

// Perform 3-way merge
mergedContent, err := mergeWorkflowContent(string(currentContent), string(newContent), wf.SourceSpec, latestRef, verbose)
// Perform 3-way merge using git merge-file
mergedContent, hasConflicts, err := mergeWorkflowContent(string(baseContent), string(currentContent), string(newContent), wf.SourceSpec, latestRef, verbose)
if err != nil {
return fmt.Errorf("failed to merge workflow content: %w", err)
}
Expand All @@ -400,6 +410,11 @@ func updateWorkflow(wf *workflowWithSource, allowMajor, force, verbose bool, eng
return fmt.Errorf("failed to write updated workflow: %w", err)
}

if hasConflicts {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Updated %s from %s to %s with CONFLICTS - please review and resolve manually", wf.Name, currentRef, latestRef)))
return nil // Not an error, but user needs to resolve conflicts
}

fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Updated %s from %s to %s", wf.Name, currentRef, latestRef)))

// Compile the updated workflow
Expand Down Expand Up @@ -435,81 +450,141 @@ func downloadWorkflowContent(repo, path, ref string, verbose bool) ([]byte, erro
return content, nil
}

// mergeWorkflowContent performs a 3-way merge of workflow content
// It removes the source field from the new content and updates it with the new ref
func mergeWorkflowContent(current, new, oldSourceSpec, newRef string, verbose bool) (string, error) {
// mergeWorkflowContent performs a 3-way merge of workflow content using git merge-file
// It returns the merged content, whether conflicts exist, and any error
func mergeWorkflowContent(base, current, new, oldSourceSpec, newRef string, verbose bool) (string, bool, error) {
if verbose {
fmt.Fprintln(os.Stderr, console.FormatVerboseMessage("Merging workflow content"))
fmt.Fprintln(os.Stderr, console.FormatVerboseMessage("Performing 3-way merge using git merge-file"))
}

// Parse both contents
currentResult, err := parser.ExtractFrontmatterFromContent(current)
// First, update the source field in the new content before merging
// This ensures the source field is correct even if there are conflicts
newWithUpdatedSource, err := updateSourceFieldInContent(new, oldSourceSpec, newRef)
if err != nil {
return "", fmt.Errorf("failed to parse current frontmatter: %w", err)
if verbose {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to update source in new content: %v", err)))
}
// Continue with original new content
newWithUpdatedSource = new
}

newResult, err := parser.ExtractFrontmatterFromContent(new)
// Create temporary directory for merge files
tmpDir, err := os.MkdirTemp("", "gh-aw-merge-*")
if err != nil {
return "", fmt.Errorf("failed to parse new frontmatter: %w", err)
return "", false, fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)

// Write base, current, and new versions to temporary files
baseFile := filepath.Join(tmpDir, "base.md")
currentFile := filepath.Join(tmpDir, "current.md")
newFile := filepath.Join(tmpDir, "new.md")

// Merge strategy: Keep local changes, but allow new fields from upstream
// Start with the new frontmatter as base
if newResult.Frontmatter == nil {
newResult.Frontmatter = make(map[string]any)
if err := os.WriteFile(baseFile, []byte(base), 0644); err != nil {
return "", false, fmt.Errorf("failed to write base file: %w", err)
}
if currentResult.Frontmatter == nil {
currentResult.Frontmatter = make(map[string]any)
if err := os.WriteFile(currentFile, []byte(current), 0644); err != nil {
return "", false, fmt.Errorf("failed to write current file: %w", err)
}
if err := os.WriteFile(newFile, []byte(newWithUpdatedSource), 0644); err != nil {
return "", false, fmt.Errorf("failed to write new file: %w", err)
}

// Execute git merge-file
// Format: git merge-file <current> <base> <new>
cmd := exec.Command("git", "merge-file",
"-L", "current (local changes)",
"-L", "base (original)",
"-L", "new (upstream)",
"--diff3", // Use diff3 style conflict markers for better context
currentFile, baseFile, newFile)

// Merge: new fields from upstream, but preserve local modifications
// This means we overlay current frontmatter on top of new frontmatter
for key, value := range currentResult.Frontmatter {
// Skip the source field as we'll update it separately
if key != "source" {
newResult.Frontmatter[key] = value
output, err := cmd.CombinedOutput()

// git merge-file returns:
// - 0 if merge was successful without conflicts
// - >0 if conflicts were found (appears to return number of conflicts, but file is still updated)
// The exit code can be >1 for multiple conflicts, not just errors
hasConflicts := false
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode := exitErr.ExitCode()
if exitCode > 0 && exitCode < 128 {
// Conflicts found (exit codes 1-127 indicate conflicts)
// Exit codes >= 128 typically indicate system errors
hasConflicts = true
if verbose {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Merge conflicts detected (exit code: %d)", exitCode)))
}
} else {
// Real error (exit code >= 128)
return "", false, fmt.Errorf("git merge-file failed: %w\nOutput: %s", err, output)
}
} else {
return "", false, fmt.Errorf("failed to execute git merge-file: %w", err)
}
}

// Use the new markdown content (assume upstream knows best for documentation)
// But if you want to preserve local markdown changes, you could use currentResult.Markdown

// Update source field with new ref
sourceSpec, err := parseSourceSpec(oldSourceSpec)
// Read the merged content from the current file (git merge-file updates it in-place)
mergedContent, err := os.ReadFile(currentFile)
if err != nil {
return "", fmt.Errorf("failed to parse source spec: %w", err)
return "", false, fmt.Errorf("failed to read merged content: %w", err)
}

mergedStr := string(mergedContent)

// Process @include directives if present and no conflicts
// Skip include processing if there are conflicts to avoid errors
if !hasConflicts {
sourceSpec, err := parseSourceSpec(oldSourceSpec)
if err == nil {
workflow := &WorkflowSpec{
RepoSpec: RepoSpec{
Repo: sourceSpec.Repo,
Version: newRef,
},
WorkflowPath: sourceSpec.Path,
}

processedContent, err := processIncludesInContent(mergedStr, workflow, newRef, verbose)
if err != nil {
if verbose {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to process includes: %v", err)))
}
// Return unprocessed content on error
} else {
mergedStr = processedContent
}
}
}

newSourceSpec := fmt.Sprintf("%s/%s@%s", sourceSpec.Repo, sourceSpec.Path, newRef)
newResult.Frontmatter["source"] = newSourceSpec
return mergedStr, hasConflicts, nil
}

// Reconstruct the workflow file with updated source
content, err := reconstructWorkflowFile(newResult.Frontmatter, newResult.Markdown)
// updateSourceFieldInContent updates the source field in workflow content
func updateSourceFieldInContent(content, oldSourceSpec, newRef string) (string, error) {
// Parse the content
result, err := parser.ExtractFrontmatterFromContent(content)
if err != nil {
return "", err
return "", fmt.Errorf("failed to parse frontmatter: %w", err)
}

// Process @include directives in the new content and replace with workflowspec
// Build a WorkflowSpec from the sourceSpec to use for processing includes
workflow := &WorkflowSpec{
RepoSpec: RepoSpec{
Repo: sourceSpec.Repo,
Version: newRef,
},
WorkflowPath: sourceSpec.Path,
if result.Frontmatter == nil {
result.Frontmatter = make(map[string]any)
}

// We don't have access to the package path here, so we'll just process the markdown
// The compile step will handle downloading the includes when needed
processedContent, err := processIncludesInContent(content, workflow, newRef, verbose)
// Parse the old source spec to construct the new one
sourceSpec, err := parseSourceSpec(oldSourceSpec)
if err != nil {
if verbose {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to process includes: %v", err)))
}
return content, nil // Return unprocessed content on error
return "", fmt.Errorf("failed to parse source spec: %w", err)
}

return processedContent, nil
// Update with new ref
newSourceSpec := fmt.Sprintf("%s/%s@%s", sourceSpec.Repo, sourceSpec.Path, newRef)
result.Frontmatter["source"] = newSourceSpec

// Reconstruct the workflow file
return reconstructWorkflowFile(result.Frontmatter, result.Markdown)
}

// reconstructWorkflowFile reconstructs a workflow file from frontmatter and markdown
Expand Down
Loading