package ci import ( "context" "log" "os/exec" "strconv" "strings" "hearthforge/internal/gitcmd" ) // PreambleMax is how much of a pushed stream a transport keeps so // TriggerForPush can read the ref updates. The pkt-line ref list always fits. const PreambleMax = 4096 // CapWriter keeps only the first Limit bytes written to it. The git transports // tee the push stream through it. type CapWriter struct { Limit int buf []byte } func (w *CapWriter) Write(p []byte) (int, error) { if n := w.Limit - len(w.buf); n > 0 { if n > len(p) { n = len(p) } w.buf = append(w.buf, p[:n]...) } return len(p), nil } // Bytes returns the captured prefix. A nil CapWriter returns nothing, so a // transport can pass one it never allocated. func (w *CapWriter) Bytes() []byte { if w == nil { return nil } return w.buf } // refUpdate is one "old new refname" line of a receive-pack request. type refUpdate struct { OldSha string NewSha string Refname string } // parsePktLineRefUpdates reads the ref updates from the start of a // receive-pack stream. It stops at the flush packet or at truncated input, // so it works on the first few kilobytes alone. func parsePktLineRefUpdates(text string) []refUpdate { var refs []refUpdate for pos := 0; pos+4 <= len(text); { length, err := strconv.ParseUint(text[pos:pos+4], 16, 32) if err != nil || length == 0 { break } if length < 4 || pos+int(length) > len(text) { break } line := text[pos+4 : pos+int(length)] // Capabilities follow a NUL byte on the first line. if i := strings.IndexByte(line, 0); i >= 0 { line = line[:i] } pos += int(length) parts := strings.Split(strings.TrimSpace(line), " ") if len(parts) >= 3 && parts[2] != "" { refs = append(refs, refUpdate{OldSha: parts[0], NewSha: parts[1], Refname: parts[2]}) } } return refs } // TriggerForPush starts a run for every pushed branch or tag whose config at // the new commit asks for it. preamble is the captured start of the // receive-pack stream. Both git transports call this. func (r *Runner) TriggerForPush(ctx context.Context, repoName string, preamble []byte) { for _, ref := range parsePktLineRefUpdates(string(preamble)) { r.TriggerForRef(ctx, repoName, ref.Refname, ref.NewSha) } } // TriggerForRef starts a run for one updated branch or tag when its config // asks for it. rev is any revision that names the new target. Pushes and web // UI ref writes both call this. func (r *Runner) TriggerForRef(ctx context.Context, repoName, refname, rev string) { // A delete pushes the all-zero sha; there is nothing to build. if rev == "" || strings.Trim(rev, "0") == "" { return } isBranch := strings.HasPrefix(refname, "refs/heads/") isTag := strings.HasPrefix(refname, "refs/tags/") if !isBranch && !isTag { return } // An annotated tag points at a tag object. CI_COMMIT_SHA must name the commit. cmd := exec.CommandContext(ctx, "git", "-C", r.repoPath(repoName), "rev-parse", "--verify", "--end-of-options", rev+"^{commit}") cmd.Env = gitcmd.Env() out, err := cmd.Output() if err != nil { return } sha := strings.TrimSpace(string(out)) cfg, err := r.ConfigAt(ctx, repoName, sha) if err != nil || cfg == nil { return } opts := TriggerOpts{CommitSha: sha} switch { case isBranch: branch := strings.TrimPrefix(refname, "refs/heads/") if !shouldTriggerPush(cfg, branch) { return } opts.TriggerSource = "push" opts.CommitBranch = branch case cfg.On.Tag: opts.TriggerSource = "tag" opts.CommitTag = strings.TrimPrefix(refname, "refs/tags/") default: return } if _, err := r.TriggerRun(ctx, repoName, opts); err != nil { log.Printf("CI %s trigger failed for %s: %v", opts.TriggerSource, repoName, err) } }