Skip to content

Commit 5f72b3d

Browse files
committed
support syncing changes from user
* new `environment_sync_from_user` tool: applies user's unstaged changes to the env, commits, syncs the env back to the user (bidirectional) * `git reset --hard` is now done after creating a refless stash, so the user can bring everything back if something goes wrong * add `Environment.ApplyPatch`, which currently depends on `patch` being available in the environment * clean up all the git code that wasn't using helpers Signed-off-by: Alex Suraci <suraci.alex@gmail.com>
1 parent 45037f6 commit 5f72b3d

File tree

3 files changed

+89
-69
lines changed

3 files changed

+89
-69
lines changed

environment/filesystem.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,14 +114,18 @@ func (env *Environment) FileSearchReplace(ctx context.Context, explanation, targ
114114

115115
// Apply the changes using `patch` so we don't have to spit out the entire
116116
// contents
117-
err = env.apply(ctx, env.container().
117+
return env.ApplyPatch(ctx, godiffpatch.GeneratePatch(targetFile, contents, newContents))
118+
}
119+
120+
func (env *Environment) ApplyPatch(ctx context.Context, patch string) error {
121+
err := env.apply(ctx, env.container().
118122
WithExec([]string{"patch", "-p1"}, dagger.ContainerWithExecOpts{
119-
Stdin: godiffpatch.GeneratePatch(targetFile, contents, newContents),
123+
Stdin: patch,
120124
}))
121125
if err != nil {
122126
return fmt.Errorf("failed applying file edit, skipping git propagation: %w", err)
123127
}
124-
env.Notes.Add("Edit %s", targetFile)
128+
env.Notes.Add("Apply patch")
125129
return nil
126130
}
127131

mcpserver/tools.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"log/slog"
1111
"os"
1212
"os/signal"
13+
"strings"
1314
"syscall"
1415

1516
"dagger.io/dagger"
@@ -143,6 +144,8 @@ func init() {
143144
EnvironmentAddServiceTool,
144145

145146
EnvironmentCheckpointTool,
147+
148+
EnvironmentSyncFromUserTool,
146149
)
147150
}
148151

@@ -824,3 +827,44 @@ Supported schemas are:
824827
return mcp.NewToolResultText(fmt.Sprintf("Service added and started successfully: %s", string(output))), nil
825828
},
826829
}
830+
831+
var EnvironmentSyncFromUserTool = &Tool{
832+
Definition: newEnvironmentTool(
833+
"environment_sync_from_user",
834+
"Apply the user's unstaged changes to the environment and apply the environment's to the user's local worktree. ONLY RUN WHEN EXPLICITLY REQUESTED BY THE USER.",
835+
),
836+
Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
837+
repo, env, err := openEnvironment(ctx, request)
838+
if err != nil {
839+
return nil, err
840+
}
841+
842+
// Use a string builder to capture output
843+
patch, err := repo.DiffUserLocalChanges(ctx)
844+
if err != nil {
845+
return nil, fmt.Errorf("failed to generate patch: %w", err)
846+
}
847+
if len(patch) == 0 {
848+
return mcp.NewToolResultText("No unstaged changes to pull."), nil
849+
}
850+
851+
if err := env.ApplyPatch(ctx, patch); err != nil {
852+
return nil, fmt.Errorf("failed to pull changes to environment: %w", err)
853+
}
854+
855+
if err := repo.Update(ctx, env, request.GetString("explanation", "")); err != nil {
856+
return nil, fmt.Errorf("unable to update the environment: %w", err)
857+
}
858+
859+
if err := repo.ResetUserLocalChanges(ctx); err != nil {
860+
return nil, fmt.Errorf("unable to reset user's worktree: %w", err)
861+
}
862+
863+
var buf strings.Builder
864+
if err := repo.Apply(ctx, env.ID, &buf); err != nil {
865+
return nil, fmt.Errorf("unable to apply changes to user's worktree: %w\n\nlogs:\n%s", err, buf.String())
866+
}
867+
868+
return mcp.NewToolResultText("Patch applied successfully to the environment:\n\n```patch\n" + string(patch) + "\n```"), nil
869+
},
870+
}

repository/repository.go

Lines changed: 38 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import (
1111
"path/filepath"
1212
"sort"
1313
"strings"
14-
"time"
1514

1615
"dagger.io/dagger"
1716
"github.com/dagger/container-use/environment"
@@ -415,68 +414,39 @@ func (r *Repository) Merge(ctx context.Context, id string, w io.Writer) error {
415414
return RunInteractiveGitCommand(ctx, r.userRepoPath, w, "merge", "--no-ff", "--autostash", "-m", "Merge environment "+envInfo.ID, "--", "container-use/"+envInfo.ID)
416415
}
417416

418-
func (r *Repository) Apply(ctx context.Context, id string, w io.Writer) error {
417+
func (r *Repository) Apply(ctx context.Context, id string, w io.Writer) (rerr error) {
419418
envInfo, err := r.Info(ctx, id)
420419
if err != nil {
421420
return err
422421
}
423422

424-
// Create patch directory if it doesn't exist
425-
configPath := os.ExpandEnv("$HOME/.config/container-use")
426-
patchDir := filepath.Join(configPath, "patches")
427-
if err := os.MkdirAll(patchDir, 0755); err != nil {
428-
return fmt.Errorf("failed to create patch directory: %w", err)
429-
}
430-
431-
// Create a unique patch filename using timestamp and environment ID
432-
patchFile := filepath.Join(patchDir, fmt.Sprintf("user-changes-%s-%d.patch", envInfo.ID, time.Now().Unix()))
433-
434-
// Check if there are any unstaged changes
435-
diffCmd := exec.CommandContext(ctx, "git", "diff")
436-
diffCmd.Dir = r.userRepoPath
437-
diffOutput, err := diffCmd.Output()
423+
diffOutput, err := r.DiffUserLocalChanges(ctx)
438424
if err != nil {
439425
return fmt.Errorf("failed to check for unstaged changes: %w", err)
440426
}
441427

442428
hasUnstagedChanges := len(diffOutput) > 0
443429

444-
if hasUnstagedChanges {
445-
// Create a patch of only unstaged changes
446-
fmt.Fprintf(w, "Saving unstaged user changes to %s...\n", patchFile)
447-
448-
// Create the patch from unstaged changes only
449-
patchCmd := exec.CommandContext(ctx, "git", "diff")
450-
patchCmd.Dir = r.userRepoPath
451-
patchOutput, err := patchCmd.Output()
452-
if err != nil {
453-
return fmt.Errorf("failed to create patch: %w", err)
454-
}
455-
456-
// Write patch to file
457-
if err := os.WriteFile(patchFile, patchOutput, 0644); err != nil {
458-
return fmt.Errorf("failed to write patch file: %w", err)
430+
fmt.Fprintf(w, "Creating virtual stash as backup...\n")
431+
stashID, err := RunGitCommand(ctx, r.userRepoPath, "stash", "create")
432+
if err != nil {
433+
return fmt.Errorf("failed to stash changes: %w", err)
434+
}
435+
defer func() {
436+
if rerr != nil {
437+
fmt.Fprintf(w, "ERROR: %s\n", rerr)
438+
fmt.Fprintf(w, "Your prior changes can be restored with `git stash apply %s`\n", stashID)
459439
}
440+
}()
460441

461-
// Reset to clean state
462-
fmt.Fprintf(w, "Resetting to clean state...\n")
463-
if err := RunInteractiveGitCommand(ctx, r.userRepoPath, w, "reset", "--hard", "HEAD"); err != nil {
464-
return fmt.Errorf("failed to reset: %w", err)
465-
}
442+
// Reset to clean state
443+
if err := RunInteractiveGitCommand(ctx, r.userRepoPath, w, "reset", "--hard", "HEAD"); err != nil {
444+
return fmt.Errorf("failed to reset: %w", err)
466445
}
467446

468447
// Apply the merge without autostash
469448
fmt.Fprintf(w, "Applying environment changes...\n")
470449
if err := RunInteractiveGitCommand(ctx, r.userRepoPath, w, "merge", "--squash", "--", "container-use/"+envInfo.ID); err != nil {
471-
// If merge fails, try to restore user changes
472-
if hasUnstagedChanges {
473-
fmt.Fprintf(w, "Merge failed, restoring user changes...\n")
474-
applyCmd := exec.CommandContext(ctx, "git", "apply", patchFile)
475-
applyCmd.Dir = r.userRepoPath
476-
applyCmd.Stdout = w
477-
applyCmd.Stderr = w
478-
applyCmd.Run() // Ignore error as patch might partially apply
479-
}
480450
return fmt.Errorf("failed to merge: %w", err)
481451
}
482452

@@ -485,46 +455,48 @@ func (r *Repository) Apply(ctx context.Context, id string, w io.Writer) error {
485455
fmt.Fprintf(w, "Restoring user changes...\n")
486456

487457
// 1. Temporarily commit the agent's changes
488-
commitCmd := exec.CommandContext(ctx, "git", "commit", "-m", "temp: agent changes")
489-
commitCmd.Dir = r.userRepoPath
490-
if err := commitCmd.Run(); err != nil {
491-
fmt.Fprintf(w, "Warning: Failed to commit agent changes: %v\n", err)
492-
return nil
458+
if err := RunInteractiveGitCommand(ctx, r.userRepoPath, w, "commit", "-m", "temp: agent changes"); err != nil {
459+
return fmt.Errorf("failed to commit agent changes: %w", err)
493460
}
494461

495462
// 2. Apply the user's patch
496-
applyCmd := exec.CommandContext(ctx, "git", "apply", patchFile)
463+
applyCmd := exec.CommandContext(ctx, "git", "apply", "-")
497464
applyCmd.Dir = r.userRepoPath
465+
applyCmd.Stdin = strings.NewReader(diffOutput)
498466
applyCmd.Stdout = w
499467
applyCmd.Stderr = w
500468
if err := applyCmd.Run(); err != nil {
501-
fmt.Fprintf(w, "Warning: Failed to apply some user changes. Patch saved at: %s\n", patchFile)
502-
fmt.Fprintf(w, "You can manually apply it with: git apply %s\n", patchFile)
503-
// Try to recover by doing soft reset
504-
resetCmd := exec.CommandContext(ctx, "git", "reset", "--soft", "HEAD~1")
505-
resetCmd.Dir = r.userRepoPath
506-
resetCmd.Run()
507-
return nil
469+
return fmt.Errorf("failed to apply user changes: %w", err)
508470
}
509471

510-
// 3. Reset to unstage everything
511-
resetCmd := exec.CommandContext(ctx, "git", "reset")
512-
resetCmd.Dir = r.userRepoPath
513-
if err := resetCmd.Run(); err != nil {
472+
// 3. Reset to unstage the user's changes
473+
if err := RunInteractiveGitCommand(ctx, r.userRepoPath, w, "reset"); err != nil {
514474
fmt.Fprintf(w, "Warning: Failed to reset: %v\n", err)
515475
}
516476

517477
// 4. Soft reset to bring agent changes back to staging
518-
softResetCmd := exec.CommandContext(ctx, "git", "reset", "--soft", "HEAD~1")
519-
softResetCmd.Dir = r.userRepoPath
520-
if err := softResetCmd.Run(); err != nil {
478+
if err := RunInteractiveGitCommand(ctx, r.userRepoPath, w, "reset", "--soft", "HEAD~1"); err != nil {
521479
fmt.Fprintf(w, "Warning: Failed to restore agent changes to staging: %v\n", err)
522480
}
523481

524482
// Clean up patch file on successful application
525-
os.Remove(patchFile)
526483
fmt.Fprintf(w, "User changes successfully restored as unstaged changes.\n")
527484
}
528485

529486
return nil
530487
}
488+
489+
func (r *Repository) DiffUserLocalChanges(ctx context.Context) (string, error) {
490+
diff, err := RunGitCommand(ctx, r.userRepoPath, "diff")
491+
if err != nil {
492+
return "", fmt.Errorf("failed to get user diff: %w", err)
493+
}
494+
return diff, nil
495+
}
496+
497+
func (r *Repository) ResetUserLocalChanges(ctx context.Context) error {
498+
if _, err := RunGitCommand(ctx, r.userRepoPath, "restore", "."); err != nil {
499+
return fmt.Errorf("failed to reset unstaged changes: %w", err)
500+
}
501+
return nil
502+
}

0 commit comments

Comments
 (0)