diff --git a/cmd/cu/checkout.go b/cmd/cu/checkout.go index b5dac2fc..7ceb3a5c 100644 --- a/cmd/cu/checkout.go +++ b/cmd/cu/checkout.go @@ -8,8 +8,8 @@ import ( ) var checkoutCmd = &cobra.Command{ - Use: "checkout ", - Short: "Switch to an environment's branch locally", + Use: "checkout ", + Short: "Switch to an environment's branch locally", Long: `Bring an environment's work into your local git workspace. This creates a local branch from the environment's state so you can explore files in your IDE, make changes, or continue development.`, @@ -48,4 +48,4 @@ cu checkout fancy-mallard -b my-review-branch`, func init() { checkoutCmd.Flags().StringP("branch", "b", "", "Local branch name to use") rootCmd.AddCommand(checkoutCmd) -} \ No newline at end of file +} diff --git a/cmd/cu/delete.go b/cmd/cu/delete.go index 0d987173..a54aeb3c 100644 --- a/cmd/cu/delete.go +++ b/cmd/cu/delete.go @@ -8,8 +8,8 @@ import ( ) var deleteCmd = &cobra.Command{ - Use: "delete [...]", - Short: "Delete environments and start fresh", + Use: "delete [...]", + Short: "Delete environments and start fresh", Long: `Delete one or more environments and their associated resources. This permanently removes the environment's branch and container state. Use this when starting over with a different approach. @@ -80,4 +80,4 @@ cu delete --all`, func init() { rootCmd.AddCommand(deleteCmd) deleteCmd.Flags().Bool("all", false, "Delete all environments") -} \ No newline at end of file +} diff --git a/cmd/cu/diff.go b/cmd/cu/diff.go index 99500c01..e0b39462 100644 --- a/cmd/cu/diff.go +++ b/cmd/cu/diff.go @@ -8,17 +8,21 @@ import ( ) var diffCmd = &cobra.Command{ - Use: "diff ", - Short: "Show what files an agent changed", + Use: "diff ", + Short: "Show what files an agent changed", Long: `Display the code changes made by an agent in an environment. -Shows a git diff between the environment's state and your current branch.`, +Shows a git diff of all changes made since the environment was created. +Use -b to compare against a specific branch instead of showing full diff.`, Args: cobra.ExactArgs(1), ValidArgsFunction: suggestEnvironments, - Example: `# See what changes the agent made + Example: `# See what changes the agent made (full diff) cu diff fancy-mallard +# Compare against main branch +cu diff fancy-mallard -b main + # Quick assessment before merging -cu diff backend-api`, +cu diff backend-api -b main`, RunE: func(app *cobra.Command, args []string) error { ctx := app.Context() @@ -28,10 +32,13 @@ cu diff backend-api`, return err } - return repo.Diff(ctx, args[0], os.Stdout) + branch, _ := app.Flags().GetString("branch") + + return repo.Diff(ctx, args[0], branch, os.Stdout) }, } func init() { + diffCmd.Flags().StringP("branch", "b", "", "Compare against specified branch (uses merge-base)") rootCmd.AddCommand(diffCmd) } \ No newline at end of file diff --git a/cmd/cu/list.go b/cmd/cu/list.go index 34cad09a..896cbbb5 100644 --- a/cmd/cu/list.go +++ b/cmd/cu/list.go @@ -57,4 +57,4 @@ func init() { listCmd.Flags().BoolP("quiet", "q", false, "Display only environment IDs") listCmd.Flags().BoolP("no-trunc", "", false, "Don't truncate output") rootCmd.AddCommand(listCmd) -} \ No newline at end of file +} diff --git a/cmd/cu/log.go b/cmd/cu/log.go index 4fadf8a3..c4e14337 100644 --- a/cmd/cu/log.go +++ b/cmd/cu/log.go @@ -8,18 +8,25 @@ import ( ) var logCmd = &cobra.Command{ - Use: "log ", - Short: "View what an agent did step-by-step", + Use: "log ", + Short: "View what an agent did step-by-step", Long: `Display the complete development history for an environment. Shows all commits made by the agent plus command execution notes. -Use -p to include code patches in the output.`, +Use -p to include code patches in the output. +Use -b to compare against a specific branch instead of showing full history.`, Args: cobra.ExactArgs(1), ValidArgsFunction: suggestEnvironments, - Example: `# See what agent did + Example: `# See what agent did (full history) cu log fancy-mallard # Include code changes -cu log fancy-mallard -p`, +cu log fancy-mallard -p + +# Compare against main branch +cu log fancy-mallard -b main + +# Compare against main with patches +cu log fancy-mallard -b main -p`, RunE: func(app *cobra.Command, args []string) error { ctx := app.Context() @@ -30,12 +37,14 @@ cu log fancy-mallard -p`, } patch, _ := app.Flags().GetBool("patch") + branch, _ := app.Flags().GetString("branch") - return repo.Log(ctx, args[0], patch, os.Stdout) + return repo.Log(ctx, args[0], patch, branch, os.Stdout) }, } func init() { logCmd.Flags().BoolP("patch", "p", false, "Generate patch") + logCmd.Flags().StringP("branch", "b", "", "Compare against specified branch (uses merge-base)") rootCmd.AddCommand(logCmd) } \ No newline at end of file diff --git a/cmd/cu/watch.go b/cmd/cu/watch.go index 2207ae82..fa9d5096 100644 --- a/cmd/cu/watch.go +++ b/cmd/cu/watch.go @@ -35,4 +35,4 @@ cu watch`, func init() { rootCmd.AddCommand(watchCmd) -} \ No newline at end of file +} diff --git a/environment/integration/repository_test.go b/environment/integration/repository_test.go index 9fef47f9..8418a15f 100644 --- a/environment/integration/repository_test.go +++ b/environment/integration/repository_test.go @@ -142,7 +142,7 @@ func TestRepositoryLog(t *testing.T) { // Get commit log without patches var logBuf bytes.Buffer - err := repo.Log(ctx, env.ID, false, &logBuf) + err := repo.Log(ctx, env.ID, false, "", &logBuf) logOutput := logBuf.String() require.NoError(t, err, logOutput) @@ -153,7 +153,7 @@ func TestRepositoryLog(t *testing.T) { // Get commit log with patches logBuf.Reset() - err = repo.Log(ctx, env.ID, true, &logBuf) + err = repo.Log(ctx, env.ID, true, "", &logBuf) logWithPatchOutput := logBuf.String() require.NoError(t, err, logWithPatchOutput) @@ -162,7 +162,7 @@ func TestRepositoryLog(t *testing.T) { assert.Contains(t, logWithPatchOutput, "+updated content") // Test log for non-existent environment - err = repo.Log(ctx, "non-existent-env", false, &logBuf) + err = repo.Log(ctx, "non-existent-env", false, "", &logBuf) assert.Error(t, err) }) } @@ -184,7 +184,7 @@ func TestRepositoryDiff(t *testing.T) { // Get diff output var diffBuf bytes.Buffer - err := repo.Diff(ctx, env.ID, &diffBuf) + err := repo.Diff(ctx, env.ID, "", &diffBuf) diffOutput := diffBuf.String() require.NoError(t, err, diffOutput) @@ -192,7 +192,7 @@ func TestRepositoryDiff(t *testing.T) { assert.Contains(t, diffOutput, "+updated content") // Test diff with non-existent environment - err = repo.Diff(ctx, "non-existent-env", &diffBuf) + err = repo.Diff(ctx, "non-existent-env", "", &diffBuf) assert.Error(t, err) }) } diff --git a/mcpserver/tools.go b/mcpserver/tools.go index 6142cda2..b679e495 100644 --- a/mcpserver/tools.go +++ b/mcpserver/tools.go @@ -1,6 +1,7 @@ package mcpserver import ( + "bytes" "context" _ "embed" "encoding/json" @@ -115,6 +116,8 @@ func init() { EnvironmentAddServiceTool, EnvironmentCheckpointTool, + EnvironmentLogTool, + EnvironmentDiffTool, ) } @@ -816,3 +819,80 @@ Supported schemas are: return mcp.NewToolResultText(fmt.Sprintf("Service added and started successfully: %s", string(output))), nil }, } + +var EnvironmentLogTool = &Tool{ + Definition: mcp.NewTool("environment_log", + mcp.WithDescription("View the development history of an environment, showing all commits made by the agent plus command execution notes."), + mcp.WithString("explanation", + mcp.Description("One sentence explanation for why this environment log is being viewed."), + ), + mcp.WithString("environment_source", + mcp.Description("Absolute path to the source git repository for the environment."), + mcp.Required(), + ), + mcp.WithString("environment_id", + mcp.Description("The ID of the environment to view the log for."), + mcp.Required(), + ), + mcp.WithBoolean("patch", + mcp.Description("Include code patches in the output (default: false)."), + ), + ), + Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + repo, err := openRepository(ctx, request) + if err != nil { + return mcp.NewToolResultErrorFromErr("unable to open the repository", err), nil + } + + envID, err := request.RequireString("environment_id") + if err != nil { + return nil, err + } + + patch := request.GetBool("patch", false) + + var buf bytes.Buffer + // MCP tools always show full history (no branch comparison) + if err := repo.Log(ctx, envID, patch, "", &buf); err != nil { + return mcp.NewToolResultErrorFromErr("failed to get environment log", err), nil + } + + return mcp.NewToolResultText(buf.String()), nil + }, +} + +var EnvironmentDiffTool = &Tool{ + Definition: mcp.NewTool("environment_diff", + mcp.WithDescription("View the cumulative changes made in an environment from its creation point, showing all code modifications as a unified diff."), + mcp.WithString("explanation", + mcp.Description("One sentence explanation for why this environment diff is being viewed."), + ), + mcp.WithString("environment_source", + mcp.Description("Absolute path to the source git repository for the environment."), + mcp.Required(), + ), + mcp.WithString("environment_id", + mcp.Description("The ID of the environment to view the diff for."), + mcp.Required(), + ), + ), + Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + repo, err := openRepository(ctx, request) + if err != nil { + return mcp.NewToolResultErrorFromErr("unable to open the repository", err), nil + } + + envID, err := request.RequireString("environment_id") + if err != nil { + return nil, err + } + + var buf bytes.Buffer + // MCP tools always show full diff (no branch comparison) + if err := repo.Diff(ctx, envID, "", &buf); err != nil { + return mcp.NewToolResultErrorFromErr("failed to get environment diff", err), nil + } + + return mcp.NewToolResultText(buf.String()), nil + }, +} \ No newline at end of file diff --git a/repository/git.go b/repository/git.go index 9f6aa52c..343cf583 100644 --- a/repository/git.go +++ b/repository/git.go @@ -239,26 +239,41 @@ func (r *Repository) currentUserBranch(ctx context.Context) (string, error) { return RunGitCommand(ctx, r.userRepoPath, "branch", "--show-current") } -func (r *Repository) mergeBase(ctx context.Context, env *environment.EnvironmentInfo) (string, error) { - currentBranch, err := r.currentUserBranch(ctx) - if err != nil { - return "", err - } - currentBranch = strings.TrimSpace(currentBranch) +func (r *Repository) mergeBase(ctx context.Context, env *environment.EnvironmentInfo, branch string) (string, error) { envGitRef := fmt.Sprintf("%s/%s", containerUseRemote, env.ID) - mergeBase, err := RunGitCommand(ctx, r.userRepoPath, "merge-base", currentBranch, envGitRef) + mergeBase, err := RunGitCommand(ctx, r.userRepoPath, "merge-base", branch, envGitRef) if err != nil { return "", err } return strings.TrimSpace(mergeBase), nil } -func (r *Repository) revisionRange(ctx context.Context, env *environment.EnvironmentInfo) (string, error) { - mergeBase, err := r.mergeBase(ctx, env) +// revisionRange determines the git revision range for log/diff operations. +// If branch is provided, uses merge-base with that branch. +// Otherwise, uses merge-base with the current user branch. +func (r *Repository) revisionRange(ctx context.Context, env *environment.EnvironmentInfo, branch string) (string, error) { + envGitRef := fmt.Sprintf("%s/%s", containerUseRemote, env.ID) + + if branch != "" { + // Use merge-base with specified branch + mergeBase, err := r.mergeBase(ctx, env, branch) + if err != nil { + return "", err + } + return fmt.Sprintf("%s..%s", mergeBase, envGitRef), nil + } + + // Use merge-base with current user branch (original behavior) + currentBranch, err := r.currentUserBranch(ctx) + if err != nil { + return "", err + } + currentBranch = strings.TrimSpace(currentBranch) + + mergeBase, err := r.mergeBase(ctx, env, currentBranch) if err != nil { return "", err } - envGitRef := fmt.Sprintf("%s/%s", containerUseRemote, env.ID) return fmt.Sprintf("%s..%s", mergeBase, envGitRef), nil } @@ -582,4 +597,4 @@ func matchesScpLike(url string) bool { func findScpLikeComponents(url string) (user, host, port, path string) { m := scpLikeURLRegExp.FindStringSubmatch(url) return m[1], m[2], m[3], m[4] -} +} \ No newline at end of file diff --git a/repository/repository.go b/repository/repository.go index 695d0912..be9b3202 100644 --- a/repository/repository.go +++ b/repository/repository.go @@ -357,7 +357,10 @@ func (r *Repository) Checkout(ctx context.Context, id, branch string) (string, e return branch, err } -func (r *Repository) Log(ctx context.Context, id string, patch bool, w io.Writer) error { +// Log displays the development history for an environment. +// If branch is provided, shows log since merge-base with that branch. +// Otherwise, shows log from environment creation. +func (r *Repository) Log(ctx context.Context, id string, patch bool, branch string, w io.Writer) error { envInfo, err := r.Info(ctx, id) if err != nil { return err @@ -375,7 +378,7 @@ func (r *Repository) Log(ctx context.Context, id string, patch bool, w io.Writer logArgs = append(logArgs, "--format=%C(yellow)%h%Creset %s %Cgreen(%cr)%Creset %+N") } - revisionRange, err := r.revisionRange(ctx, envInfo) + revisionRange, err := r.revisionRange(ctx, envInfo, branch) if err != nil { return err } @@ -391,7 +394,10 @@ func (r *Repository) Log(ctx context.Context, id string, patch bool, w io.Writer return cmd.Run() } -func (r *Repository) Diff(ctx context.Context, id string, w io.Writer) error { +// Diff shows changes made in an environment. +// If branch is provided, shows diff since merge-base with that branch. +// Otherwise, shows diff from environment creation. +func (r *Repository) Diff(ctx context.Context, id string, branch string, w io.Writer) error { envInfo, err := r.Info(ctx, id) if err != nil { return err @@ -402,7 +408,7 @@ func (r *Repository) Diff(ctx context.Context, id string, w io.Writer) error { "diff", } - revisionRange, err := r.revisionRange(ctx, envInfo) + revisionRange, err := r.revisionRange(ctx, envInfo, branch) if err != nil { return err } @@ -416,4 +422,4 @@ func (r *Repository) Diff(ctx context.Context, id string, w io.Writer) error { cmd.Stderr = w return cmd.Run() -} +} \ No newline at end of file diff --git a/repository/repository_test.go b/repository/repository_test.go index 4a7b0577..2d2924b4 100644 --- a/repository/repository_test.go +++ b/repository/repository_test.go @@ -14,38 +14,38 @@ import ( // TestRepositoryOpen tests the Open function which initializes a Repository func TestRepositoryOpen(t *testing.T) { ctx := context.Background() - + t.Run("not_a_git_repository", func(t *testing.T) { tempDir := t.TempDir() _, err := Open(ctx, tempDir) assert.Error(t, err) assert.Contains(t, err.Error(), "you must be in a git repository") }) - + t.Run("valid_git_repository", func(t *testing.T) { tempDir := t.TempDir() configDir := t.TempDir() // Separate dir for container-use config - + // Initialize a git repo _, err := RunGitCommand(ctx, tempDir, "init") require.NoError(t, err) - + // Set git config _, err = RunGitCommand(ctx, tempDir, "config", "user.email", "test@example.com") require.NoError(t, err) _, err = RunGitCommand(ctx, tempDir, "config", "user.name", "Test User") require.NoError(t, err) - + // Make initial commit testFile := filepath.Join(tempDir, "README.md") err = os.WriteFile(testFile, []byte("# Test"), 0644) require.NoError(t, err) - + _, err = RunGitCommand(ctx, tempDir, "add", ".") require.NoError(t, err) _, err = RunGitCommand(ctx, tempDir, "commit", "-m", "Initial commit") require.NoError(t, err) - + // Open repository with isolated base path repo, err := OpenWithBasePath(ctx, tempDir, configDir) require.NoError(t, err) @@ -55,16 +55,14 @@ func TestRepositoryOpen(t *testing.T) { assert.NotEmpty(t, repo.userRepoPath) assert.DirExists(t, repo.userRepoPath) assert.NotEmpty(t, repo.forkRepoPath) - + // Verify fork was created _, err = os.Stat(repo.forkRepoPath) assert.NoError(t, err) - + // Verify remote was added remote, err := RunGitCommand(ctx, tempDir, "remote", "get-url", "container-use") require.NoError(t, err) assert.Equal(t, repo.forkRepoPath, strings.TrimSpace(remote)) }) } - -