diff --git a/cmd/container-use/follow.go b/cmd/container-use/follow.go new file mode 100644 index 00000000..d295c022 --- /dev/null +++ b/cmd/container-use/follow.go @@ -0,0 +1,197 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/dagger/container-use/repository" + "github.com/fsnotify/fsnotify" + "github.com/spf13/cobra" +) + +var followCmd = &cobra.Command{ + Use: "follow ", + Short: "Checkout environment and continuously pull changes", + Long: `Checkout an environment's branch locally and continuously pull changes from the remote. +This command first performs a checkout operation, then continuously monitors for +changes in the environment's remote branch and pulls them automatically. + +Uses file system watching on the git refs directory for near-immediate response +when the environment is updated. Falls back to periodic polling for reliability. +Press Ctrl+C to stop following.`, + Args: cobra.ExactArgs(1), + ValidArgsFunction: suggestEnvironments, + Example: `# Follow environment changes +container-use follow fancy-mallard + +# Follow with custom branch name +container-use follow fancy-mallard -b my-review-branch + +# Follow with custom fallback interval +container-use follow fancy-mallard --fallback-interval 30s`, + RunE: func(app *cobra.Command, args []string) error { + ctx := app.Context() + envID := args[0] + + repo, err := repository.Open(ctx, ".") + if err != nil { + return err + } + + branchName, err := app.Flags().GetString("branch") + if err != nil { + return err + } + + fallbackInterval, err := app.Flags().GetDuration("fallback-interval") + if err != nil { + return err + } + + // First, perform the checkout + branch, err := repo.Checkout(ctx, envID, branchName) + if err != nil { + return err + } + + slog.Info("switched to branch", "branch", branch) + slog.Info("following environment for changes", "env-id", envID, "fallback-interval", fallbackInterval) + slog.Info("press Ctrl+C to stop following") + + // Set up signal handling for graceful shutdown + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + // Start following with file watching + return followWithFileWatching(ctx, repo, envID, fallbackInterval, sigCh) + }, +} + +func followWithFileWatching(ctx context.Context, repo *repository.Repository, envID string, fallbackInterval time.Duration, sigCh chan os.Signal) error { + // Create file watcher + watcher, err := fsnotify.NewWatcher() + if err != nil { + slog.Warn("failed to create file watcher, falling back to polling", "error", err) + return followWithPolling(ctx, repo, envID, fallbackInterval, sigCh) + } + defer watcher.Close() + + // Path to the remote ref file and its parent directory + refPath := filepath.Join(repo.SourcePath(), ".git", "refs", "remotes", "container-use", envID) + refDir := filepath.Dir(refPath) + + // Watch the ref directory to catch atomic writes + err = watcher.Add(refDir) + if err != nil { + slog.Warn("failed to watch ref directory, falling back to polling", "ref-dir", refDir, "error", err) + return followWithPolling(ctx, repo, envID, fallbackInterval, sigCh) + } + + slog.Debug("starting file watcher for environment", "env-id", envID, "ref-path", refPath) + + // Fallback ticker in case file watching misses something + fallbackTicker := time.NewTicker(fallbackInterval) + defer fallbackTicker.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-sigCh: + slog.Info("stopping follow") + return nil + case event := <-watcher.Events: + slog.Debug("file event received", "op", event.Op, "name", event.Name) + // Check if the event is for our specific ref file + if event.Name == refPath { + slog.Debug("triggering pull for ref file event", "op", event.Op) + if err := pullChanges(ctx, repo, envID); err != nil { + slog.Error("failed to pull changes", "error", err) + } + } else { + slog.Debug("ignoring event on unrelated file", "name", event.Name) + } + case err := <-watcher.Errors: + slog.Error("file watcher error", "error", err) + case <-fallbackTicker.C: + slog.Debug("fallback check triggered") + // Fallback check in case file watching missed something + if err := pullChanges(ctx, repo, envID); err != nil { + slog.Error("failed to pull changes during fallback", "error", err) + } + } + } +} + +func followWithPolling(ctx context.Context, repo *repository.Repository, envID string, interval time.Duration, sigCh chan os.Signal) error { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-sigCh: + slog.Info("stopping follow") + return nil + case <-ticker.C: + if err := pullChanges(ctx, repo, envID); err != nil { + slog.Error("failed to pull changes", "error", err) + } + } + } +} + +func pullChanges(ctx context.Context, repo *repository.Repository, envID string) error { + slog.Debug("fetching changes from remote", "env-id", envID) + // First, fetch the latest changes from the container-use remote + _, err := repository.RunGitCommand(ctx, repo.SourcePath(), "fetch", "container-use", envID) + if err != nil { + return fmt.Errorf("failed to fetch changes: %w", err) + } + slog.Debug("fetch completed successfully") + + // Check if there are any new changes to pull + remoteRef := fmt.Sprintf("container-use/%s", envID) + counts, err := repository.RunGitCommand(ctx, repo.SourcePath(), "rev-list", "--left-right", "--count", fmt.Sprintf("HEAD...%s", remoteRef)) + if err != nil { + return fmt.Errorf("failed to check for changes: %w", err) + } + + // Parse the output to determine if there are changes + parts := strings.Split(strings.TrimSpace(counts), "\t") + if len(parts) != 2 { + return fmt.Errorf("unexpected git rev-list output: %s", counts) + } + aheadCount, behindCount := parts[0], parts[1] + + // If we're behind, pull the changes + if behindCount != "0" { + if aheadCount == "0" { + // Fast-forward merge + _, err = repository.RunGitCommand(ctx, repo.SourcePath(), "merge", "--ff-only", remoteRef) + if err != nil { + return fmt.Errorf("failed to fast-forward merge: %w", err) + } + slog.Info("pulled new commits from environment", "commits", behindCount, "env-id", envID) + } else { + // Local changes exist, notify user + slog.Warn("environment has new commits but local branch is ahead, manual merge required", "env-id", envID, "remote-commits", behindCount, "local-commits", aheadCount) + } + } + + return nil +} + +func init() { + followCmd.Flags().StringP("branch", "b", "", "Local branch name to use") + followCmd.Flags().DurationP("fallback-interval", "i", 30*time.Second, "Fallback polling interval (e.g., 30s, 1m)") + rootCmd.AddCommand(followCmd) +} diff --git a/cmd/container-use/logger.go b/cmd/container-use/logger.go index a3344249..9b29d2b7 100644 --- a/cmd/container-use/logger.go +++ b/cmd/container-use/logger.go @@ -6,6 +6,9 @@ import ( "log/slog" "os" "time" + + "github.com/lmittmann/tint" + "golang.org/x/term" ) var ( @@ -30,16 +33,38 @@ func parseLogLevel(levelStr string) slog.Level { func setupLogger() error { var writers []io.Writer - logFile := "/tmp/container-use.debug.stderr.log" - if v, ok := os.LookupEnv("CONTAINER_USE_STDERR_FILE"); ok { - logFile = v - } + // Check if stdout is a TTY (interactive) vs piped/redirected (non-interactive) + isInteractive := term.IsTerminal(int(os.Stdout.Fd())) - file, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) - if err != nil { - return fmt.Errorf("failed to open log file %s: %w", logFile, err) + if !isInteractive { + // For non-interactive use (like MCP protocol), log to file to avoid interference + logFile := "/tmp/container-use.debug.stderr.log" + if v, ok := os.LookupEnv("CONTAINER_USE_STDERR_FILE"); ok { + logFile = v + } + + file, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + return fmt.Errorf("failed to open log file %s: %w", logFile, err) + } + writers = append(writers, file) + } else { + // For interactive use, log to stderr by default + if v, ok := os.LookupEnv("CONTAINER_USE_STDERR_FILE"); ok { + if v == "/dev/stderr" || v == "" { + writers = append(writers, os.Stderr) + } else { + file, err := os.OpenFile(v, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + return fmt.Errorf("failed to open log file %s: %w", v, err) + } + writers = append(writers, file) + } + } else { + // Default to stderr for interactive use + writers = append(writers, os.Stderr) + } } - writers = append(writers, file) if len(writers) == 0 { fmt.Fprintf(os.Stderr, "%s Logging disabled. Set CONTAINER_USE_STDERR_FILE and CONTAINER_USE_LOG_LEVEL environment variables\n", time.Now().Format(time.DateTime)) @@ -47,9 +72,20 @@ func setupLogger() error { logLevel := parseLogLevel(os.Getenv("CONTAINER_USE_LOG_LEVEL")) logWriter = io.MultiWriter(writers...) - handler := slog.NewTextHandler(logWriter, &slog.HandlerOptions{ - Level: logLevel, - }) + + var handler slog.Handler + if !isInteractive { + // For non-interactive use, use plain text handler for file logging + handler = slog.NewTextHandler(logWriter, &slog.HandlerOptions{ + Level: logLevel, + }) + } else { + // For interactive use, use tint for prettier output + handler = tint.NewHandler(logWriter, &tint.Options{ + Level: logLevel, + TimeFormat: time.Kitchen, + }) + } slog.SetDefault(slog.New(handler)) return nil diff --git a/go.mod b/go.mod index 461f6838..64d9853b 100644 --- a/go.mod +++ b/go.mod @@ -11,9 +11,11 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/dustin/go-humanize v1.0.1 github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0 + github.com/fsnotify/fsnotify v1.9.0 github.com/mark3labs/mcp-go v0.29.0 github.com/mitchellh/go-homedir v1.1.0 github.com/pelletier/go-toml/v2 v2.2.4 + github.com/sourcegraph/go-diff-patch v0.0.0-20240223163233-798fd1e94a8e github.com/spf13/cobra v1.9.1 github.com/stretchr/testify v1.10.0 github.com/tiborvass/go-watch v0.0.0-20250607214558-08999a83bf8b @@ -39,6 +41,7 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lmittmann/tint v1.1.2 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect diff --git a/go.sum b/go.sum index 44d4f368..73de34c4 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -dagger.io/dagger v0.18.11 h1:6lSfemlbGM2HmdOjhgevrX2+orMDGKU/xTaBMZ+otyY= -dagger.io/dagger v0.18.11/go.mod h1:azlZ24m2br95t0jQHUBpL5SiafeqtVDLl1Itlq6GO+4= dagger.io/dagger v0.18.12 h1:s7v8aHlzDUogZ/jW92lHC+gljCNRML+0mosfh13R4vs= dagger.io/dagger v0.18.12/go.mod h1:azlZ24m2br95t0jQHUBpL5SiafeqtVDLl1Itlq6GO+4= github.com/99designs/gqlgen v0.17.75 h1:GwHJsptXWLHeY7JO8b7YueUI4w9Pom6wJTICosDtQuI= @@ -48,6 +46,8 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -70,6 +70,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w= +github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mark3labs/mcp-go v0.29.0 h1:sH1NBcumKskhxqYzhXfGc201D7P76TVXiT0fGVhabeI= diff --git a/repository/git.go b/repository/git.go index b3793cae..6aa7f3c6 100644 --- a/repository/git.go +++ b/repository/git.go @@ -32,9 +32,9 @@ var ( // RunGitCommand executes a git command in the specified directory. // This is exported for use in tests and other packages that need direct git access. func RunGitCommand(ctx context.Context, dir string, args ...string) (out string, rerr error) { - slog.Info(fmt.Sprintf("[%s] $ git %s", dir, strings.Join(args, " "))) + slog.Debug(fmt.Sprintf("[%s] $ git %s", dir, strings.Join(args, " "))) defer func() { - slog.Info(fmt.Sprintf("[%s] $ git %s (DONE)", dir, strings.Join(args, " ")), "err", rerr) + slog.Debug(fmt.Sprintf("[%s] $ git %s (DONE)", dir, strings.Join(args, " ")), "err", rerr) }() cmd := exec.CommandContext(ctx, "git", args...) @@ -55,9 +55,9 @@ func RunGitCommand(ctx context.Context, dir string, args ...string) (out string, // RunInteractiveGitCommand executes a git command in the specified directory in interactive mode. func RunInteractiveGitCommand(ctx context.Context, dir string, w io.Writer, args ...string) (rerr error) { - slog.Info(fmt.Sprintf("[%s] $ git %s", dir, strings.Join(args, " "))) + slog.Debug(fmt.Sprintf("[%s] $ git %s", dir, strings.Join(args, " "))) defer func() { - slog.Info(fmt.Sprintf("[%s] $ git %s (DONE)", dir, strings.Join(args, " ")), "err", rerr) + slog.Debug(fmt.Sprintf("[%s] $ git %s (DONE)", dir, strings.Join(args, " ")), "err", rerr) }() cmd := exec.CommandContext(ctx, "git", args...)