| @@ -1,438 +0,0 @@ |
| 1 | | - | // SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | | - | |
| 3 | | - | package gitcli |
| 4 | | - | |
| 5 | | - | import ( |
| 6 | | - | "bufio" |
| 7 | | - | "context" |
| 8 | | - | "errors" |
| 9 | | - | "fmt" |
| 10 | | - | "io" |
| 11 | | - | "path/filepath" |
| 12 | | - | "strconv" |
| 13 | | - | "strings" |
| 14 | | - | |
| 15 | | - | "bnema.dev/ermite/internal/domain" |
| 16 | | - | "bnema.dev/ermite/internal/ports" |
| 17 | | - | ) |
| 18 | | - | |
| 19 | | - | var _ ports.WorkflowGuard = (*Git)(nil) |
| 20 | | - | |
| 21 | | - | // workflowsDir is where workflow files live, as a literal pathspec directory. |
| 22 | | - | const workflowsDir = ".github/workflows/" |
| 23 | | - | |
| 24 | | - | // maxWorkflowPath bounds the one path the guard reads from git: a longer one |
| 25 | | - | // fails closed (the push is refused), whatever the trees hold. |
| 26 | | - | const maxWorkflowPath = 4096 |
| 27 | | - | |
| 28 | | - | // Bounds of the walk over the commits a push brings: more than that, or a |
| 29 | | - | // commit with more parents than a line of rev-list holds, fails closed (the |
| 30 | | - | // push is refused), whatever the commits hold. maxPushedObjects counts every |
| 31 | | - | // commit and every parent listed, repeats included (so it bounds the distinct |
| 32 | | - | // ids too), and is enforced as the lines are read, before anything more is |
| 33 | | - | // kept: a few thousand commits with two hundred parents each are refused after |
| 34 | | - | // the first lines, not after they have been held. |
| 35 | | - | const ( |
| 36 | | - | maxPushedCommits = 10000 |
| 37 | | - | maxPushedObjects = 4 * maxPushedCommits |
| 38 | | - | maxRevListLine = 8192 |
| 39 | | - | ) |
| 40 | | - | |
| 41 | | - | // ChangedWorkflow tells whether a push changes the workflows of a repository. |
| 42 | | - | // It returns the path of the first change, "" for none. Deleting a ref changes |
| 43 | | - | // nothing, and a workflows directory that disappears is not a change. |
| 44 | | - | // |
| 45 | | - | // Two things are judged, as GitHub does for the token of a job (it refuses a |
| 46 | | - | // push when the commits pushed change workflow files, not when a new branch is |
| 47 | | - | // cut from a commit that has them): |
| 48 | | - | // |
| 49 | | - | // 1. Every commit the push brings (`rev-list <new> --not --all`, so the commits |
| 50 | | - | // no ref has yet) must leave .github/workflows as it found it: it is |
| 51 | | - | // compared with each of its parents (a root commit with nothing) and is a |
| 52 | | - | // change when it is present and differs from every one of them. A merge of |
| 53 | | - | // existing commits is thus no change; a side that brings a change is itself |
| 54 | | - | // a new commit, and judged as one. |
| 55 | | - | // 2. The directory a ref ends with must not be one it did not have: for an |
| 56 | | - | // update the final .github/workflows must be the old one or absent, for a |
| 57 | | - | // new ref the one of the default branch (HEAD) or absent. This keeps a job |
| 58 | | - | // token from moving a ref onto existing commits that carry other workflows |
| 59 | | - | // (an older commit, a commit of a branch with different ones). |
| 60 | | - | // |
| 61 | | - | // Presence is the directory the workflow loader lists (CI.workflowFiles, on both |
| 62 | | - | // git readers): .github and workflows both entries of mode 040000 and type |
| 63 | | - | // tree. A path through a gitlink (160000), a symbolic link (120000, which the |
| 64 | | - | // loader does not follow) or a file is no workflows, so the same tree id that is |
| 65 | | - | // no workflows as a gitlink and becomes workflows as a directory is a change, |
| 66 | | - | // which the object id alone would not tell. Mode and id of |
| 67 | | - | // "<commit>:.github/workflows" are what is compared: equal means the same |
| 68 | | - | // content, whatever it holds, so nothing is read. |
| 69 | | - | // |
| 70 | | - | // Nothing is walked recursively: the trees of a push are the pusher's, and a |
| 71 | | - | // recursive diff of trees that share subtrees fans out into more paths than the |
| 72 | | - | // push has bytes (git builds the whole diff before it prints a line, so reading |
| 73 | | - | // it as a stream would not help). The shallow diff that names the path reads one |
| 74 | | - | // directory, as large as its tree object, as a stream, up to its first name, and |
| 75 | | - | // git is stopped and reaped then; a name longer than maxWorkflowPath fails |
| 76 | | - | // closed. |
| 77 | | - | // |
| 78 | | - | // The ends of each update are peeled first (`cat-file --batch-check` on |
| 79 | | - | // `<oid>^{}`): a commit or a tree is compared, a blob (a tag may point to one) |
| 80 | | - | // holds no workflows; a missing or malformed object fails closed. Git reads the |
| 81 | | - | // objects of a push still in quarantine through the environment of the hook |
| 82 | | - | // (ports.PushObjects), accepted only inside the repository's objects directory. |
| 83 | | - | // |
| 84 | | - | //hexcheck:ignore suspicious-business-logic-in-adapter reads the object ids and the path git prints for the compared trees; the rule that refuses is the use case's |
| 85 | | - | func (g *Git) ChangedWorkflow(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) (string, error) { |
| 86 | | - | dir, err := g.repoDir(repo) |
| 87 | | - | if err != nil { |
| 88 | | - | return "", err |
| 89 | | - | } |
| 90 | | - | extra, err := quarantineEnv(dir, objects) |
| 91 | | - | if err != nil { |
| 92 | | - | return "", err |
| 93 | | - | } |
| 94 | | - | kept := make([]domain.RefUpdate, 0, len(updates)) |
| 95 | | - | for _, up := range updates { |
| 96 | | - | if !up.IsDelete() { |
| 97 | | - | kept = append(kept, up) |
| 98 | | - | } |
| 99 | | - | } |
| 100 | | - | if len(kept) == 0 { |
| 101 | | - | return "", nil |
| 102 | | - | } |
| 103 | | - | types, err := g.batchCheck(ctx, dir, extra, kept, "^{}", "%(objecttype)") |
| 104 | | - | if err != nil { |
| 105 | | - | return "", err |
| 106 | | - | } |
| 107 | | - | if p, err := g.changedByNewCommits(ctx, dir, extra, kept, types); p != "" || err != nil { |
| 108 | | - | return p, err |
| 109 | | - | } |
| 110 | | - | trees, err := g.batchCheck(ctx, dir, extra, kept, ":"+strings.TrimSuffix(workflowsDir, "/"), "%(objectmode) %(objectname) %(objecttype)") |
| 111 | | - | if err != nil { |
| 112 | | - | return "", err |
| 113 | | - | } |
| 114 | | - | headW, headRead := "", false |
| 115 | | - | for i, up := range kept { |
| 116 | | - | if types[2*i+1] == "blob" { |
| 117 | | - | continue // a blob holds no workflow, whatever it replaces |
| 118 | | - | } |
| 119 | | - | base := workflowsOf(types[2*i], trees[2*i]) |
| 120 | | - | if up.IsCreate() { |
| 121 | | - | if !headRead { |
| 122 | | - | w, err := g.workflowsAt(ctx, dir, extra, []string{"HEAD:" + strings.TrimSuffix(workflowsDir, "/")}) |
| 123 | | - | if err != nil { |
| 124 | | - | return "", err |
| 125 | | - | } |
| 126 | | - | headW, headRead = w["HEAD:"+strings.TrimSuffix(workflowsDir, "/")], true |
| 127 | | - | } |
| 128 | | - | base = headW // a new ref may end with the workflows of the default branch |
| 129 | | - | } |
| 130 | | - | newW := workflowsOf(types[2*i+1], trees[2*i+1]) |
| 131 | | - | if base == newW || newW == "" { |
| 132 | | - | continue // the same directory, or none left |
| 133 | | - | } |
| 134 | | - | return g.firstChanged(ctx, dir, extra, base, newW) |
| 135 | | - | } |
| 136 | | - | return "", nil |
| 137 | | - | } |
| 138 | | - | |
| 139 | | - | // changedByNewCommits judges the commits the updates bring (rule 1 of |
| 140 | | - | // ChangedWorkflow). |
| 141 | | - | func (g *Git) changedByNewCommits(ctx context.Context, dir string, extra []string, ups []domain.RefUpdate, types []string) (string, error) { |
| 142 | | - | var tips []string |
| 143 | | - | seen := map[string]bool{} |
| 144 | | - | for i, up := range ups { |
| 145 | | - | if types[2*i+1] == "commit" && !seen[up.New] { |
| 146 | | - | seen[up.New] = true |
| 147 | | - | tips = append(tips, up.New) |
| 148 | | - | } |
| 149 | | - | } |
| 150 | | - | if len(tips) == 0 { |
| 151 | | - | return "", nil |
| 152 | | - | } |
| 153 | | - | commits, err := g.newCommits(ctx, dir, extra, tips) |
| 154 | | - | if err != nil { |
| 155 | | - | return "", err |
| 156 | | - | } |
| 157 | | - | const suffix = ":.github/workflows" |
| 158 | | - | var specs []string |
| 159 | | - | asked := map[string]bool{} |
| 160 | | - | for _, c := range commits { |
| 161 | | - | for _, id := range append([]string{c.id}, c.parents...) { |
| 162 | | - | if !asked[id] { |
| 163 | | - | asked[id] = true |
| 164 | | - | specs = append(specs, id+suffix) |
| 165 | | - | } |
| 166 | | - | } |
| 167 | | - | } |
| 168 | | - | ws, err := g.workflowsAt(ctx, dir, extra, specs) |
| 169 | | - | if err != nil { |
| 170 | | - | return "", err |
| 171 | | - | } |
| 172 | | - | for _, c := range commits { |
| 173 | | - | cur := ws[c.id+suffix] |
| 174 | | - | if cur == "" { |
| 175 | | - | continue // none: nothing new is active |
| 176 | | - | } |
| 177 | | - | first, same := "", false |
| 178 | | - | for i, p := range c.parents { |
| 179 | | - | if i == 0 { |
| 180 | | - | first = ws[p+suffix] |
| 181 | | - | } |
| 182 | | - | if ws[p+suffix] == cur { |
| 183 | | - | same = true |
| 184 | | - | } |
| 185 | | - | } |
| 186 | | - | if !same { |
| 187 | | - | return g.firstChanged(ctx, dir, extra, first, cur) |
| 188 | | - | } |
| 189 | | - | } |
| 190 | | - | return "", nil |
| 191 | | - | } |
| 192 | | - | |
| 193 | | - | type pushedCommit struct { |
| 194 | | - | id string |
| 195 | | - | parents []string |
| 196 | | - | } |
| 197 | | - | |
| 198 | | - | // newCommits lists the commits reachable from the tips and from no ref |
| 199 | | - | // (`rev-list --parents <tips> --not --all`), at most maxPushedCommits: more |
| 200 | | - | // fails closed. |
| 201 | | - | func (g *Git) newCommits(ctx context.Context, dir string, extra []string, tips []string) ([]pushedCommit, error) { |
| 202 | | - | args := readArgs(dir, append(append([]string{"rev-list", "--parents", "--max-count=" + strconv.Itoa(maxPushedCommits+1)}, tips...), "--not", "--all")...) |
| 203 | | - | var out []pushedCommit |
| 204 | | - | err := g.pipeWith(ctx, extra, nil, args, func(r *bufio.Reader) error { |
| 205 | | - | var err error |
| 206 | | - | out, err = parseRevList(r, maxPushedCommits, maxPushedObjects) |
| 207 | | - | return err |
| 208 | | - | }) |
| 209 | | - | if err != nil { |
| 210 | | - | return nil, fmt.Errorf("commits of a push: %w", err) |
| 211 | | - | } |
| 212 | | - | return out, nil |
| 213 | | - | } |
| 214 | | - | |
| 215 | | - | // parseRevList reads the lines of `rev-list --parents` ("<commit> <parent>..."): |
| 216 | | - | // at most maxCommits lines and maxIDs ids in all (a commit and each of its |
| 217 | | - | // parents count, repeats too). A line over maxRevListLine, a limit crossed or an |
| 218 | | - | // id that is not one is an error, and it is raised as soon as the line that |
| 219 | | - | // crosses it is read: nothing past it is read or kept. |
| 220 | | - | func parseRevList(r *bufio.Reader, maxCommits, maxIDs int) ([]pushedCommit, error) { |
| 221 | | - | var out []pushedCommit |
| 222 | | - | ids := 0 |
| 223 | | - | for { |
| 224 | | - | line, err := r.ReadSlice('\n') |
| 225 | | - | switch { |
| 226 | | - | case errors.Is(err, bufio.ErrBufferFull) || len(line) > maxRevListLine: |
| 227 | | - | return nil, fmt.Errorf("%w: a pushed commit has too many parents", domain.ErrInvalid) |
| 228 | | - | case errors.Is(err, io.EOF) && len(line) == 0: |
| 229 | | - | return out, nil |
| 230 | | - | case err != nil && !errors.Is(err, io.EOF): |
| 231 | | - | return nil, err |
| 232 | | - | } |
| 233 | | - | f := strings.Fields(string(line)) |
| 234 | | - | if len(f) == 0 { |
| 235 | | - | continue |
| 236 | | - | } |
| 237 | | - | if len(out) == maxCommits { |
| 238 | | - | return nil, fmt.Errorf("%w: the push brings more than %d commits", domain.ErrInvalid, maxCommits) |
| 239 | | - | } |
| 240 | | - | if ids += len(f); ids > maxIDs { |
| 241 | | - | return nil, fmt.Errorf("%w: the push brings commits with more than %d ids (commits and parents) in all", domain.ErrInvalid, maxIDs) |
| 242 | | - | } |
| 243 | | - | for _, id := range f { |
| 244 | | - | if _, err := domain.ParseObjectID(id); err != nil { |
| 245 | | - | return nil, fmt.Errorf("%w: unexpected rev-list output", domain.ErrInvalid) |
| 246 | | - | } |
| 247 | | - | } |
| 248 | | - | out = append(out, pushedCommit{id: f[0], parents: f[1:]}) |
| 249 | | - | } |
| 250 | | - | } |
| 251 | | - | |
| 252 | | - | // workflowsAt returns, for each spec ("<rev>:.github/workflows"), the id of the |
| 253 | | - | // directory it names, or "" when there is none (see workflowsOf). |
| 254 | | - | func (g *Git) workflowsAt(ctx context.Context, dir string, extra []string, specs []string) (map[string]string, error) { |
| 255 | | - | out := make(map[string]string, len(specs)) |
| 256 | | - | if len(specs) == 0 { |
| 257 | | - | return out, nil |
| 258 | | - | } |
| 259 | | - | args := readArgs(dir, "cat-file", "--batch-check=%(objectmode) %(objectname) %(objecttype)") |
| 260 | | - | err := g.pipeWith(ctx, extra, strings.NewReader(strings.Join(specs, "\n")+"\n"), args, func(r *bufio.Reader) error { |
| 261 | | - | for _, spec := range specs { |
| 262 | | - | line, err := r.ReadSlice('\n') |
| 263 | | - | if err != nil { |
| 264 | | - | return fmt.Errorf("read object answers: %w", err) |
| 265 | | - | } |
| 266 | | - | out[spec] = workflowsOf("", strings.TrimSuffix(string(line), "\n")) |
| 267 | | - | } |
| 268 | | - | return nil |
| 269 | | - | }) |
| 270 | | - | if err != nil { |
| 271 | | - | return nil, fmt.Errorf("workflows of a push: %w", err) |
| 272 | | - | } |
| 273 | | - | return out, nil |
| 274 | | - | } |
| 275 | | - | |
| 276 | | - | // workflowsOf is the id of the tree that is the .github/workflows of a commit |
| 277 | | - | // or a tree, "" when there is none: the answer for "<it>:.github/workflows" must |
| 278 | | - | // be a tree of mode 040000, and the object peeled to a tree-ish, not a blob. |
| 279 | | - | func workflowsOf(objType, answer string) string { |
| 280 | | - | if objType == "blob" { |
| 281 | | - | return "" |
| 282 | | - | } |
| 283 | | - | mode, rest, _ := strings.Cut(answer, " ") |
| 284 | | - | id, typ, _ := strings.Cut(rest, " ") |
| 285 | | - | if mode != "040000" || typ != "tree" { |
| 286 | | - | return "" |
| 287 | | - | } |
| 288 | | - | return id |
| 289 | | - | } |
| 290 | | - | |
| 291 | | - | // firstChanged names the first top-level entry of .github/workflows that |
| 292 | | - | // differs between two trees (oldID may be "": none, the empty tree). |
| 293 | | - | func (g *Git) firstChanged(ctx context.Context, dir string, extra []string, oldID, newID string) (string, error) { |
| 294 | | - | from := oldID |
| 295 | | - | if from == "" { |
| 296 | | - | from = emptyTree |
| 297 | | - | } |
| 298 | | - | to := newID |
| 299 | | - | args := readArgs(dir, "diff-tree", "--name-only", "-z", "--no-renames", "--no-commit-id", "--end-of-options", from, to) |
| 300 | | - | var name string |
| 301 | | - | err := g.pipeWith(ctx, extra, nil, args, func(r *bufio.Reader) error { |
| 302 | | - | n, found, err := firstPath(r) |
| 303 | | - | name = n |
| 304 | | - | if err != nil || !found { |
| 305 | | - | return err // no name: git's exit status is checked, so a failure refuses |
| 306 | | - | } |
| 307 | | - | return errStop // the first name is all that is needed: stop git |
| 308 | | - | }) |
| 309 | | - | if err != nil { |
| 310 | | - | return "", fmt.Errorf("changed workflows %s..%s: %w", from, to, err) |
| 311 | | - | } |
| 312 | | - | if name == "" { |
| 313 | | - | return strings.TrimSuffix(workflowsDir, "/"), nil |
| 314 | | - | } |
| 315 | | - | return workflowsDir + name, nil |
| 316 | | - | } |
| 317 | | - | |
| 318 | | - | // firstPath reads the first NUL-terminated path of r; found is false at the end |
| 319 | | - | // of the output without one. A path longer than maxWorkflowPath is an error. |
| 320 | | - | func firstPath(r *bufio.Reader) (path string, found bool, err error) { |
| 321 | | - | var b []byte |
| 322 | | - | for { |
| 323 | | - | c, err := r.ReadByte() |
| 324 | | - | switch { |
| 325 | | - | case err != nil: |
| 326 | | - | return "", false, nil // the end of the output; a failing exit status is reported by pipe |
| 327 | | - | case c == 0: |
| 328 | | - | return string(b), true, nil |
| 329 | | - | case len(b) >= maxWorkflowPath: |
| 330 | | - | return "", false, fmt.Errorf("%w: a changed path is longer than %d bytes", domain.ErrInvalid, maxWorkflowPath) |
| 331 | | - | } |
| 332 | | - | b = append(b, c) |
| 333 | | - | } |
| 334 | | - | } |
| 335 | | - | |
| 336 | | - | // batchCheck asks `cat-file --batch-check=<format>` about <oid><suffix> for the |
| 337 | | - | // old (unless the ref is created) and the new end of each update and returns |
| 338 | | - | // the answers in that order, two per update, "" for the old end of a created |
| 339 | | - | // ref. With suffix "^{}" an object that is missing, malformed or neither a |
| 340 | | - | // commit, a tree nor a blob is an error (the push is refused); with any other |
| 341 | | - | // suffix an answer "<input> missing" is kept as the answer. |
| 342 | | - | func (g *Git) batchCheck(ctx context.Context, dir string, extra []string, ups []domain.RefUpdate, suffix, format string) ([]string, error) { |
| 343 | | - | var in strings.Builder |
| 344 | | - | want := 0 |
| 345 | | - | for _, up := range ups { |
| 346 | | - | for _, oid := range []string{up.Old, up.New} { |
| 347 | | - | if oid == domain.ZeroOID { |
| 348 | | - | continue |
| 349 | | - | } |
| 350 | | - | if _, err := domain.ParseObjectID(oid); err != nil { |
| 351 | | - | return nil, fmt.Errorf("%w: %v", domain.ErrInvalid, err) |
| 352 | | - | } |
| 353 | | - | in.WriteString(oid + suffix + "\n") |
| 354 | | - | want++ |
| 355 | | - | } |
| 356 | | - | } |
| 357 | | - | got := make([]string, 0, want) |
| 358 | | - | args := readArgs(dir, "cat-file", "--batch-check="+format) |
| 359 | | - | err := g.pipeWith(ctx, extra, strings.NewReader(in.String()), args, func(r *bufio.Reader) error { |
| 360 | | - | for range want { |
| 361 | | - | line, err := r.ReadSlice('\n') |
| 362 | | - | if err != nil { |
| 363 | | - | if errors.Is(err, bufio.ErrBufferFull) { |
| 364 | | - | return fmt.Errorf("%w: unexpected cat-file output", domain.ErrInvalid) |
| 365 | | - | } |
| 366 | | - | return fmt.Errorf("read object answers: %w", err) |
| 367 | | - | } |
| 368 | | - | got = append(got, strings.TrimSuffix(string(line), "\n")) |
| 369 | | - | } |
| 370 | | - | return nil |
| 371 | | - | }) |
| 372 | | - | if err != nil { |
| 373 | | - | return nil, fmt.Errorf("objects of a push: %w", err) |
| 374 | | - | } |
| 375 | | - | out := make([]string, 0, 2*len(ups)) |
| 376 | | - | i := 0 |
| 377 | | - | for _, up := range ups { |
| 378 | | - | for _, oid := range []string{up.Old, up.New} { |
| 379 | | - | if oid == domain.ZeroOID { |
| 380 | | - | out = append(out, "") |
| 381 | | - | continue |
| 382 | | - | } |
| 383 | | - | if suffix == "^{}" { |
| 384 | | - | switch got[i] { |
| 385 | | - | case "commit", "tree", "blob": |
| 386 | | - | default: // "<input> missing", or a type that cannot be compared |
| 387 | | - | return nil, fmt.Errorf("%w: object %s of the push is missing or of an unexpected type", domain.ErrInvalid, oid) |
| 388 | | - | } |
| 389 | | - | } |
| 390 | | - | out = append(out, got[i]) |
| 391 | | - | i++ |
| 392 | | - | } |
| 393 | | - | } |
| 394 | | - | return out, nil |
| 395 | | - | } |
| 396 | | - | |
| 397 | | - | // quarantineEnv returns the environment that makes git read the objects of a |
| 398 | | - | // push in quarantine. Each directory must exist and, once symbolic links are |
| 399 | | - | // resolved, be inside the objects directory of the repository dir (itself |
| 400 | | - | // resolved): a hook request cannot send git to read anything else. The resolved |
| 401 | | - | // paths are the ones given to git. |
| 402 | | - | func quarantineEnv(repoDir string, o ports.PushObjects) ([]string, error) { |
| 403 | | - | if o.Dir == "" && o.Alternates == "" { |
| 404 | | - | return nil, nil |
| 405 | | - | } |
| 406 | | - | root, err := filepath.EvalSymlinks(filepath.Join(repoDir, "objects")) |
| 407 | | - | if err != nil { |
| 408 | | - | return nil, fmt.Errorf("resolve the objects directory of %s: %w", repoDir, err) |
| 409 | | - | } |
| 410 | | - | inside := func(p string) (string, bool) { |
| 411 | | - | if !filepath.IsAbs(p) { |
| 412 | | - | return "", false |
| 413 | | - | } |
| 414 | | - | real, err := filepath.EvalSymlinks(p) |
| 415 | | - | if err != nil { |
| 416 | | - | return "", false |
| 417 | | - | } |
| 418 | | - | rel, err := filepath.Rel(root, real) |
| 419 | | - | return real, err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) |
| 420 | | - | } |
| 421 | | - | dir, ok := inside(o.Dir) |
| 422 | | - | if o.Dir == "" || !ok { |
| 423 | | - | return nil, fmt.Errorf("%w: the objects directory of the push is outside the repository", domain.ErrInvalid) |
| 424 | | - | } |
| 425 | | - | env := []string{"GIT_OBJECT_DIRECTORY=" + dir} |
| 426 | | - | if o.Alternates != "" { |
| 427 | | - | var alts []string |
| 428 | | - | for _, a := range filepath.SplitList(o.Alternates) { |
| 429 | | - | real, ok := inside(a) |
| 430 | | - | if !ok { |
| 431 | | - | return nil, fmt.Errorf("%w: an alternate objects directory of the push is outside the repository", domain.ErrInvalid) |
| 432 | | - | } |
| 433 | | - | alts = append(alts, real) |
| 434 | | - | } |
| 435 | | - | env = append(env, "GIT_ALTERNATE_OBJECT_DIRECTORIES="+strings.Join(alts, string(filepath.ListSeparator))) |
| 436 | | - | } |
| 437 | | - | return env, nil |
| 438 | | - | } |