From 9661b7bde5a4bbc51b1240c7b5a04b7aa225b494 Mon Sep 17 00:00:00 2001 From: Ryan Moran Date: Tue, 17 Mar 2026 10:52:23 -0700 Subject: [PATCH] Support running from any subdirectory of a git repo --- README.md | 26 +++++++++++++---------- internal/git/archive.go | 24 ++++++++++++--------- internal/git/archive_test.go | 14 ++++++++---- internal/git/errors_test.go | 41 +++++++++++++++++++++++++----------- internal/git/server.go | 4 ---- internal/git/server_test.go | 7 ------ main.go | 31 +++++++++++++++++++++++---- 7 files changed, 95 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 247648e..cc2bf57 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,7 @@ 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 @@ -183,14 +184,17 @@ CLI flags override all configuration files: - `--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) @@ -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 @@ -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 diff --git a/internal/git/archive.go b/internal/git/archive.go index bdee45c..b83b7f1 100644 --- a/internal/git/archive.go +++ b/internal/git/archive.go @@ -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 @@ -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) @@ -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 { diff --git a/internal/git/archive_test.go b/internal/git/archive_test.go index d6deada..b0db34e 100644 --- a/internal/git/archive_test.go +++ b/internal/git/archive_test.go @@ -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", @@ -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) { @@ -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", diff --git a/internal/git/errors_test.go b/internal/git/errors_test.go index 041f868..e57f5a6 100644 --- a/internal/git/errors_test.go +++ b/internal/git/errors_test.go @@ -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) { @@ -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", @@ -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", @@ -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) { @@ -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", @@ -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) }) diff --git a/internal/git/server.go b/internal/git/server.go index 11cfe0b..deed79e 100644 --- a/internal/git/server.go +++ b/internal/git/server.go @@ -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) diff --git a/internal/git/server_test.go b/internal/git/server_test.go index 2e3b49f..dba643e 100644 --- a/internal/git/server_test.go +++ b/internal/git/server_test.go @@ -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") - }) - }) } diff --git a/main.go b/main.go index 5260cef..88bd938 100644 --- a/main.go +++ b/main.go @@ -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) @@ -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, @@ -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,