push.go
| 1 | package ci |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "log" |
| 6 | "os/exec" |
| 7 | "strconv" |
| 8 | "strings" |
| 9 | |
| 10 | "hearthforge/internal/gitcmd" |
| 11 | ) |
| 12 | |
| 13 | // PreambleMax is how much of a pushed stream a transport keeps so |
| 14 | // TriggerForPush can read the ref updates. The pkt-line ref list always fits. |
| 15 | const PreambleMax = 4096 |
| 16 | |
| 17 | // CapWriter keeps only the first Limit bytes written to it. The git transports |
| 18 | // tee the push stream through it. |
| 19 | type CapWriter struct { |
| 20 | Limit int |
| 21 | buf []byte |
| 22 | } |
| 23 | |
| 24 | func (w *CapWriter) Write(p []byte) (int, error) { |
| 25 | if n := w.Limit - len(w.buf); n > 0 { |
| 26 | if n > len(p) { |
| 27 | n = len(p) |
| 28 | } |
| 29 | w.buf = append(w.buf, p[:n]...) |
| 30 | } |
| 31 | return len(p), nil |
| 32 | } |
| 33 | |
| 34 | // Bytes returns the captured prefix. A nil CapWriter returns nothing, so a |
| 35 | // transport can pass one it never allocated. |
| 36 | func (w *CapWriter) Bytes() []byte { |
| 37 | if w == nil { |
| 38 | return nil |
| 39 | } |
| 40 | return w.buf |
| 41 | } |
| 42 | |
| 43 | // refUpdate is one "old new refname" line of a receive-pack request. |
| 44 | type refUpdate struct { |
| 45 | OldSha string |
| 46 | NewSha string |
| 47 | Refname string |
| 48 | } |
| 49 | |
| 50 | // parsePktLineRefUpdates reads the ref updates from the start of a |
| 51 | // receive-pack stream. It stops at the flush packet or at truncated input, |
| 52 | // so it works on the first few kilobytes alone. |
| 53 | func parsePktLineRefUpdates(text string) []refUpdate { |
| 54 | var refs []refUpdate |
| 55 | for pos := 0; pos+4 <= len(text); { |
| 56 | length, err := strconv.ParseUint(text[pos:pos+4], 16, 32) |
| 57 | if err != nil || length == 0 { |
| 58 | break |
| 59 | } |
| 60 | if length < 4 || pos+int(length) > len(text) { |
| 61 | break |
| 62 | } |
| 63 | line := text[pos+4 : pos+int(length)] |
| 64 | // Capabilities follow a NUL byte on the first line. |
| 65 | if i := strings.IndexByte(line, 0); i >= 0 { |
| 66 | line = line[:i] |
| 67 | } |
| 68 | pos += int(length) |
| 69 | |
| 70 | parts := strings.Split(strings.TrimSpace(line), " ") |
| 71 | if len(parts) >= 3 && parts[2] != "" { |
| 72 | refs = append(refs, refUpdate{OldSha: parts[0], NewSha: parts[1], Refname: parts[2]}) |
| 73 | } |
| 74 | } |
| 75 | return refs |
| 76 | } |
| 77 | |
| 78 | // TriggerForPush starts a run for every pushed branch or tag whose config at |
| 79 | // the new commit asks for it. preamble is the captured start of the |
| 80 | // receive-pack stream. Both git transports call this. |
| 81 | func (r *Runner) TriggerForPush(ctx context.Context, repoName string, preamble []byte) { |
| 82 | for _, ref := range parsePktLineRefUpdates(string(preamble)) { |
| 83 | r.TriggerForRef(ctx, repoName, ref.Refname, ref.NewSha) |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | // TriggerForRef starts a run for one updated branch or tag when its config |
| 88 | // asks for it. rev is any revision that names the new target. Pushes and web |
| 89 | // UI ref writes both call this. |
| 90 | func (r *Runner) TriggerForRef(ctx context.Context, repoName, refname, rev string) { |
| 91 | // A delete pushes the all-zero sha; there is nothing to build. |
| 92 | if rev == "" || strings.Trim(rev, "0") == "" { |
| 93 | return |
| 94 | } |
| 95 | isBranch := strings.HasPrefix(refname, "refs/heads/") |
| 96 | isTag := strings.HasPrefix(refname, "refs/tags/") |
| 97 | if !isBranch && !isTag { |
| 98 | return |
| 99 | } |
| 100 | // An annotated tag points at a tag object. CI_COMMIT_SHA must name the commit. |
| 101 | cmd := exec.CommandContext(ctx, "git", "-C", r.repoPath(repoName), "rev-parse", |
| 102 | "--verify", "--end-of-options", rev+"^{commit}") |
| 103 | cmd.Env = gitcmd.Env() |
| 104 | out, err := cmd.Output() |
| 105 | if err != nil { |
| 106 | return |
| 107 | } |
| 108 | sha := strings.TrimSpace(string(out)) |
| 109 | cfg, err := r.ConfigAt(ctx, repoName, sha) |
| 110 | if err != nil || cfg == nil { |
| 111 | return |
| 112 | } |
| 113 | opts := TriggerOpts{CommitSha: sha} |
| 114 | switch { |
| 115 | case isBranch: |
| 116 | branch := strings.TrimPrefix(refname, "refs/heads/") |
| 117 | if !shouldTriggerPush(cfg, branch) { |
| 118 | return |
| 119 | } |
| 120 | opts.TriggerSource = "push" |
| 121 | opts.CommitBranch = branch |
| 122 | case cfg.On.Tag: |
| 123 | opts.TriggerSource = "tag" |
| 124 | opts.CommitTag = strings.TrimPrefix(refname, "refs/tags/") |
| 125 | default: |
| 126 | return |
| 127 | } |
| 128 | if _, err := r.TriggerRun(ctx, repoName, opts); err != nil { |
| 129 | log.Printf("CI %s trigger failed for %s: %v", opts.TriggerSource, repoName, err) |
| 130 | } |
| 131 | } |
| 132 |