Skip to content
Closed
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
10 changes: 9 additions & 1 deletion docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ chunk
│ │ --sidecar-id <id> # Sidecar ID (defaults to active sidecar)
│ │ --public-key <key> # SSH public key string
│ │ --public-key-file <path> # Path to public key file
│ ├── ssh # SSH into sidecar
│ ├── ssh # SSH into sidecar (stdin forwarded when piped)
│ │ --sidecar-id <id> # Sidecar ID (defaults to active sidecar)
│ │ --identity-file <path> # SSH identity file
│ │ -e / --env KEY=VALUE # Set env var in remote session (repeatable)
Expand All @@ -83,6 +83,7 @@ chunk
│ │ --sidecar-id <id> # Sidecar ID (defaults to active sidecar)
│ │ --identity-file <path> # SSH identity file
│ │ --workdir <path> # Destination path on sidecar (auto-detected when omitted)
│ │ --bundle # Sync via git bundle (no GitHub access required)
│ ├── env # Detect tech stack and print environment spec as JSON
│ │ --dir <path> # Directory to analyse (default: .)
│ │ --no-save # Print only, do not save to .chunk/config.json
Expand Down Expand Up @@ -122,6 +123,13 @@ chunk
to disable.
- `config set` accepts only `model` and `apiKey` as keys.
- `chunk init` uses Claude to auto-detect the test command for the project.
- `sidecar sync --bundle` sends a full git bundle on first use, then incremental
bundles (`<lastRef>..HEAD`) on subsequent syncs. The branch does not need to be
pushed to GitHub. Set `bundleSync: true` in `.chunk/config.json` to use bundle
sync by default without passing `--bundle` on every invocation; `sidecar setup`
reads the same config key.
- `sidecar ssh -- <cmd>` forwards stdin when the process stdin is a pipe, enabling
patterns like `cat bundle | chunk sidecar ssh -- git fetch ...`.
It generates `.claude/settings.json` with pre-commit hooks. It never touches
CircleCI — tokens are prompted inline only when a command actually needs them.
- Commands that require a CircleCI token (`task run`, `task config`, `sidecar *`,
Expand Down
20 changes: 20 additions & 0 deletions docs/GETTING_STARTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,26 @@ run the tests on the sidecar

The skill handles the full loop: auth checks → find active sidecar → sync → validate → interpret failures → fix locally → repeat.

### Syncing without pushing to GitHub

By default `chunk sidecar sync` requires the branch to be pushed so the sidecar can clone it. If you want to sync unpushed commits, use `--bundle`:

```bash
chunk sidecar sync --bundle
```

The first sync sends a full git bundle of HEAD. Subsequent syncs send only the new commits since the last sync (`<lastRef>..HEAD`), so incremental syncs are fast. Uncommitted working-tree changes are applied on top as a patch after each bundle transfer.

To avoid passing `--bundle` every time, set it in `.chunk/config.json`:

```json
{
"bundleSync": true
}
```

`chunk sidecar setup` reads the same setting, so the initial environment setup also uses bundle sync when it is enabled.

### Environment setup

Auto-detect your tech stack and build a sidecar image for it:
Expand Down
44 changes: 40 additions & 4 deletions internal/cmd/sidecar.go
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,11 @@ func newSidecarSSHCmd() *cobra.Command {
return &userError{msg: fmt.Sprintf("resolve secrets: %s", err), err: err}
}
envVars = resolved
err = sidecar.SSH(cmd.Context(), client, sidecarID, identityFile, authSock, args, envVars, io)
var stdin *os.File
if fi, statErr := os.Stdin.Stat(); statErr == nil && fi.Mode()&os.ModeCharDevice == 0 {
stdin = os.Stdin
}
err = sidecar.SSH(cmd.Context(), client, sidecarID, identityFile, authSock, args, envVars, io, stdin)
if err != nil {
if err := sshSessionError(err); err != nil {
return err
Expand All @@ -380,6 +384,7 @@ func newSidecarSSHCmd() *cobra.Command {

func newSidecarSyncCmd() *cobra.Command {
var sidecarID, identityFile, workdir string
var bundle bool

cmd := &cobra.Command{
Use: "sync",
Expand All @@ -394,7 +399,24 @@ func newSidecarSyncCmd() *cobra.Command {
if err != nil {
return err
}
err = sidecar.Sync(cmd.Context(), client, sidecarID, identityFile, authSock, workdir, newStatusFunc(io))
cwd, cwdErr := os.Getwd()
if cwdErr != nil {
return fmt.Errorf("sync: %w", cwdErr)
}
useBundle := bundle
if !useBundle {
cfg, cfgErr := config.LoadProjectConfig(cwd)
if cfgErr != nil {
io.ErrPrintf("warning: could not load project config: %v\n", cfgErr)
} else {
useBundle = cfg.BundleSync
}
}
if useBundle {
err = sidecar.BundleSync(cmd.Context(), client, sidecarID, identityFile, authSock, workdir, cwd, newStatusFunc(io))
} else {
err = sidecar.Sync(cmd.Context(), client, sidecarID, identityFile, authSock, workdir, newStatusFunc(io))
}
if err != nil {
if _, ok := errors.AsType[*sidecar.RemoteBaseError](err); ok {
return &userError{
Expand All @@ -421,6 +443,7 @@ func newSidecarSyncCmd() *cobra.Command {
cmd.Flags().StringVar(&sidecarID, "sidecar-id", "", "Sidecar ID (defaults to active sidecar)")
cmd.Flags().StringVar(&identityFile, "identity-file", "", "SSH identity file")
cmd.Flags().StringVar(&workdir, "workdir", "", "Destination path on sidecar (auto-detected as /workspace/<repo> when omitted)")
cmd.Flags().BoolVar(&bundle, "bundle", false, "Sync via git bundle instead of patch (no GitHub access required)")

return cmd
}
Expand Down Expand Up @@ -810,7 +833,13 @@ Example:

// Step 4: Sync files to sidecar.
if !skipSync {
if err := sidecarSetupSync(cmd.Context(), client, sidecarID, identityFile, authSock, status); err != nil {
useBundle := false
if cfg, cfgErr := config.LoadProjectConfig(dir); cfgErr != nil {
status(iostream.LevelWarn, fmt.Sprintf("warning: could not load project config: %v", cfgErr))
} else {
useBundle = cfg.BundleSync
}
if err := sidecarSetupSync(cmd.Context(), client, sidecarID, identityFile, authSock, useBundle, dir, status); err != nil {
return err
}
}
Expand Down Expand Up @@ -901,10 +930,17 @@ func sidecarSetupSync(
ctx context.Context,
client *circleci.Client,
sidecarID, identityFile, authSock string,
useBundle bool,
cwd string,
status iostream.StatusFunc,
) error {
status(iostream.LevelStep, "Syncing files to sidecar...")
err := sidecar.Sync(ctx, client, sidecarID, identityFile, authSock, "", status)
var err error
if useBundle {
err = sidecar.BundleSync(ctx, client, sidecarID, identityFile, authSock, "", cwd, status)
} else {
err = sidecar.Sync(ctx, client, sidecarID, identityFile, authSock, "", status)
}
if err == nil {
return nil
}
Expand Down
1 change: 1 addition & 0 deletions internal/config/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ type ProjectConfig struct {
OrgID string `json:"orgID,omitempty"`
StopHookMaxAttempts int `json:"stopHookMaxAttempts,omitempty"`
Environment *envspec.Environment `json:"environment,omitempty"`
BundleSync bool `json:"bundleSync,omitempty"`
}

// LoadProjectConfig reads .chunk/config.json from workDir.
Expand Down
33 changes: 33 additions & 0 deletions internal/gitutil/gitutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,39 @@ func GeneratePatch(base string) (string, error) {
return string(out), nil
}

// HeadRef returns the SHA of the current HEAD commit in the repo at cwd.
func HeadRef(cwd string) (string, error) {
cmd := exec.Command("git", "rev-parse", "HEAD")
cmd.Dir = cwd
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("resolve HEAD: %w", err)
}
sha := strings.TrimSpace(string(out))
if sha == "" {
return "", fmt.Errorf("resolve HEAD: empty output")
}
return sha, nil
}

// CreateBundle creates a git bundle from base..HEAD in the repo at cwd and returns the raw bytes.
// If base is empty, the full history up to HEAD is bundled.
func CreateBundle(base, cwd string) ([]byte, error) {
var args []string
if base == "" {
args = []string{"bundle", "create", "-", "HEAD"}
} else {
args = []string{"bundle", "create", "-", base + "..HEAD"}
}
cmd := exec.Command("git", args...)
cmd.Dir = cwd
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("create bundle: %w", err)
}
return out, nil
}

func splitNonEmpty(s string) []string {
if s == "" {
return nil
Expand Down
7 changes: 4 additions & 3 deletions internal/sidecar/active.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ import (

// ActiveSidecar holds the currently active sidecar for a project.
type ActiveSidecar struct {
SidecarID string `json:"sidecar_id"`
Name string `json:"name,omitempty"`
Workspace string `json:"workspace,omitempty"`
SidecarID string `json:"sidecar_id"`
Name string `json:"name,omitempty"`
Workspace string `json:"workspace,omitempty"`
LastSyncedRef string `json:"last_synced_ref,omitempty"`
}

// sidecarFileName returns the name of the sidecar state file. When sessionID
Expand Down
11 changes: 7 additions & 4 deletions internal/sidecar/sidecar.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package sidecar
import (
"context"
"fmt"
"io"
"os"
"strings"

Expand Down Expand Up @@ -47,7 +48,9 @@ func AddSSHKey(ctx context.Context, client *circleci.Client, sidecarID, publicKe
}

// SSH opens a session and either runs a command or starts an interactive shell.
func SSH(ctx context.Context, client *circleci.Client, sidecarID, identityFile, authSock string, args []string, envVars map[string]string, io iostream.Streams) error {
// stdin is forwarded to the remote command when non-nil; callers should pass
// os.Stdin when the process stdin is a pipe, nil otherwise.
func SSH(ctx context.Context, client *circleci.Client, sidecarID, identityFile, authSock string, args []string, envVars map[string]string, streams iostream.Streams, stdin io.Reader) error {
session, err := OpenSession(ctx, client, sidecarID, identityFile, authSock)
if err != nil {
return err
Expand All @@ -58,16 +61,16 @@ func SSH(ctx context.Context, client *circleci.Client, sidecarID, identityFile,
}

command := ShellJoin(args)
result, err := ExecOverSSH(ctx, session, command, nil, envVars)
result, err := ExecOverSSH(ctx, session, command, stdin, envVars)
if err != nil {
return err
}

if result.Stdout != "" {
_, _ = fmt.Fprint(io.Out, result.Stdout)
_, _ = fmt.Fprint(streams.Out, result.Stdout)
}
if result.Stderr != "" {
_, _ = fmt.Fprint(io.Err, result.Stderr)
_, _ = fmt.Fprint(streams.Err, result.Stderr)
}
if result.ExitCode != 0 {
return fmt.Errorf("%q exited with status %d", command, result.ExitCode)
Expand Down
Loading