Skip to content
Draft
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
2 changes: 1 addition & 1 deletion cmd/container-use/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ git commit -m "Add backend API implementation"`,

env := args[0]

if err := repo.Apply(ctx, env, os.Stdout); err != nil {
if err := repo.Apply(ctx, env, os.Stdout, false); err != nil {
return fmt.Errorf("failed to apply environment: %w", err)
}

Expand Down
4 changes: 2 additions & 2 deletions cmd/container-use/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,11 @@ Use -q for environment IDs only, useful for scripting.`,
}

tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tTITLE\tCREATED\tUPDATED")
fmt.Fprintln(tw, "ID\tBRANCH\tTITLE\tCREATED\tUPDATED\tEPHEMERAL")

defer tw.Flush()
for _, envInfo := range envInfos {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", envInfo.ID, truncate(app, envInfo.State.Title, 40), humanize.Time(envInfo.State.CreatedAt), humanize.Time(envInfo.State.UpdatedAt))
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%v\n", envInfo.ID, envInfo.State.TrackingBranch, truncate(app, envInfo.State.Title, 40), humanize.Time(envInfo.State.CreatedAt), humanize.Time(envInfo.State.UpdatedAt), envInfo.State.Ephemeral)
}
return nil
},
Expand Down
12 changes: 7 additions & 5 deletions environment/environment.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,17 @@ type Environment struct {
mu sync.RWMutex
}

func New(ctx context.Context, dag *dagger.Client, id, title string, config *EnvironmentConfig, initialSourceDir *dagger.Directory) (*Environment, error) {
func New(ctx context.Context, dag *dagger.Client, id, branch, title string, ephemeral bool, config *EnvironmentConfig, initialSourceDir *dagger.Directory) (*Environment, error) {
env := &Environment{
EnvironmentInfo: &EnvironmentInfo{
ID: id,
State: &State{
Config: config,
Title: title,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Config: config,
Title: title,
TrackingBranch: branch,
Ephemeral: ephemeral,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
},
dag: dag,
Expand Down
120 changes: 120 additions & 0 deletions environment/filesystem.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ package environment

import (
"context"
"crypto/sha256"
"fmt"
"strings"

godiffpatch "github.com/sourcegraph/go-diff-patch"
)

func (env *Environment) FileRead(ctx context.Context, targetFile string, shouldReadEntireFile bool, startLineOneIndexedInclusive int, endLineOneIndexedInclusive int) (string, error) {
Expand Down Expand Up @@ -45,6 +48,85 @@ func (env *Environment) FileWrite(ctx context.Context, explanation, targetFile,
return nil
}

func (env *Environment) FileSearchReplace(ctx context.Context, explanation, targetFile, search, replace, matchID string) error {
contents, err := env.container().File(targetFile).Contents(ctx)
if err != nil {
return err
}

// Find all matches of the search text
matches := []int{}
searchIndex := 0
for {
index := strings.Index(contents[searchIndex:], search)
if index == -1 {
break
}
actualIndex := searchIndex + index
matches = append(matches, actualIndex)
searchIndex = actualIndex + 1
}

if len(matches) == 0 {
return fmt.Errorf("search text not found in file %s", targetFile)
}

// If there are multiple matches and no matchID is provided, return an error with all matches
if len(matches) > 1 && matchID == "" {
var matchDescriptions []string
for i, matchIndex := range matches {
// Generate a unique ID for each match
id := generateMatchID(targetFile, search, replace, i)

// Get context around the match (3 lines before and after)
context := getMatchContext(contents, matchIndex, len(search))

matchDescriptions = append(matchDescriptions, fmt.Sprintf("Match %d (ID: %s):\n%s", i+1, id, context))
}

return fmt.Errorf("multiple matches found for search text in %s. Please specify which_match parameter with one of the following IDs:\n\n%s",
targetFile, strings.Join(matchDescriptions, "\n\n"))
}

// Determine which match to replace
var targetMatchIndex int
if len(matches) == 1 {
targetMatchIndex = matches[0]
} else {
// Find the match with the specified ID
found := false
for i, matchIndex := range matches {
id := generateMatchID(targetFile, search, replace, i)
if id == matchID {
targetMatchIndex = matchIndex
found = true
break
}
}
if !found {
return fmt.Errorf("match ID %s not found", matchID)
}
}

// Replace the specific match
newContents := contents[:targetMatchIndex] + replace + contents[targetMatchIndex+len(search):]

// Apply the changes using `patch` so we don't have to spit out the entire
// contents
return env.ApplyPatch(ctx, godiffpatch.GeneratePatch(targetFile, contents, newContents))
}

func (env *Environment) ApplyPatch(ctx context.Context, patch string) error {
ctr := env.container()
err := env.apply(ctx, ctr.
WithDirectory(".", ctr.Directory(".").WithPatch(patch)))
if err != nil {
return fmt.Errorf("failed applying file edit, skipping git propagation: %w", err)
}
env.Notes.Add("Apply patch")
return nil
}

func (env *Environment) FileDelete(ctx context.Context, explanation, targetFile string) error {
err := env.apply(ctx, env.container().WithoutFile(targetFile))
if err != nil {
Expand All @@ -65,3 +147,41 @@ func (env *Environment) FileList(ctx context.Context, path string) (string, erro
}
return out.String(), nil
}

// generateMatchID creates a unique ID for a match based on file, search, replace, and index
func generateMatchID(targetFile, search, replace string, index int) string {
data := fmt.Sprintf("%s:%s:%s:%d", targetFile, search, replace, index)
hash := sha256.Sum256([]byte(data))
return fmt.Sprintf("%x", hash)[:8] // Use first 8 characters of hash
}

// getMatchContext returns the context around a match (3 lines before and after)
func getMatchContext(contents string, matchIndex, matchLength int) string {
lines := strings.Split(contents, "\n")

// Find which line contains the match
currentPos := 0
matchLine := 0
for i, line := range lines {
if currentPos+len(line) >= matchIndex {
matchLine = i
break
}
currentPos += len(line) + 1 // +1 for newline
}

// Get context lines (3 before, match line, 3 after)
start := max(0, matchLine-3)
end := min(len(lines), matchLine+4)

contextLines := make([]string, 0, end-start)
for i := start; i < end; i++ {
prefix := " "
if i == matchLine {
prefix = "> " // Mark the line containing the match
}
contextLines = append(contextLines, fmt.Sprintf("%s%s", prefix, lines[i]))
}

return strings.Join(contextLines, "\n")
}
6 changes: 3 additions & 3 deletions environment/integration/merge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ func TestRepositoryApply(t *testing.T) {

// Apply the environment (squash merge)
var applyOutput bytes.Buffer
err = repo.Apply(ctx, env.ID, &applyOutput)
err = repo.Apply(ctx, env.ID, &applyOutput, true)
require.NoError(t, err, "Apply should succeed: %s", applyOutput.String())

// Verify we're still on the initial branch
Expand Down Expand Up @@ -146,7 +146,7 @@ func TestRepositoryApplyNonExistent(t *testing.T) {

// Try to apply non-existent environment
var applyOutput bytes.Buffer
err := repo.Apply(ctx, "non-existent-env", &applyOutput)
err := repo.Apply(ctx, "non-existent-env", &applyOutput, true)
assert.Error(t, err, "Applying non-existent environment should fail")
assert.Contains(t, err.Error(), "not found")
})
Expand Down Expand Up @@ -203,7 +203,7 @@ func TestRepositoryApplyWithConflicts(t *testing.T) {

// Try to apply - this should fail due to conflict
var applyOutput bytes.Buffer
err = repo.Apply(ctx, env.ID, &applyOutput)
err = repo.Apply(ctx, env.ID, &applyOutput, true)

// The apply should fail due to conflict
assert.Error(t, err, "Apply should fail due to conflict")
Expand Down
8 changes: 5 additions & 3 deletions environment/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ type State struct {
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`

Config *EnvironmentConfig `json:"config,omitempty"`
Container string `json:"container,omitempty"`
Title string `json:"title,omitempty"`
Config *EnvironmentConfig `json:"config,omitempty"`
Container string `json:"container,omitempty"`
Title string `json:"title,omitempty"`
TrackingBranch string `json:"tracking_branch,omitempty"`
Ephemeral bool `json:"ephemeral,omitempty"`
}

func (s *State) Marshal() ([]byte, error) {
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ go 1.24.3
toolchain go1.24.4

require (
dagger.io/dagger v0.18.12
dagger.io/dagger v0.18.14
github.com/charmbracelet/bubbletea v1.3.5
github.com/charmbracelet/fang v0.3.0
github.com/charmbracelet/lipgloss v1.1.0
Expand All @@ -14,6 +14,7 @@ require (
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 Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
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=
dagger.io/dagger v0.18.14 h1:7+VFqNJffm6Qa8ckNRMfsM64sI5dXbRnZswCQ1jnDF0=
dagger.io/dagger v0.18.14/go.mod h1:azlZ24m2br95t0jQHUBpL5SiafeqtVDLl1Itlq6GO+4=
github.com/99designs/gqlgen v0.17.75 h1:GwHJsptXWLHeY7JO8b7YueUI4w9Pom6wJTICosDtQuI=
github.com/99designs/gqlgen v0.17.75/go.mod h1:p7gbTpdnHyl70hmSpM8XG8GiKwmCv+T5zkdY8U8bLog=
github.com/Khan/genqlient v0.8.1 h1:wtOCc8N9rNynRLXN3k3CnfzheCUNKBcvXmVv5zt6WCs=
Expand Down Expand Up @@ -112,6 +110,8 @@ github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4=
github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
github.com/sourcegraph/go-diff-patch v0.0.0-20240223163233-798fd1e94a8e h1:H+jDTUeF+SVd4ApwnSFoew8ZwGNRfgb9EsZc7LcocAg=
github.com/sourcegraph/go-diff-patch v0.0.0-20240223163233-798fd1e94a8e/go.mod h1:VsUklG6OQo7Ctunu0gS3AtEOCEc2kMB6r5rKzxAes58=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
Expand Down
Loading