package gitosync import ( "context" "errors" "io" "log/slog" "os/exec" "strings" "sync" "nhooyr.io/websocket" toki "git.toki-labs.com/toki/proto-socket/go" "github.com/nomadcode/nomadcode-core/internal/gitoevents" "github.com/nomadcode/nomadcode-core/internal/protosocket" "google.golang.org/protobuf/types/known/structpb" ) // 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 } // protoSocketTransport is the production gitoevents.Transport that dials the Gito // proto-socket endpoint over a websocket and exchanges binary protobuf envelopes. // It is kept unexported; callers wire it through NewRunner. type protoSocketTransport struct { url string conn *websocket.Conn client *toki.WsClient inbound chan struct { env protosocket.Envelope err error } closeOnce sync.Once closed chan struct{} inboundCap int } func (t *protoSocketTransport) Connect(ctx context.Context) error { conn, _, err := websocket.Dial(ctx, t.url, nil) if err != nil { return err } t.conn = conn t.closed = make(chan struct{}) cap := t.inboundCap if cap <= 0 { cap = 100 } t.client = toki.NewWsClient(conn, 0, 0, protosocket.ParserMap()) t.inbound = make(chan struct { env protosocket.Envelope err error }, cap) toki.AddListenerTyped[*structpb.Struct](&t.client.Communicator, func(msg *structpb.Struct) { env, err := protosocket.EnvelopeFromStruct(msg) t.enqueue(struct { env protosocket.Envelope err error }{env: env, err: err}) }) t.client.AddDisconnectListener(func(c *toki.WsClient) { t.enqueue(struct { env protosocket.Envelope err error }{err: io.EOF}) }) return nil } func (t *protoSocketTransport) enqueue(item struct { env protosocket.Envelope err error }) { select { case <-t.closed: case t.inbound <- item: } } func (t *protoSocketTransport) Write(ctx context.Context, env protosocket.Envelope) error { if t.client == nil { return errors.New("gitosync: transport not connected") } msg, err := env.ToStruct() if err != nil { return err } return t.client.Communicator.Send(msg) } func (t *protoSocketTransport) Read(ctx context.Context) (protosocket.Envelope, error) { if t.client == nil { return protosocket.Envelope{}, errors.New("gitosync: transport not connected") } select { case <-ctx.Done(): return protosocket.Envelope{}, ctx.Err() case <-t.closed: return protosocket.Envelope{}, io.EOF case item, ok := <-t.inbound: if !ok { return protosocket.Envelope{}, io.EOF } if item.err != nil { return protosocket.Envelope{}, item.err } return item.env, nil } } func (t *protoSocketTransport) Close() error { var err error t.closeOnce.Do(func() { if t.closed != nil { close(t.closed) } if t.client != nil { err = t.client.Close() } }) return err } // 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 := &protoSocketTransport{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, }) }