diff --git a/.github/workflows/weekly-research.lock.yml b/.github/workflows/weekly-research.lock.yml index 39a1a3f554..b5ad1662ae 100644 --- a/.github/workflows/weekly-research.lock.yml +++ b/.github/workflows/weekly-research.lock.yml @@ -124,7 +124,7 @@ jobs: ### Output Report implemented via GitHub Action Job Summary - You will use the Job Summary for GitHub Actions run ${{ github.run_id }} in ${{ github.repository }} to report progess. This means writing to the special file that is stored in the environment variable GITHUB_STEP_SUMMARY. You can write the file using "echo" or the "Write" tool. GITHUB_STEP_SUMMARY is an environment variable set by GitHub Actions which you can use to write the report. You can read this environment variable using the bash command "echo $GITHUB_STEP_SUMMARY". + You will use the Job Summary for GitHub Actions run ${{ github.run_id }} in ${{ github.repository }} to report progess. This means writing to the special file $GITHUB_STEP_SUMMARY. You can write the file using "echo" or the "Write" tool. GITHUB_STEP_SUMMARY is an environment variable set by GitHub Actions which you can use to write the report. You can read this environment variable using the bash command "echo $GITHUB_STEP_SUMMARY". At the end of the workflow, finalize the job summry with a very, very succinct summary in note form of - the steps you took diff --git a/pkg/cli/commands.go b/pkg/cli/commands.go index 682b9e1da9..25504c7baa 100644 --- a/pkg/cli/commands.go +++ b/pkg/cli/commands.go @@ -168,7 +168,7 @@ func listAgenticEngines(verbose bool) error { engine, err := registry.GetEngine(engineID) if err != nil { if verbose { - fmt.Printf("Warning: Failed to get engine '%s': %v\n", engineID, err) + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to get engine '%s': %v", engineID, err))) } continue } @@ -225,7 +225,7 @@ func AddWorkflowWithRepo(workflow string, number int, verbose bool, engineOverri } if verbose { - fmt.Printf("Installing repository %s before adding workflow...\n", repoSpec) + fmt.Println(console.FormatInfoMessage(fmt.Sprintf("Installing repository %s before adding workflow...", repoSpec))) } // Install as global package (not local) to match the behavior expected if err := InstallPackage(repoSpec, false, verbose); err != nil { @@ -237,8 +237,17 @@ func AddWorkflowWithRepo(workflow string, number int, verbose bool, engineOverri workflow = fmt.Sprintf("%s/%s", repo, workflow) } - // Call the original AddWorkflow function - return AddWorkflow(workflow, number, verbose, engineOverride, name, force) + // Call AddWorkflowWithTracking directly with a new tracker + tracker, err := NewFileTracker() + if err != nil { + // If we can't create a tracker (e.g., not in git repo), fall back to non-tracking behavior + if verbose { + fmt.Println(console.FormatWarningMessage(fmt.Sprintf("Could not create file tracker: %v", err))) + } + return AddWorkflowWithTracking(workflow, number, verbose, engineOverride, name, force, nil) + } + + return AddWorkflowWithTracking(workflow, number, verbose, engineOverride, name, force, tracker) } // AddWorkflowWithRepoAndPR adds a workflow from components to .github/workflows @@ -272,10 +281,16 @@ func AddWorkflowWithRepoAndPR(workflow string, number int, verbose bool, engineO return fmt.Errorf("failed to create branch %s: %w", branchName, err) } - // Ensure we return to original branch on error + // Create file tracker for rollback capability + tracker, err := NewFileTracker() + if err != nil { + return fmt.Errorf("failed to create file tracker: %w", err) + } + + // Ensure we switch back to original branch on exit defer func() { if err := switchBranch(currentBranch, verbose); err != nil && verbose { - fmt.Printf("Warning: Failed to switch back to original branch %s: %v\n", currentBranch, err) + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to switch back to original branch %s: %v", currentBranch, err))) } }() @@ -287,7 +302,7 @@ func AddWorkflowWithRepoAndPR(workflow string, number int, verbose bool, engineO } if verbose { - fmt.Printf("Installing repository %s before adding workflow...\n", repoSpec) + fmt.Println(console.FormatInfoMessage(fmt.Sprintf("Installing repository %s before adding workflow...", repoSpec))) } // Install as global package (not local) to match the behavior expected if err := InstallPackage(repoSpec, false, verbose); err != nil { @@ -299,19 +314,28 @@ func AddWorkflowWithRepoAndPR(workflow string, number int, verbose bool, engineO workflow = fmt.Sprintf("%s/%s", repo, workflow) } - // Add workflow files using existing logic - if err := AddWorkflow(workflow, number, verbose, engineOverride, name, force); err != nil { + // Add workflow files using tracking logic + if err := AddWorkflowWithTracking(workflow, number, verbose, engineOverride, name, force, tracker); err != nil { + if rollbackErr := tracker.RollbackAllFiles(verbose); rollbackErr != nil && verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to rollback files: %v", rollbackErr))) + } return fmt.Errorf("failed to add workflow: %w", err) } - // Commit changes + // Commit changes (all tracked files should already be staged) commitMessage := fmt.Sprintf("Add workflow: %s", workflow) if err := commitChanges(commitMessage, verbose); err != nil { + if rollbackErr := tracker.RollbackAllFiles(verbose); rollbackErr != nil && verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to rollback files: %v", rollbackErr))) + } return fmt.Errorf("failed to commit changes: %w", err) } // Push branch if err := pushBranch(branchName, verbose); err != nil { + if rollbackErr := tracker.RollbackAllFiles(verbose); rollbackErr != nil && verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to rollback files: %v", rollbackErr))) + } return fmt.Errorf("failed to push branch %s: %w", branchName, err) } @@ -319,9 +343,14 @@ func AddWorkflowWithRepoAndPR(workflow string, number int, verbose bool, engineO prTitle := fmt.Sprintf("Add workflow: %s", workflow) prBody := fmt.Sprintf("Automatically created PR to add workflow: %s", workflow) if err := createPR(branchName, prTitle, prBody, verbose); err != nil { + if rollbackErr := tracker.RollbackAllFiles(verbose); rollbackErr != nil && verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to rollback files: %v", rollbackErr))) + } return fmt.Errorf("failed to create PR: %w", err) } + // Success - no rollback needed + // Switch back to original branch if err := switchBranch(currentBranch, verbose); err != nil { return fmt.Errorf("failed to switch back to branch %s: %w", currentBranch, err) @@ -331,19 +360,19 @@ func AddWorkflowWithRepoAndPR(workflow string, number int, verbose bool, engineO return nil } -// AddWorkflow adds a workflow from components to .github/workflows -func AddWorkflow(workflow string, number int, verbose bool, engineOverride string, name string, force bool) error { +// AddWorkflowWithTracking adds a workflow from components to .github/workflows with file tracking +func AddWorkflowWithTracking(workflow string, number int, verbose bool, engineOverride string, name string, force bool, tracker *FileTracker) error { if workflow == "" { - fmt.Println("Error: No components path specified. Usage: " + constants.CLIExtensionPrefix + " add ") + fmt.Fprintln(os.Stderr, console.FormatErrorMessage("No components path specified. Usage: "+constants.CLIExtensionPrefix+" add ")) // Show available workflows using the same logic as ListWorkflows return ListWorkflows(false) } if verbose { - fmt.Printf("Adding workflow: %s\n", workflow) - fmt.Printf("Number of copies: %d\n", number) + fmt.Println(console.FormatInfoMessage(fmt.Sprintf("Adding workflow: %s", workflow))) + fmt.Println(console.FormatInfoMessage(fmt.Sprintf("Number of copies: %d", number))) if force { - fmt.Printf("Force flag enabled: will overwrite existing files\n") + fmt.Println(console.FormatInfoMessage("Force flag enabled: will overwrite existing files")) } } @@ -371,11 +400,11 @@ func AddWorkflow(workflow string, number int, verbose bool, engineOverride strin // Try to read the workflow content from multiple sources sourceContent, sourceInfo, err := findAndReadWorkflow(workflowPath, workflowsDir, verbose) if err != nil { - fmt.Printf("Error: Workflow '%s' not found.\n", workflow) + fmt.Fprintln(os.Stderr, console.FormatErrorMessage(fmt.Sprintf("Workflow '%s' not found.", workflow))) // Show available workflows using the same logic as ListWorkflows - fmt.Println("\nRun '" + constants.CLIExtensionPrefix + " list' to see available workflows.") - fmt.Println("For packages, use '" + constants.CLIExtensionPrefix + " list --packages' to see installed packages.") + fmt.Println(console.FormatInfoMessage("Run '" + constants.CLIExtensionPrefix + " list' to see available workflows.")) + fmt.Println(console.FormatInfoMessage("For packages, use '" + constants.CLIExtensionPrefix + " list --packages' to see installed packages.")) return fmt.Errorf("workflow not found: %s", workflow) } @@ -415,12 +444,12 @@ func AddWorkflow(workflow string, number int, verbose bool, engineOverride strin // Collect all @include dependencies from the workflow file includeDeps, err := collectIncludeDependenciesFromSource(string(sourceContent), sourceInfo, verbose) if err != nil { - fmt.Printf("Warning: Failed to collect include dependencies: %v\n", err) + fmt.Println(console.FormatWarningMessage(fmt.Sprintf("Failed to collect include dependencies: %v", err))) } // Copy all @include dependencies to .github/workflows maintaining relative paths if err := copyIncludeDependenciesFromSourceWithForce(includeDeps, githubWorkflowsDir, sourceInfo, verbose, force); err != nil { - fmt.Printf("Warning: Failed to copy include dependencies: %v\n", err) + fmt.Println(console.FormatWarningMessage(fmt.Sprintf("Failed to copy include dependencies: %v", err))) } // Process each copy @@ -434,14 +463,14 @@ func AddWorkflow(workflow string, number int, verbose bool, engineOverride strin } // Check if destination file already exists - if _, err := os.Stat(destFile); err == nil && !force { - fmt.Printf("Warning: Destination file '%s' already exists, skipping.\n", destFile) - continue - } - - // If force is enabled and file exists, show overwrite message - if _, err := os.Stat(destFile); err == nil && force { - fmt.Printf("Overwriting existing file: %s\n", destFile) + fileExists := false + if _, err := os.Stat(destFile); err == nil { + fileExists = true + if !force { + fmt.Println(console.FormatWarningMessage(fmt.Sprintf("Destination file '%s' already exists, skipping.", destFile))) + continue + } + fmt.Println(console.FormatInfoMessage(fmt.Sprintf("Overwriting existing file: %s", destFile))) } // Process content for numbered workflows @@ -451,6 +480,15 @@ func AddWorkflow(workflow string, number int, verbose bool, engineOverride strin content = updateWorkflowTitle(content, i) } + // Track the file based on whether it existed before (if tracker is available) + if tracker != nil { + if fileExists { + tracker.TrackModified(destFile) + } else { + tracker.TrackCreated(destFile) + } + } + // Write the file if err := os.WriteFile(destFile, []byte(content), 0644); err != nil { return fmt.Errorf("failed to write destination file '%s': %w", destFile, err) @@ -458,15 +496,24 @@ func AddWorkflow(workflow string, number int, verbose bool, engineOverride strin fmt.Printf("Added workflow: %s\n", destFile) - // Try to compile the workflow and then move lock file to git root - if err := compileWorkflow(destFile, verbose, engineOverride); err != nil { - fmt.Println(err) + // Try to compile the workflow and track generated files + if tracker != nil { + if err := compileWorkflowWithTracking(destFile, verbose, engineOverride, tracker); err != nil { + fmt.Println(err) + } + } else { + // Fall back to basic compilation without tracking + if err := compileWorkflow(destFile, verbose, engineOverride); err != nil { + fmt.Println(err) + } } } - // Try to stage changes to git if in a git repository - if isGitRepo() { - stageWorkflowChanges() + // Stage tracked files to git if in a git repository + if isGitRepo() && tracker != nil { + if err := tracker.StageAllFiles(verbose); err != nil { + return fmt.Errorf("failed to stage workflow files: %w", err) + } } return nil @@ -497,7 +544,7 @@ func CompileWorkflows(markdownFile string, verbose bool, engineOverride string, if autoCompile { if err := ensureAutoCompileWorkflow(verbose); err != nil { if verbose { - fmt.Printf("Warning: Failed to manage auto-compile workflow: %v\n", err) + fmt.Println(console.FormatWarningMessage(fmt.Sprintf("Failed to manage auto-compile workflow: %v", err))) } } } @@ -505,16 +552,16 @@ func CompileWorkflows(markdownFile string, verbose bool, engineOverride string, // Ensure .gitattributes marks .lock.yml files as generated if err := ensureGitAttributes(); err != nil { if verbose { - fmt.Printf("Warning: Failed to update .gitattributes: %v\n", err) + fmt.Println(console.FormatWarningMessage(fmt.Sprintf("Failed to update .gitattributes: %v", err))) } } else if verbose { - fmt.Printf("Updated .gitattributes to mark .lock.yml files as generated\n") + fmt.Println(console.FormatSuccessMessage("Updated .gitattributes to mark .lock.yml files as generated")) } // Ensure copilot instructions are present if err := ensureCopilotInstructions(verbose, writeInstructions); err != nil { if verbose { - fmt.Printf("Warning: Failed to update copilot instructions: %v\n", err) + fmt.Println(console.FormatWarningMessage(fmt.Sprintf("Failed to update copilot instructions: %v", err))) } } @@ -531,7 +578,7 @@ func CompileWorkflows(markdownFile string, verbose bool, engineOverride string, if autoCompile { if err := ensureAutoCompileWorkflow(verbose); err != nil { if verbose { - fmt.Printf("Warning: Failed to manage auto-compile workflow: %v\n", err) + fmt.Println(console.FormatWarningMessage(fmt.Sprintf("Failed to manage auto-compile workflow: %v", err))) } } } @@ -577,16 +624,16 @@ func CompileWorkflows(markdownFile string, verbose bool, engineOverride string, // Ensure .gitattributes marks .lock.yml files as generated if err := ensureGitAttributes(); err != nil { if verbose { - fmt.Printf("Warning: Failed to update .gitattributes: %v\n", err) + fmt.Println(console.FormatWarningMessage(fmt.Sprintf("Failed to update .gitattributes: %v", err))) } } else if verbose { - fmt.Printf("Updated .gitattributes to mark .lock.yml files as generated\n") + fmt.Println(console.FormatSuccessMessage("Updated .gitattributes to mark .lock.yml files as generated")) } // Ensure copilot instructions are present if err := ensureCopilotInstructions(verbose, writeInstructions); err != nil { if verbose { - fmt.Printf("Warning: Failed to update copilot instructions: %v\n", err) + fmt.Println(console.FormatWarningMessage(fmt.Sprintf("Failed to update copilot instructions: %v", err))) } } @@ -964,9 +1011,9 @@ func RemoveWorkflows(pattern string, keepOrphans bool) error { var removedFiles []string for _, file := range filesToRemove { if err := os.Remove(file); err != nil { - fmt.Printf("Warning: Failed to remove %s: %v\n", file, err) + fmt.Println(console.FormatWarningMessage(fmt.Sprintf("Failed to remove %s: %v", file, err))) } else { - fmt.Printf("Removed: %s\n", filepath.Base(file)) + fmt.Println(console.FormatSuccessMessage(fmt.Sprintf("Removed: %s", filepath.Base(file)))) removedFiles = append(removedFiles, file) } @@ -1279,6 +1326,58 @@ func compileWorkflow(filePath string, verbose bool, engineOverride string) error return nil } +// compileWorkflowWithTracking compiles a workflow and tracks generated files +func compileWorkflowWithTracking(filePath string, verbose bool, engineOverride string, tracker *FileTracker) error { + // Generate the expected lock file path + lockFile := strings.TrimSuffix(filePath, ".md") + ".lock.yml" + + // Check if lock file exists before compilation + lockFileExists := false + if _, err := os.Stat(lockFile); err == nil { + lockFileExists = true + } + + // Check if .gitattributes exists before ensuring it + gitRoot, err := findGitRoot() + if err != nil { + return err + } + gitAttributesPath := filepath.Join(gitRoot, ".gitattributes") + gitAttributesExists := false + if _, err := os.Stat(gitAttributesPath); err == nil { + gitAttributesExists = true + } + + // Track the lock file before compilation + if lockFileExists { + tracker.TrackModified(lockFile) + } else { + tracker.TrackCreated(lockFile) + } + + // Track .gitattributes file before modification + if gitAttributesExists { + tracker.TrackModified(gitAttributesPath) + } else { + tracker.TrackCreated(gitAttributesPath) + } + + // Create compiler and compile the workflow + compiler := workflow.NewCompiler(verbose, engineOverride, GetVersion()) + if err := compiler.CompileWorkflow(filePath); err != nil { + return err + } + + // Ensure .gitattributes marks .lock.yml files as generated + if err := ensureGitAttributes(); err != nil { + if verbose { + fmt.Printf("Warning: Failed to update .gitattributes: %v\n", err) + } + } + + return nil +} + func isGitRepo() bool { cmd := exec.Command("git", "rev-parse", "--git-dir") return cmd.Run() == nil diff --git a/pkg/cli/commands_test.go b/pkg/cli/commands_test.go index 1df130b601..7f9b7d4d60 100644 --- a/pkg/cli/commands_test.go +++ b/pkg/cli/commands_test.go @@ -51,7 +51,7 @@ func TestAddWorkflow(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := AddWorkflow(tt.workflow, tt.number, false, "", "", false) + err := AddWorkflowWithTracking(tt.workflow, tt.number, false, "", "", false, nil) if tt.expectError && err == nil { t.Errorf("Expected error for test '%s', got nil", tt.name) @@ -68,13 +68,13 @@ func TestAddWorkflowForce(t *testing.T) { // It doesn't test the actual file system operations // Test that force=false fails when a file "exists" (simulated by empty workflow name which triggers help) - err := AddWorkflow("", 1, false, "", "", false) + err := AddWorkflowWithTracking("", 1, false, "", "", false, nil) if err != nil { t.Errorf("Expected no error for empty workflow (shows help), got: %v", err) } // Test that force=true works with same parameters - err = AddWorkflow("", 1, false, "", "", true) + err = AddWorkflowWithTracking("", 1, false, "", "", true, nil) if err != nil { t.Errorf("Expected no error for empty workflow with force=true, got: %v", err) } @@ -180,13 +180,13 @@ func TestAllCommandsExist(t *testing.T) { name string }{ {func() error { return ListWorkflows(false) }, false, "ListWorkflows"}, - {func() error { return AddWorkflow("", 1, false, "", "", false) }, false, "AddWorkflow (empty name)"}, // Shows help when empty, doesn't error - {func() error { return CompileWorkflows("", false, "", false, false, false, false) }, false, "CompileWorkflows"}, // Should compile existing markdown files successfully - {func() error { return RemoveWorkflows("test", false) }, false, "RemoveWorkflows"}, // Should handle missing directory gracefully - {func() error { return StatusWorkflows("test", false) }, false, "StatusWorkflows"}, // Should handle missing directory gracefully - {func() error { return EnableWorkflows("test") }, false, "EnableWorkflows"}, // Should handle missing directory gracefully - {func() error { return DisableWorkflows("test") }, false, "DisableWorkflows"}, // Should handle missing directory gracefully - {func() error { return RunWorkflowOnGitHub("", false) }, true, "RunWorkflowOnGitHub"}, // Should error with empty workflow name + {func() error { return AddWorkflowWithTracking("", 1, false, "", "", false, nil) }, false, "AddWorkflowWithTracking (empty name)"}, // Shows help when empty, doesn't error + {func() error { return CompileWorkflows("", false, "", false, false, false, false) }, false, "CompileWorkflows"}, // Should compile existing markdown files successfully + {func() error { return RemoveWorkflows("test", false) }, false, "RemoveWorkflows"}, // Should handle missing directory gracefully + {func() error { return StatusWorkflows("test", false) }, false, "StatusWorkflows"}, // Should handle missing directory gracefully + {func() error { return EnableWorkflows("test") }, false, "EnableWorkflows"}, // Should handle missing directory gracefully + {func() error { return DisableWorkflows("test") }, false, "DisableWorkflows"}, // Should handle missing directory gracefully + {func() error { return RunWorkflowOnGitHub("", false) }, true, "RunWorkflowOnGitHub"}, // Should error with empty workflow name } for _, test := range tests { diff --git a/pkg/cli/file_tracker.go b/pkg/cli/file_tracker.go new file mode 100644 index 0000000000..8dffd09b24 --- /dev/null +++ b/pkg/cli/file_tracker.go @@ -0,0 +1,175 @@ +package cli + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// FileTracker keeps track of files created or modified during workflow operations +// to enable proper staging and rollback functionality +type FileTracker struct { + CreatedFiles []string + ModifiedFiles []string + OriginalContent map[string][]byte // Store original content for rollback + gitRoot string +} + +// NewFileTracker creates a new file tracker +func NewFileTracker() (*FileTracker, error) { + gitRoot, err := findGitRoot() + if err != nil { + return nil, fmt.Errorf("file tracker requires being in a git repository: %w", err) + } + return &FileTracker{ + CreatedFiles: make([]string, 0), + ModifiedFiles: make([]string, 0), + OriginalContent: make(map[string][]byte), + gitRoot: gitRoot, + }, nil +} + +// TrackCreated adds a file to the created files list +func (ft *FileTracker) TrackCreated(filePath string) { + absPath, err := filepath.Abs(filePath) + if err != nil { + absPath = filePath + } + ft.CreatedFiles = append(ft.CreatedFiles, absPath) +} + +// TrackModified adds a file to the modified files list and stores its original content +func (ft *FileTracker) TrackModified(filePath string) { + absPath, err := filepath.Abs(filePath) + if err != nil { + absPath = filePath + } + + // Store original content if not already stored + if _, exists := ft.OriginalContent[absPath]; !exists { + if content, err := os.ReadFile(absPath); err == nil { + ft.OriginalContent[absPath] = content + } + } + + ft.ModifiedFiles = append(ft.ModifiedFiles, absPath) +} + +// GetAllFiles returns all tracked files (created and modified) +func (ft *FileTracker) GetAllFiles() []string { + all := make([]string, 0, len(ft.CreatedFiles)+len(ft.ModifiedFiles)) + all = append(all, ft.CreatedFiles...) + all = append(all, ft.ModifiedFiles...) + return all +} + +// StageAllFiles stages all tracked files using git add +func (ft *FileTracker) StageAllFiles(verbose bool) error { + allFiles := ft.GetAllFiles() + if len(allFiles) == 0 { + if verbose { + fmt.Println("No files to stage") + } + return nil + } + + if verbose { + fmt.Printf("Staging %d files...\n", len(allFiles)) + for _, file := range allFiles { + fmt.Printf(" - %s\n", file) + } + } + + // Stage all files in a single git add command + args := append([]string{"add"}, allFiles...) + cmd := exec.Command("git", args...) + cmd.Dir = ft.gitRoot + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to stage files: %w", err) + } + + return nil +} + +// RollbackCreatedFiles deletes all files that were created during the operation +func (ft *FileTracker) RollbackCreatedFiles(verbose bool) error { + if len(ft.CreatedFiles) == 0 { + return nil + } + + if verbose { + fmt.Printf("Rolling back %d created files...\n", len(ft.CreatedFiles)) + } + + var errors []string + for _, file := range ft.CreatedFiles { + if verbose { + fmt.Printf(" - Deleting %s\n", file) + } + if err := os.Remove(file); err != nil && !os.IsNotExist(err) { + errors = append(errors, fmt.Sprintf("failed to delete %s: %v", file, err)) + } + } + + if len(errors) > 0 { + return fmt.Errorf("rollback errors: %s", strings.Join(errors, "; ")) + } + + return nil +} + +// RollbackModifiedFiles restores all modified files to their original state +func (ft *FileTracker) RollbackModifiedFiles(verbose bool) error { + if len(ft.ModifiedFiles) == 0 { + return nil + } + + if verbose { + fmt.Printf("Rolling back %d modified files...\n", len(ft.ModifiedFiles)) + } + + var errors []string + for _, file := range ft.ModifiedFiles { + if verbose { + fmt.Printf(" - Restoring %s\n", file) + } + + // Restore original content if we have it + if originalContent, exists := ft.OriginalContent[file]; exists { + if err := os.WriteFile(file, originalContent, 0644); err != nil { + errors = append(errors, fmt.Sprintf("failed to restore %s: %v", file, err)) + } + } else { + if verbose { + fmt.Printf(" Warning: No original content stored for %s\n", file) + } + } + } + + if len(errors) > 0 { + return fmt.Errorf("rollback errors: %s", strings.Join(errors, "; ")) + } + + return nil +} + +// RollbackAllFiles rolls back both created and modified files +func (ft *FileTracker) RollbackAllFiles(verbose bool) error { + var errors []string + + if err := ft.RollbackCreatedFiles(verbose); err != nil { + errors = append(errors, fmt.Sprintf("created files rollback: %v", err)) + } + + if err := ft.RollbackModifiedFiles(verbose); err != nil { + errors = append(errors, fmt.Sprintf("modified files rollback: %v", err)) + } + + if len(errors) > 0 { + return fmt.Errorf("rollback errors: %s", strings.Join(errors, "; ")) + } + + return nil +} diff --git a/pkg/cli/file_tracker_test.go b/pkg/cli/file_tracker_test.go new file mode 100644 index 0000000000..37745bf9d9 --- /dev/null +++ b/pkg/cli/file_tracker_test.go @@ -0,0 +1,255 @@ +package cli + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestFileTracker_CreationAndTracking(t *testing.T) { + // Create a temporary directory for testing + tempDir, err := os.MkdirTemp("", "file-tracker-test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + // Initialize git repository in temp directory + gitCmd := []string{"git", "init"} + if err := runCommandInDir(gitCmd, tempDir); err != nil { + t.Skipf("Skipping test - git not available or failed to init: %v", err) + } + + // Change to temp directory + oldWd, _ := os.Getwd() + defer func() { + _ = os.Chdir(oldWd) + }() + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("Failed to change to temp directory: %v", err) + } + + // Create file tracker + tracker, err := NewFileTracker() + if err != nil { + t.Fatalf("Failed to create file tracker: %v", err) + } + + // Create test files + testFile1 := filepath.Join(tempDir, "test1.md") + testFile2 := filepath.Join(tempDir, "test2.lock.yml") + + // Create first file and track it + content1 := "# Test Workflow 1" + if err := os.WriteFile(testFile1, []byte(content1), 0644); err != nil { + t.Fatalf("Failed to write test file 1: %v", err) + } + tracker.TrackCreated(testFile1) + + // Create second file and track it + content2 := "name: test-workflow" + if err := os.WriteFile(testFile2, []byte(content2), 0644); err != nil { + t.Fatalf("Failed to write test file 2: %v", err) + } + tracker.TrackCreated(testFile2) + + // Verify tracking + allFiles := tracker.GetAllFiles() + if len(allFiles) != 2 { + t.Errorf("Expected 2 tracked files, got %d", len(allFiles)) + } + + // Test staging files + if err := tracker.StageAllFiles(false); err != nil { + t.Errorf("Failed to stage files: %v", err) + } + + // Test rollback + if err := tracker.RollbackCreatedFiles(false); err != nil { + t.Errorf("Failed to rollback files: %v", err) + } + + // Verify files were deleted + if _, err := os.Stat(testFile1); !os.IsNotExist(err) { + t.Errorf("File %s should have been deleted during rollback", testFile1) + } + if _, err := os.Stat(testFile2); !os.IsNotExist(err) { + t.Errorf("File %s should have been deleted during rollback", testFile2) + } +} + +func TestFileTracker_ModifiedFiles(t *testing.T) { + // Create a temporary directory for testing + tempDir, err := os.MkdirTemp("", "file-tracker-test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + // Initialize git repository in temp directory + gitCmd := []string{"git", "init"} + if err := runCommandInDir(gitCmd, tempDir); err != nil { + t.Skipf("Skipping test - git not available or failed to init: %v", err) + } + + // Change to temp directory + oldWd, _ := os.Getwd() + defer func() { + _ = os.Chdir(oldWd) + }() + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("Failed to change to temp directory: %v", err) + } + + // Create file tracker + tracker, err := NewFileTracker() + if err != nil { + t.Fatalf("Failed to create file tracker: %v", err) + } + + // Create existing file + testFile := filepath.Join(tempDir, "existing.md") + originalContent := "# Original Content" + if err := os.WriteFile(testFile, []byte(originalContent), 0644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + // Track modification BEFORE modifying the file + tracker.TrackModified(testFile) + + // Now modify the file + modifiedContent := "# Modified Content" + if err := os.WriteFile(testFile, []byte(modifiedContent), 0644); err != nil { + t.Fatalf("Failed to modify test file: %v", err) + } + + // Verify tracking + if len(tracker.CreatedFiles) != 0 { + t.Errorf("Expected 0 created files, got %d", len(tracker.CreatedFiles)) + } + if len(tracker.ModifiedFiles) != 1 { + t.Errorf("Expected 1 modified file, got %d", len(tracker.ModifiedFiles)) + } + + // Test staging files + if err := tracker.StageAllFiles(false); err != nil { + t.Errorf("Failed to stage files: %v", err) + } + + // Rollback should not delete modified files (only created ones) + if err := tracker.RollbackCreatedFiles(false); err != nil { + t.Errorf("Failed to rollback files: %v", err) + } + + // Verify file still exists (not deleted since it was modified, not created) + if _, err := os.Stat(testFile); os.IsNotExist(err) { + t.Errorf("Modified file %s should not have been deleted during rollback", testFile) + } + + // Verify file content is still modified + currentContent, err := os.ReadFile(testFile) + if err != nil { + t.Fatalf("Failed to read file after rollback: %v", err) + } + if string(currentContent) != modifiedContent { + t.Errorf("File content should still be modified, got %q", string(currentContent)) + } + + // Test rollback of modified files + if err := tracker.RollbackModifiedFiles(false); err != nil { + t.Errorf("Failed to rollback modified files: %v", err) + } + + // Verify file content was restored to original + restoredContent, err := os.ReadFile(testFile) + if err != nil { + t.Fatalf("Failed to read file after modified rollback: %v", err) + } + if string(restoredContent) != originalContent { + t.Errorf("File content should be restored to original %q, got %q", originalContent, string(restoredContent)) + } +} + +// Helper function to run commands in a specific directory +func runCommandInDir(cmd []string, dir string) error { + if len(cmd) == 0 { + return nil + } + command := cmd[0] + args := cmd[1:] + + c := exec.Command(command, args...) + c.Dir = dir + return c.Run() +} + +func TestFileTracker_RollbackAllFiles(t *testing.T) { + // Create a temporary directory for testing + tempDir, err := os.MkdirTemp("", "file-tracker-rollback-test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + // Initialize git repository in temp directory + gitCmd := []string{"git", "init"} + if err := runCommandInDir(gitCmd, tempDir); err != nil { + t.Skipf("Skipping test - git not available or failed to init: %v", err) + } + + // Change to temp directory + oldWd, _ := os.Getwd() + defer func() { + _ = os.Chdir(oldWd) + }() + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("Failed to change to temp directory: %v", err) + } + + // Create file tracker + tracker, err := NewFileTracker() + if err != nil { + t.Fatalf("Failed to create file tracker: %v", err) + } + + // Create an existing file + existingFile := filepath.Join(tempDir, "existing.md") + originalContent := "# Original Content" + if err := os.WriteFile(existingFile, []byte(originalContent), 0644); err != nil { + t.Fatalf("Failed to write existing file: %v", err) + } + + // Track modification before modifying + tracker.TrackModified(existingFile) + modifiedContent := "# Modified Content" + if err := os.WriteFile(existingFile, []byte(modifiedContent), 0644); err != nil { + t.Fatalf("Failed to modify existing file: %v", err) + } + + // Create a new file + newFile := filepath.Join(tempDir, "new.md") + tracker.TrackCreated(newFile) + if err := os.WriteFile(newFile, []byte("# New Content"), 0644); err != nil { + t.Fatalf("Failed to write new file: %v", err) + } + + // Rollback all files + if err := tracker.RollbackAllFiles(false); err != nil { + t.Errorf("Failed to rollback all files: %v", err) + } + + // Verify new file was deleted + if _, err := os.Stat(newFile); !os.IsNotExist(err) { + t.Errorf("New file %s should have been deleted", newFile) + } + + // Verify existing file was restored to original content + currentContent, err := os.ReadFile(existingFile) + if err != nil { + t.Fatalf("Failed to read existing file after rollback: %v", err) + } + if string(currentContent) != originalContent { + t.Errorf("Existing file should be restored to original %q, got %q", originalContent, string(currentContent)) + } +}