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
197 changes: 197 additions & 0 deletions cmd/container-use/follow.go
Original file line number Diff line number Diff line change
@@ -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 <env>",
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)
}
58 changes: 47 additions & 11 deletions cmd/container-use/logger.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import (
"log/slog"
"os"
"time"

"github.com/lmittmann/tint"
"golang.org/x/term"
)

var (
Expand All @@ -30,26 +33,59 @@ 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))
}

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
Expand Down
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 4 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down Expand Up @@ -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=
Expand All @@ -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=
Expand Down
8 changes: 4 additions & 4 deletions repository/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...)
Expand All @@ -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...)
Expand Down
Loading