package gitosync import ( "context" "encoding/json" "errors" "log/slog" "os/exec" "strings" "nhooyr.io/websocket" "github.com/nomadcode/nomadcode-core/internal/gitoevents" "github.com/nomadcode/nomadcode-core/internal/protosocket" ) // ExecCommandRunner is the production CommandRunner backed by os/exec. It runs // git in the given directory and returns stdout with the trailing newline left // intact for the caller to trim. type ExecCommandRunner struct{} func (ExecCommandRunner) Run(ctx context.Context, dir, name string, args ...string) (string, error) { cmd := exec.CommandContext(ctx, name, args...) cmd.Dir = dir out, err := cmd.Output() if err != nil { return "", err } return string(out), nil } // websocketTransport is the production gitoevents.Transport that dials the Gito // proto-socket endpoint over a websocket and exchanges JSON envelopes. It is // kept unexported; callers wire it through NewRunner. type websocketTransport struct { url string conn *websocket.Conn } func (t *websocketTransport) Connect(ctx context.Context) error { conn, _, err := websocket.Dial(ctx, t.url, nil) if err != nil { return err } t.conn = conn return nil } func (t *websocketTransport) Write(ctx context.Context, env protosocket.Envelope) error { if t.conn == nil { return errors.New("gitosync: transport not connected") } data, err := json.Marshal(env) if err != nil { return err } return t.conn.Write(ctx, websocket.MessageText, data) } func (t *websocketTransport) Read(ctx context.Context) (protosocket.Envelope, error) { if t.conn == nil { return protosocket.Envelope{}, errors.New("gitosync: transport not connected") } _, data, err := t.conn.Read(ctx) if err != nil { return protosocket.Envelope{}, err } var env protosocket.Envelope if err := json.Unmarshal(data, &env); err != nil { return protosocket.Envelope{}, err } return env, nil } func (t *websocketTransport) Close() error { if t.conn == nil { return nil } return t.conn.Close(websocket.StatusNormalClosure, "") } // NewRunner builds the gitoevents client wired to forward on-target branch // events into the bridge. It returns a *gitoevents.Client whose Run blocks // until the context is cancelled or the stream ends; the caller owns starting // it in a goroutine and cancelling the context on shutdown. The bridge's Handle // error is logged (not propagated) so a single failed event does not tear down // the long-lived subscription. func NewRunner(url, repoID, branch string, bridge *Bridge, logger *slog.Logger) (*gitoevents.Client, error) { if strings.TrimSpace(url) == "" { return nil, errors.New("gitosync: proto-socket url is required") } if bridge == nil { return nil, errors.New("gitosync: bridge is nil") } transport := &websocketTransport{url: url} handler := func(ctx context.Context, ev gitoevents.BranchUpdatedEvent) { if err := bridge.Handle(ctx, ev); err != nil { if logger != nil { logger.Error("gito creation sync handle failed", "error", err, "repo_id", ev.RepoID, "after", ev.After) } } } return gitoevents.NewClient(transport, gitoevents.Options{ RepoID: repoID, Branch: branch, Handler: handler, Logger: logger, }) }