Add repo-backed vm run command
Create a CLI-only banger vm run [path] flow that resolves the enclosing git repository, creates a VM, imports a guest checkout, and launches opencode attach automatically from the host. Build the guest checkout by bundling git history plus the resolved base and head commits, cloning that bundle in the guest, and overlaying tracked plus untracked non-ignored files over SSH so local working-tree changes carry over. Support guest-only branch creation with --branch and --from, reject bare repos and submodules, and add selective tar helpers plus CLI seams to keep the workflow testable. Validate with go test ./..., make build, banger vm run --help, and the expected --from requires --branch error path.
This commit is contained in:
parent
8bcc767824
commit
2ebc6f99c6
5 changed files with 929 additions and 0 deletions
|
|
@ -11,7 +11,9 @@ import (
|
|||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -94,6 +96,19 @@ func (c *Client) StreamTar(ctx context.Context, sourceDir, remoteCommand string,
|
|||
return errors.Join(runErr, tarErr)
|
||||
}
|
||||
|
||||
func (c *Client) StreamTarEntries(ctx context.Context, sourceDir string, entries []string, remoteCommand string, logWriter io.Writer) error {
|
||||
reader, writer := io.Pipe()
|
||||
writeErr := make(chan error, 1)
|
||||
go func() {
|
||||
writeErr <- writeTarEntriesArchive(writer, sourceDir, entries)
|
||||
_ = writer.Close()
|
||||
}()
|
||||
|
||||
runErr := c.runSession(ctx, remoteCommand, reader, logWriter)
|
||||
tarErr := <-writeErr
|
||||
return errors.Join(runErr, tarErr)
|
||||
}
|
||||
|
||||
func (c *Client) runSession(ctx context.Context, command string, stdin io.Reader, logWriter io.Writer) error {
|
||||
if c == nil || c.client == nil {
|
||||
return fmt.Errorf("ssh client is not connected")
|
||||
|
|
@ -197,3 +212,68 @@ func writeTarArchive(dst io.Writer, sourceDir string) error {
|
|||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func writeTarEntriesArchive(dst io.Writer, sourceDir string, entries []string) error {
|
||||
tw := tar.NewWriter(dst)
|
||||
defer tw.Close()
|
||||
|
||||
sourceDir = filepath.Clean(sourceDir)
|
||||
rootName := filepath.Base(sourceDir)
|
||||
|
||||
uniqueEntries := make([]string, 0, len(entries))
|
||||
seen := make(map[string]struct{}, len(entries))
|
||||
for _, entry := range entries {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
entry = filepath.Clean(entry)
|
||||
if entry == "." || entry == ".." || strings.HasPrefix(entry, ".."+string(filepath.Separator)) {
|
||||
return fmt.Errorf("tar entry %q escapes source dir", entry)
|
||||
}
|
||||
if _, ok := seen[entry]; ok {
|
||||
continue
|
||||
}
|
||||
seen[entry] = struct{}{}
|
||||
uniqueEntries = append(uniqueEntries, entry)
|
||||
}
|
||||
sort.Strings(uniqueEntries)
|
||||
|
||||
for _, entry := range uniqueEntries {
|
||||
fullPath := filepath.Join(sourceDir, entry)
|
||||
info, err := os.Lstat(fullPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
linkTarget := ""
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
linkTarget, err = os.Readlink(fullPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
header, err := tar.FileInfoHeader(info, linkTarget)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Name = path.Join(rootName, filepath.ToSlash(entry))
|
||||
if err := tw.WriteHeader(header); err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
file, err := os.Open(fullPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(tw, file); err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,3 +91,52 @@ func TestAuthorizedPublicKey(t *testing.T) {
|
|||
t.Fatalf("key type = %q, want %q", parsed.Type(), ssh.KeyAlgoRSA)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteTarEntriesArchiveIncludesOnlySelectedPaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sourceDir := filepath.Join(t.TempDir(), "repo")
|
||||
if err := os.MkdirAll(filepath.Join(sourceDir, "nested"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(nested): %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sourceDir, "tracked.txt"), []byte("tracked"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(tracked.txt): %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sourceDir, "nested", "keep.txt"), []byte("keep"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(keep.txt): %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sourceDir, "nested", "skip.txt"), []byte("skip"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(skip.txt): %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := writeTarEntriesArchive(&buf, sourceDir, []string{"tracked.txt", "nested/keep.txt"}); err != nil {
|
||||
t.Fatalf("writeTarEntriesArchive: %v", err)
|
||||
}
|
||||
|
||||
tr := tar.NewReader(bytes.NewReader(buf.Bytes()))
|
||||
var names []string
|
||||
for {
|
||||
header, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("tar.Next: %v", err)
|
||||
}
|
||||
names = append(names, header.Name)
|
||||
}
|
||||
|
||||
want := map[string]struct{}{
|
||||
"repo/tracked.txt": {},
|
||||
"repo/nested/keep.txt": {},
|
||||
}
|
||||
if len(names) != len(want) {
|
||||
t.Fatalf("archive names = %v, want %d entries", names, len(want))
|
||||
}
|
||||
for _, name := range names {
|
||||
if _, ok := want[name]; !ok {
|
||||
t.Fatalf("unexpected archive entry %q in %v", name, names)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue