Skip to content
Merged
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
26 changes: 15 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,21 +176,25 @@ env:
CLI flags override all configuration files:

#### Container Configuration

- `--image NAME`: Container image name
- `--dockerfile PATH`: Path to Dockerfile for building image
- `--working-dir PATH`: Working directory inside container
- `--network NAME`: Docker network to use
- `--stop-timeout SECONDS`: Container stop timeout

#### TTY Configuration

- `--tty-retries COUNT`: Number of TTY resize retry attempts
- `--retry-delay DURATION`: Delay between retries (e.g., "10ms", "100ms")

#### Git Configuration

- `--git-user-name NAME`: Git user name for commits
- `--git-user-email EMAIL`: Git user email for commits

#### Runtime Configuration

- `--env KEY=VALUE`: Add environment variable (can be used multiple times)
- `--volume HOST:CONTAINER`: Mount volume (can be used multiple times)

Expand All @@ -210,10 +214,10 @@ Both `env` and `volumes` sections in configuration files support environment var
env:
# Simple expansion
HOME_PATH: $HOME

# Braced expansion
USER_DIR: ${HOME}/${USER}

# Use in volumes
volumes:
- $HOME/.ssh:/root/.ssh
Expand Down Expand Up @@ -260,15 +264,15 @@ contagent --volume ./data:/data --volume $HOME/.cache:/root/.cache /bin/bash

If no configuration is provided, these defaults are used:

| Setting | Default Value |
|---------|---------------|
| `image` | `contagent:latest` |
| `working_dir` | `/app` |
| `network` | `default` |
| `stop_timeout` | `10` seconds |
| `tty_retries` | `10` |
| `retry_delay` | `10ms` |
| `git.user.name` | `Contagent` |
| Setting | Default Value |
| ---------------- | ----------------------- |
| `image` | `contagent:latest` |
| `working_dir` | `/app` |
| `network` | `default` |
| `stop_timeout` | `10` seconds |
| `tty_retries` | `10` |
| `retry_delay` | `10ms` |
| `git.user.name` | `Contagent` |
| `git.user.email` | `contagent@example.com` |

### Configuration Priority Example
Expand Down
24 changes: 14 additions & 10 deletions internal/git/archive.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@ type ArchiveOptions struct {
DestDir string
}

// FindRoot returns the root directory of the git repository containing path,
// by running "git rev-parse --show-toplevel". Returns an error if path is not
// inside a git repository.
func FindRoot(path string) (string, error) {
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
cmd.Dir = path
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to get git root path from %q: %w\nEnsure you're in a git repository", path, err)
}
return strings.TrimSpace(string(output)), nil
}

// CreateArchive creates a tar archive of the Git repository at the specified path, configured
// with the given remote URL and branch name. It checks out HEAD into a temporary directory,
// configures the remote, creates a new branch, and archives the .git directory and all tracked
Expand All @@ -44,15 +57,6 @@ type ArchiveOptions struct {
// resources. Returns an error if the Git root cannot be determined, the temporary directory
// cannot be created, .git copying fails, git operations fail, or archive creation fails.
func CreateArchive(opts ArchiveOptions, w internal.Writer) (io.ReadCloser, error) {
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
cmd.Dir = opts.Path
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("failed to get git root path from %q: %w\nEnsure you're in a git repository", opts.Path, err)
}

root := strings.TrimSpace(string(output))

tempDir, err := os.MkdirTemp("", "contagent-checkout-*")
if err != nil {
return nil, fmt.Errorf("failed to create temporary directory: %w\nCheck disk space and /tmp permissions", err)
Expand All @@ -63,7 +67,7 @@ func CreateArchive(opts ArchiveOptions, w internal.Writer) (io.ReadCloser, error
go func() {
tw := tar.NewWriter(pw)

err := buildArchive(tw, opts, root, tempDir)
err := buildArchive(tw, opts, opts.Path, tempDir)
if err != nil {
pw.CloseWithError(fmt.Errorf("failed to create git archive: %w", err))
} else {
Expand Down
14 changes: 10 additions & 4 deletions internal/git/archive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,10 @@ func TestCreateArchive(t *testing.T) {
require.NoError(t, err)
defer os.RemoveAll(dir)

_, err = git.CreateArchive(git.ArchiveOptions{
// CreateArchive no longer calls FindRoot; callers must pass the git root.
// The error is deferred: CreateArchive returns a reader, but reading fails
// when buildArchive cannot find the .git directory.
reader, err := git.CreateArchive(git.ArchiveOptions{
Path: dir,
Remote: "http://example.com",
Branch: "branch",
Expand All @@ -169,7 +172,10 @@ func TestCreateArchive(t *testing.T) {
GID: 0,
DestDir: "",
}, internal.NewStandardWriter())
require.ErrorContains(t, err, "failed to get git root path")
require.NoError(t, err)
defer reader.Close()
_, err = io.ReadAll(reader)
require.ErrorContains(t, err, "failed to copy .git directory")
})

t.Run("handles repository with no initial remote", func(t *testing.T) {
Expand Down Expand Up @@ -316,9 +322,9 @@ func TestCreateArchive(t *testing.T) {
)
require.NoError(t, cmd.Run())

// Create archive from subdirectory
// CreateArchive requires the git root (callers resolve it via FindRoot first).
reader, err := git.CreateArchive(git.ArchiveOptions{
Path: subDir,
Path: dir,
Remote: "http://example.com",
Branch: "branch",
GitUserName: "user",
Expand Down
41 changes: 29 additions & 12 deletions internal/git/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,20 @@ func TestGitServerErrorCases(t *testing.T) {
require.NoError(t, err)
defer os.RemoveAll(dir)

_, err = git.NewServer(dir, internal.NewStandardWriter())
require.Error(t, err)
require.Contains(t, err.Error(), "not a git repository")
// NewServer no longer validates that the path is a git repo;
// callers (e.g. main.go) are expected to resolve the git root first.
server, err := git.NewServer(dir, internal.NewStandardWriter())
require.NoError(t, err)
server.Close()
})

t.Run("non-existent directory", func(t *testing.T) {
_, err := git.NewServer("/nonexistent/path/to/repo", internal.NewStandardWriter())
require.Error(t, err)
// Error could be either "not a git repository" or path resolution error
require.Error(t, err)
// NewServer no longer validates path existence upfront.
// The server will start but fail when handling requests.
server, err := git.NewServer("/nonexistent/path/to/repo", internal.NewStandardWriter())
if err == nil {
server.Close()
}
})

t.Run("relative path resolution", func(t *testing.T) {
Expand Down Expand Up @@ -94,7 +98,9 @@ func TestGitArchiveErrorCases(t *testing.T) {
require.NoError(t, err)
defer os.RemoveAll(dir)

_, err = git.CreateArchive(git.ArchiveOptions{
// CreateArchive no longer calls FindRoot; callers must pass the git root.
// The error is deferred to read time when buildArchive fails to find .git.
reader, err := git.CreateArchive(git.ArchiveOptions{
Path: dir,
Remote: "http://example.com",
Branch: "branch",
Expand All @@ -104,12 +110,16 @@ func TestGitArchiveErrorCases(t *testing.T) {
GID: 0,
DestDir: "",
}, internal.NewStandardWriter())
require.NoError(t, err)
defer reader.Close()
_, err = io.ReadAll(reader)
require.Error(t, err)
require.Contains(t, err.Error(), "failed to get git root path")
require.Contains(t, err.Error(), "failed to copy .git directory")
})

t.Run("non-existent directory", func(t *testing.T) {
_, err := git.CreateArchive(git.ArchiveOptions{
// Error is deferred to read time.
reader, err := git.CreateArchive(git.ArchiveOptions{
Path: "/nonexistent/path",
Remote: "http://example.com",
Branch: "branch",
Expand All @@ -119,8 +129,10 @@ func TestGitArchiveErrorCases(t *testing.T) {
GID: 0,
DestDir: "",
}, internal.NewStandardWriter())
require.NoError(t, err)
defer reader.Close()
_, err = io.ReadAll(reader)
require.Error(t, err)
require.Contains(t, err.Error(), "failed to get git root path")
})

t.Run("empty git repository", func(t *testing.T) {
Expand Down Expand Up @@ -196,7 +208,9 @@ func TestGitArchiveErrorCases(t *testing.T) {
}
})

_, err = git.CreateArchive(git.ArchiveOptions{
// CreateArchive no longer calls FindRoot; error is deferred to read time
// when buildArchive fails to copy the .git directory.
reader, err := git.CreateArchive(git.ArchiveOptions{
Path: dir,
Remote: "http://example.com",
Branch: "branch",
Expand All @@ -206,6 +220,9 @@ func TestGitArchiveErrorCases(t *testing.T) {
GID: 0,
DestDir: "",
}, internal.NewStandardWriter())
require.NoError(t, err)
defer reader.Close()
_, err = io.ReadAll(reader)
require.Error(t, err)
})

Expand Down
4 changes: 0 additions & 4 deletions internal/git/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,6 @@ func NewServer(path string, w internal.Writer) (Server, error) {
return Server{}, fmt.Errorf("failed to resolve absolute path for %q: %w\nCheck that the path exists and is accessible", path, err)
}

if _, err := os.Stat(filepath.Join(path, ".git")); os.IsNotExist(err) {
return Server{}, fmt.Errorf("not a git repository: %q\nRun 'git init' to initialize a repository or navigate to an existing one", path)
}

listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return Server{}, fmt.Errorf("failed to create TCP listener on localhost: %w\nAnother process may be using network resources", err)
Expand Down
7 changes: 0 additions & 7 deletions internal/git/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,4 @@ func TestServer(t *testing.T) {
require.NoError(t, err)
require.Equal(t, "modified content\n", string(content))
})

t.Run("failure cases", func(t *testing.T) {
t.Run("when the directory is not a git repo", func(t *testing.T) {
_, err := git.NewServer("/tmp", internal.NewStandardWriter())
require.ErrorContains(t, err, "not a git repository")
})
})
}
31 changes: 27 additions & 4 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,28 @@ func run(args, env []string) error {

session := internal.GenerateSession()

remote, err := git.NewServer(workingDirectory, w)
gitRoot, err := git.FindRoot(workingDirectory)
if err != nil {
return fmt.Errorf("failed to start git server in directory %q: %w", workingDirectory, err)
return fmt.Errorf("not a git repository: %w", err)
}

relPath, err := filepath.Rel(gitRoot, workingDirectory)
if err != nil {
return fmt.Errorf("failed to compute relative path from git root: %w", err)
}

// containerWorkingDir is where the container starts. When running from a
// subdirectory, it is config.WorkingDir + relPath. The archive always
// extracts to config.WorkingDir so the git root path in the container
// remains stable regardless of where contagent was invoked.
containerWorkingDir := config.WorkingDir
if relPath != "." {
containerWorkingDir = filepath.Join(config.WorkingDir, relPath)
}

remote, err := git.NewServer(gitRoot, w)
if err != nil {
return fmt.Errorf("failed to start git server in directory %q: %w", gitRoot, err)
}
cleanup.Add("git-server", remote.Close)

Expand Down Expand Up @@ -106,7 +125,7 @@ func run(args, env []string) error {
Args: config.Args,
Env: config.Env,
Volumes: config.Volumes,
WorkingDir: config.WorkingDir,
WorkingDir: containerWorkingDir,
Network: config.Network,
StopTimeout: config.StopTimeout,
TTYRetries: config.TTYRetries,
Expand All @@ -125,8 +144,12 @@ func run(args, env []string) error {
return fmt.Errorf("failed to inspect user for image %q: %w", image.Name, err)
}

// DestDir and CopyTo work in tandem: DestDir is the final path component of
// config.WorkingDir, and CopyTo receives its parent. Together they cause the
// archive to be extracted at exactly config.WorkingDir in the container.
// Both must remain derived from the same config.WorkingDir value.
archive, err := git.CreateArchive(git.ArchiveOptions{
Path: workingDirectory,
Path: gitRoot,
Remote: fmt.Sprintf("http://%s:%d", rt.HostAddress(), remote.Port()),
Branch: session.Branch(),
GitUserName: config.GitUser.Name,
Expand Down
Loading