scripts/
static/
templates/
.gitignore
30 B
.gitsigners
112 B
AGENTS.md
7.5 KiB
LICENSE
89 B
README.md
1.9 KiB
deploy
723 B
discuss.go
16.7 KiB
git.go
3.5 KiB
git_cli.go
16.0 KiB
git_http.go
1.9 KiB
go.mod
572 B
go.sum
1.9 KiB
handler.go
11.3 KiB
handler_test.go
69.0 KiB
main.go
5.2 KiB
template.go
8.9 KiB
watch
272 B
git_cli.go
raw
| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bufio" |
| 5 | "bytes" |
| 6 | "fmt" |
| 7 | "io" |
| 8 | "os/exec" |
| 9 | "strconv" |
| 10 | "strings" |
| 11 | "time" |
| 12 | "unicode/utf8" |
| 13 | ) |
| 14 | |
| 15 | // gitCLIBackend implements git operations by shelling out to the git CLI. |
| 16 | // Used for SHA256 repos which go-git cannot handle. |
| 17 | type gitCLIBackend struct { |
| 18 | gitDir string |
| 19 | } |
| 20 | |
| 21 | func newGitCLIBackend(gitDir string) *gitCLIBackend { |
| 22 | return &gitCLIBackend{gitDir: gitDir} |
| 23 | } |
| 24 | |
| 25 | func (g *gitCLIBackend) cmd(args ...string) (string, error) { |
| 26 | cmd := exec.Command("git", append([]string{"--git-dir", g.gitDir}, args...)...) |
| 27 | var stdout, stderr bytes.Buffer |
| 28 | cmd.Stdout = &stdout |
| 29 | cmd.Stderr = &stderr |
| 30 | if err := cmd.Run(); err != nil { |
| 31 | return "", fmt.Errorf("git %s: %s: %w", strings.Join(args, " "), stderr.String(), err) |
| 32 | } |
| 33 | return stdout.String(), nil |
| 34 | } |
| 35 | |
| 36 | func (g *gitCLIBackend) pipe(args ...string) (io.ReadCloser, func() error, error) { |
| 37 | cmd := exec.Command("git", append([]string{"--git-dir", g.gitDir}, args...)...) |
| 38 | stdout, err := cmd.StdoutPipe() |
| 39 | if err != nil { |
| 40 | return nil, nil, err |
| 41 | } |
| 42 | if err := cmd.Start(); err != nil { |
| 43 | return nil, nil, err |
| 44 | } |
| 45 | return stdout, cmd.Wait, nil |
| 46 | } |
| 47 | |
| 48 | func (g *gitCLIBackend) resolveRef(refStr string) (string, error) { |
| 49 | if refStr == "" { |
| 50 | refStr = "HEAD" |
| 51 | } |
| 52 | out, err := g.cmd("rev-parse", refStr) |
| 53 | if err != nil { |
| 54 | return "", err |
| 55 | } |
| 56 | return strings.TrimSpace(out), nil |
| 57 | } |
| 58 | |
| 59 | func (g *gitCLIBackend) resolveRefAndPath(segments []string) (hash string, ref string, path string, err error) { |
| 60 | for i := 1; i <= len(segments); i++ { |
| 61 | candidate := strings.Join(segments[:i], "/") |
| 62 | h, err := g.resolveRef(candidate) |
| 63 | if err == nil { |
| 64 | rest := "" |
| 65 | if i < len(segments) { |
| 66 | rest = strings.Join(segments[i:], "/") |
| 67 | } |
| 68 | return h, candidate, rest, nil |
| 69 | } |
| 70 | } |
| 71 | return "", "", "", fmt.Errorf("cannot resolve ref from path segments: %s", strings.Join(segments, "/")) |
| 72 | } |
| 73 | |
| 74 | func (g *gitCLIBackend) getDefaultBranch() string { |
| 75 | // Use HEAD's symbolic ref (the branch HEAD points to). |
| 76 | out, err := g.cmd("symbolic-ref", "--short", "HEAD") |
| 77 | if err == nil { |
| 78 | branch := strings.TrimSpace(out) |
| 79 | if _, err := g.resolveRef(branch); err == nil { |
| 80 | return branch |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | // Use init.defaultBranch from git config. |
| 85 | out, err = g.cmd("config", "--get", "init.defaultBranch") |
| 86 | if err == nil { |
| 87 | branch := strings.TrimSpace(out) |
| 88 | if _, err := g.resolveRef(branch); err == nil { |
| 89 | return branch |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | // Fall back to the first available branch. |
| 94 | branches, err := g.getBranches() |
| 95 | if err == nil && len(branches) > 0 { |
| 96 | return branches[0].Name |
| 97 | } |
| 98 | return "master" |
| 99 | } |
| 100 | |
| 101 | func (g *gitCLIBackend) getLog(hash string, page, perPage int) ([]CommitInfo, bool, error) { |
| 102 | skip := page * perPage |
| 103 | out, err := g.cmd("log", "--format=%H%n%h%n%an%n%ae%n%aI%n%s%n%b%x00", |
| 104 | "--skip", strconv.Itoa(skip), "-n", strconv.Itoa(perPage+1), hash) |
| 105 | if err != nil { |
| 106 | return nil, false, err |
| 107 | } |
| 108 | |
| 109 | entries := strings.Split(out, "\x00") |
| 110 | var commits []CommitInfo |
| 111 | for _, entry := range entries { |
| 112 | entry = strings.TrimSpace(entry) |
| 113 | if entry == "" { |
| 114 | continue |
| 115 | } |
| 116 | lines := strings.SplitN(entry, "\n", 7) |
| 117 | if len(lines) < 6 { |
| 118 | continue |
| 119 | } |
| 120 | t, _ := time.Parse(time.RFC3339, strings.TrimSpace(lines[4])) |
| 121 | body := "" |
| 122 | if len(lines) > 6 { |
| 123 | body = strings.TrimSpace(lines[6]) |
| 124 | } |
| 125 | commits = append(commits, CommitInfo{ |
| 126 | Hash: strings.TrimSpace(lines[0]), |
| 127 | ShortHash: strings.TrimSpace(lines[1]), |
| 128 | Subject: strings.TrimSpace(lines[5]), |
| 129 | Body: body, |
| 130 | Author: strings.TrimSpace(lines[2]), |
| 131 | AuthorEmail: strings.TrimSpace(lines[3]), |
| 132 | AuthorDate: t, |
| 133 | }) |
| 134 | } |
| 135 | |
| 136 | if len(commits) > perPage { |
| 137 | return commits[:perPage], true, nil |
| 138 | } |
| 139 | return commits, false, nil |
| 140 | } |
| 141 | |
| 142 | func (g *gitCLIBackend) getTree(hash string, path string) ([]TreeEntryInfo, error) { |
| 143 | treeish := hash |
| 144 | if path != "" { |
| 145 | treeish = hash + ":" + path |
| 146 | } |
| 147 | out, err := g.cmd("ls-tree", "-l", treeish) |
| 148 | if err != nil { |
| 149 | return nil, err |
| 150 | } |
| 151 | |
| 152 | var entries []TreeEntryInfo |
| 153 | scanner := bufio.NewScanner(strings.NewReader(out)) |
| 154 | for scanner.Scan() { |
| 155 | line := scanner.Text() |
| 156 | // format: <mode> SP <type> SP <hash> SP <size>\t<name> |
| 157 | tabIdx := strings.IndexByte(line, '\t') |
| 158 | if tabIdx < 0 { |
| 159 | continue |
| 160 | } |
| 161 | name := line[tabIdx+1:] |
| 162 | meta := strings.Fields(line[:tabIdx]) |
| 163 | if len(meta) < 4 { |
| 164 | continue |
| 165 | } |
| 166 | mode := meta[0] |
| 167 | objType := meta[1] |
| 168 | sizeStr := meta[3] |
| 169 | |
| 170 | isDir := objType == "tree" |
| 171 | var size int64 |
| 172 | if !isDir && sizeStr != "-" { |
| 173 | size, _ = strconv.ParseInt(sizeStr, 10, 64) |
| 174 | } |
| 175 | |
| 176 | objHash := meta[2] |
| 177 | shortHash := objHash |
| 178 | if len(shortHash) > 8 { |
| 179 | shortHash = shortHash[:8] |
| 180 | } |
| 181 | |
| 182 | entries = append(entries, TreeEntryInfo{ |
| 183 | Name: name, |
| 184 | IsDir: isDir, |
| 185 | Size: size, |
| 186 | Mode: mode, |
| 187 | Hash: shortHash, |
| 188 | }) |
| 189 | } |
| 190 | |
| 191 | sortTreeEntries(entries) |
| 192 | return entries, nil |
| 193 | } |
| 194 | |
| 195 | func (g *gitCLIBackend) getBlob(hash string, path string) (*BlobInfo, error) { |
| 196 | out, err := g.cmd("cat-file", "-s", hash+":"+path) |
| 197 | if err != nil { |
| 198 | return nil, err |
| 199 | } |
| 200 | size, _ := strconv.ParseInt(strings.TrimSpace(out), 10, 64) |
| 201 | |
| 202 | name := path |
| 203 | if idx := strings.LastIndex(path, "/"); idx >= 0 { |
| 204 | name = path[idx+1:] |
| 205 | } |
| 206 | |
| 207 | info := &BlobInfo{ |
| 208 | Name: name, |
| 209 | Size: size, |
| 210 | } |
| 211 | |
| 212 | if size > maxBlobDisplaySize { |
| 213 | info.IsBinary = true |
| 214 | return info, nil |
| 215 | } |
| 216 | |
| 217 | content, err := g.cmd("cat-file", "blob", hash+":"+path) |
| 218 | if err != nil { |
| 219 | return nil, err |
| 220 | } |
| 221 | |
| 222 | // Check binary |
| 223 | checkLen := len(content) |
| 224 | if checkLen > 8000 { |
| 225 | checkLen = 8000 |
| 226 | } |
| 227 | if strings.Contains(content[:checkLen], "\x00") { |
| 228 | info.IsBinary = true |
| 229 | return info, nil |
| 230 | } |
| 231 | |
| 232 | if !utf8.ValidString(content) { |
| 233 | info.IsBinary = true |
| 234 | return info, nil |
| 235 | } |
| 236 | |
| 237 | info.Content = content |
| 238 | info.Lines = strings.Split(content, "\n") |
| 239 | if len(info.Lines) > 0 && info.Lines[len(info.Lines)-1] == "" { |
| 240 | info.Lines = info.Lines[:len(info.Lines)-1] |
| 241 | } |
| 242 | return info, nil |
| 243 | } |
| 244 | |
| 245 | func (g *gitCLIBackend) getRawBlob(hash string, path string) (io.ReadCloser, string, int64, error) { |
| 246 | out, err := g.cmd("cat-file", "-s", hash+":"+path) |
| 247 | if err != nil { |
| 248 | return nil, "", 0, err |
| 249 | } |
| 250 | size, _ := strconv.ParseInt(strings.TrimSpace(out), 10, 64) |
| 251 | |
| 252 | ct := "application/octet-stream" |
| 253 | if mtype := mimeFromPath(path); mtype != "" { |
| 254 | ct = mtype |
| 255 | } |
| 256 | |
| 257 | reader, wait, err := g.pipe("cat-file", "blob", hash+":"+path) |
| 258 | if err != nil { |
| 259 | return nil, "", 0, err |
| 260 | } |
| 261 | |
| 262 | return &waitReadCloser{reader: reader, wait: wait}, ct, size, nil |
| 263 | } |
| 264 | |
| 265 | type waitReadCloser struct { |
| 266 | reader io.ReadCloser |
| 267 | wait func() error |
| 268 | } |
| 269 | |
| 270 | func (w *waitReadCloser) Read(p []byte) (int, error) { |
| 271 | return w.reader.Read(p) |
| 272 | } |
| 273 | |
| 274 | func (w *waitReadCloser) Close() error { |
| 275 | err := w.reader.Close() |
| 276 | w.wait() |
| 277 | return err |
| 278 | } |
| 279 | |
| 280 | func (g *gitCLIBackend) getCommit(hash string) (*CommitInfo, error) { |
| 281 | out, err := g.cmd("log", "-1", "--format=%H%n%h%n%an%n%ae%n%aI%n%P%n%s%n%b", hash) |
| 282 | if err != nil { |
| 283 | return nil, err |
| 284 | } |
| 285 | |
| 286 | lines := strings.SplitN(strings.TrimRight(out, "\n"), "\n", 8) |
| 287 | if len(lines) < 7 { |
| 288 | return nil, fmt.Errorf("unexpected git log output") |
| 289 | } |
| 290 | |
| 291 | t, _ := time.Parse(time.RFC3339, strings.TrimSpace(lines[4])) |
| 292 | |
| 293 | var parents []string |
| 294 | if p := strings.TrimSpace(lines[5]); p != "" { |
| 295 | parents = strings.Fields(p) |
| 296 | } |
| 297 | |
| 298 | body := "" |
| 299 | if len(lines) > 7 { |
| 300 | body = strings.TrimSpace(lines[7]) |
| 301 | } |
| 302 | |
| 303 | return &CommitInfo{ |
| 304 | Hash: strings.TrimSpace(lines[0]), |
| 305 | ShortHash: strings.TrimSpace(lines[1]), |
| 306 | Subject: strings.TrimSpace(lines[6]), |
| 307 | Body: body, |
| 308 | Author: strings.TrimSpace(lines[2]), |
| 309 | AuthorEmail: strings.TrimSpace(lines[3]), |
| 310 | AuthorDate: t, |
| 311 | Parents: parents, |
| 312 | }, nil |
| 313 | } |
| 314 | |
| 315 | func (g *gitCLIBackend) getDiff(hash string) ([]DiffFile, error) { |
| 316 | var out string |
| 317 | var err error |
| 318 | |
| 319 | // Check if this is a root commit (no parents) |
| 320 | parentOut, perr := g.cmd("rev-parse", hash+"^") |
| 321 | if perr != nil { |
| 322 | // Root commit: diff against empty tree |
| 323 | out, err = g.cmd("diff-tree", "-p", "--no-commit-id", "--root", "-U1000000", hash) |
| 324 | } else { |
| 325 | parent := strings.TrimSpace(parentOut) |
| 326 | out, err = g.cmd("diff", "-U1000000", parent, hash) |
| 327 | } |
| 328 | if err != nil { |
| 329 | return nil, err |
| 330 | } |
| 331 | |
| 332 | return parseDiffOutput(out), nil |
| 333 | } |
| 334 | |
| 335 | func parseDiffOutput(output string) []DiffFile { |
| 336 | var files []DiffFile |
| 337 | parts := strings.Split(output, "diff --git ") |
| 338 | for _, part := range parts[1:] { |
| 339 | df := parseDiffFilePatch(part) |
| 340 | files = append(files, df) |
| 341 | } |
| 342 | return files |
| 343 | } |
| 344 | |
| 345 | func parseDiffFilePatch(patch string) DiffFile { |
| 346 | lines := strings.SplitAfter(patch, "\n") |
| 347 | df := DiffFile{} |
| 348 | |
| 349 | // Parse header: first line is "a/... b/..." |
| 350 | if len(lines) > 0 { |
| 351 | header := strings.TrimSpace(lines[0]) |
| 352 | parts := strings.SplitN(header, " ", 2) |
| 353 | if len(parts) == 2 { |
| 354 | df.OldName = strings.TrimPrefix(parts[0], "a/") |
| 355 | df.NewName = strings.TrimPrefix(parts[1], "b/") |
| 356 | } |
| 357 | } |
| 358 | |
| 359 | // Look for status indicators and binary |
| 360 | i := 1 |
| 361 | for i < len(lines) { |
| 362 | line := strings.TrimRight(lines[i], "\n") |
| 363 | if strings.HasPrefix(line, "new file") { |
| 364 | df.Status = "A" |
| 365 | } else if strings.HasPrefix(line, "deleted file") { |
| 366 | df.Status = "D" |
| 367 | } else if strings.HasPrefix(line, "rename from") { |
| 368 | df.Status = "R" |
| 369 | } else if strings.HasPrefix(line, "Binary files") { |
| 370 | df.IsBinary = true |
| 371 | if df.Status == "" { |
| 372 | df.Status = "M" |
| 373 | } |
| 374 | return df |
| 375 | } else if strings.HasPrefix(line, "@@") || strings.HasPrefix(line, "---") { |
| 376 | break |
| 377 | } |
| 378 | i++ |
| 379 | } |
| 380 | if df.Status == "" { |
| 381 | df.Status = "M" |
| 382 | } |
| 383 | |
| 384 | // Skip --- and +++ lines |
| 385 | for i < len(lines) { |
| 386 | line := strings.TrimRight(lines[i], "\n") |
| 387 | if strings.HasPrefix(line, "---") || strings.HasPrefix(line, "+++") { |
| 388 | i++ |
| 389 | } else { |
| 390 | break |
| 391 | } |
| 392 | } |
| 393 | |
| 394 | // Parse diff lines |
| 395 | var allLines []DiffLine |
| 396 | oldNum, newNum := 1, 1 |
| 397 | for i < len(lines) { |
| 398 | line := strings.TrimRight(lines[i], "\n") |
| 399 | if strings.HasPrefix(line, "@@") { |
| 400 | if parts := parseHunkHeader(line); parts != nil { |
| 401 | oldNum = parts[0] |
| 402 | newNum = parts[1] |
| 403 | } |
| 404 | i++ |
| 405 | continue |
| 406 | } |
| 407 | |
| 408 | if len(line) == 0 && i == len(lines)-1 { |
| 409 | i++ |
| 410 | continue |
| 411 | } |
| 412 | |
| 413 | if len(line) > 0 { |
| 414 | switch line[0] { |
| 415 | case '+': |
| 416 | df.Stats.Added++ |
| 417 | allLines = append(allLines, DiffLine{Type: "add", Content: line[1:], NewNum: newNum}) |
| 418 | newNum++ |
| 419 | case '-': |
| 420 | df.Stats.Deleted++ |
| 421 | allLines = append(allLines, DiffLine{Type: "del", Content: line[1:], OldNum: oldNum}) |
| 422 | oldNum++ |
| 423 | case '\\': |
| 424 | // "\ No newline at end of file" — skip |
| 425 | default: |
| 426 | content := line |
| 427 | if len(content) > 0 && content[0] == ' ' { |
| 428 | content = content[1:] |
| 429 | } |
| 430 | allLines = append(allLines, DiffLine{Type: "context", Content: content, OldNum: oldNum, NewNum: newNum}) |
| 431 | oldNum++ |
| 432 | newNum++ |
| 433 | } |
| 434 | } else { |
| 435 | allLines = append(allLines, DiffLine{Type: "context", Content: "", OldNum: oldNum, NewNum: newNum}) |
| 436 | oldNum++ |
| 437 | newNum++ |
| 438 | } |
| 439 | i++ |
| 440 | } |
| 441 | |
| 442 | if len(allLines) > 0 { |
| 443 | df.Hunks = collapseContext(allLines) |
| 444 | } |
| 445 | |
| 446 | return df |
| 447 | } |
| 448 | |
| 449 | func parseHunkHeader(line string) []int { |
| 450 | // @@ -1,5 +1,7 @@ |
| 451 | at := strings.Index(line, "@@") |
| 452 | if at < 0 { |
| 453 | return nil |
| 454 | } |
| 455 | rest := line[at+2:] |
| 456 | at2 := strings.Index(rest, "@@") |
| 457 | if at2 < 0 { |
| 458 | return nil |
| 459 | } |
| 460 | spec := strings.TrimSpace(rest[:at2]) |
| 461 | parts := strings.Fields(spec) |
| 462 | if len(parts) < 2 { |
| 463 | return nil |
| 464 | } |
| 465 | |
| 466 | oldStart := 1 |
| 467 | newStart := 1 |
| 468 | |
| 469 | old := strings.TrimPrefix(parts[0], "-") |
| 470 | if comma := strings.IndexByte(old, ','); comma >= 0 { |
| 471 | oldStart, _ = strconv.Atoi(old[:comma]) |
| 472 | } else { |
| 473 | oldStart, _ = strconv.Atoi(old) |
| 474 | } |
| 475 | |
| 476 | nw := strings.TrimPrefix(parts[1], "+") |
| 477 | if comma := strings.IndexByte(nw, ','); comma >= 0 { |
| 478 | newStart, _ = strconv.Atoi(nw[:comma]) |
| 479 | } else { |
| 480 | newStart, _ = strconv.Atoi(nw) |
| 481 | } |
| 482 | |
| 483 | return []int{oldStart, newStart} |
| 484 | } |
| 485 | |
| 486 | func (g *gitCLIBackend) getBranches() ([]RefInfo, error) { |
| 487 | out, err := g.cmd("for-each-ref", "--sort=-committerdate", |
| 488 | "--format=%(refname:short)%09%(objectname)%09%(objectname:short)%09%(subject)%09%(authorname)%09%(creatordate:iso-strict)", |
| 489 | "refs/heads/") |
| 490 | if err != nil { |
| 491 | return nil, err |
| 492 | } |
| 493 | branches := parseRefLines(out, false) |
| 494 | |
| 495 | // Move the default branch to the front of the list. |
| 496 | defaultBranch := g.getDefaultBranch() |
| 497 | for i, b := range branches { |
| 498 | if b.Name == defaultBranch && i > 0 { |
| 499 | branches = append([]RefInfo{b}, append(branches[:i], branches[i+1:]...)...) |
| 500 | break |
| 501 | } |
| 502 | } |
| 503 | return branches, nil |
| 504 | } |
| 505 | |
| 506 | func (g *gitCLIBackend) getTags() ([]RefInfo, error) { |
| 507 | // For tags, use %(*objectname) to dereference annotated tags to their commit. |
| 508 | // For lightweight tags, %(*objectname) is empty so we fall back to %(objectname). |
| 509 | out, err := g.cmd("for-each-ref", "--sort=-creatordate", |
| 510 | "--format=%(refname:short)%09%(objectname)%09%(objectname:short)%09%(*objectname)%09%(subject)%09%(authorname)%09%(creatordate:iso-strict)", |
| 511 | "refs/tags/") |
| 512 | if err != nil { |
| 513 | return nil, err |
| 514 | } |
| 515 | return parseTagRefLines(out), nil |
| 516 | } |
| 517 | |
| 518 | func parseRefLines(output string, isTag bool) []RefInfo { |
| 519 | var refs []RefInfo |
| 520 | scanner := bufio.NewScanner(strings.NewReader(output)) |
| 521 | for scanner.Scan() { |
| 522 | line := scanner.Text() |
| 523 | if line == "" { |
| 524 | continue |
| 525 | } |
| 526 | parts := strings.SplitN(line, "\t", 6) |
| 527 | if len(parts) < 6 { |
| 528 | continue |
| 529 | } |
| 530 | t, _ := time.Parse(time.RFC3339, strings.TrimSpace(parts[5])) |
| 531 | |
| 532 | hash := strings.TrimSpace(parts[1]) |
| 533 | shortHash := strings.TrimSpace(parts[2]) |
| 534 | if len(shortHash) > 8 { |
| 535 | shortHash = shortHash[:8] |
| 536 | } |
| 537 | |
| 538 | refs = append(refs, RefInfo{ |
| 539 | Name: strings.TrimSpace(parts[0]), |
| 540 | Hash: hash, |
| 541 | ShortHash: shortHash, |
| 542 | Subject: strings.TrimSpace(parts[3]), |
| 543 | Author: strings.TrimSpace(parts[4]), |
| 544 | Date: t, |
| 545 | IsTag: isTag, |
| 546 | }) |
| 547 | } |
| 548 | return refs |
| 549 | } |
| 550 | |
| 551 | func parseTagRefLines(output string) []RefInfo { |
| 552 | var refs []RefInfo |
| 553 | scanner := bufio.NewScanner(strings.NewReader(output)) |
| 554 | for scanner.Scan() { |
| 555 | line := scanner.Text() |
| 556 | if line == "" { |
| 557 | continue |
| 558 | } |
| 559 | parts := strings.SplitN(line, "\t", 7) |
| 560 | if len(parts) < 7 { |
| 561 | continue |
| 562 | } |
| 563 | t, _ := time.Parse(time.RFC3339, strings.TrimSpace(parts[6])) |
| 564 | |
| 565 | hash := strings.TrimSpace(parts[1]) |
| 566 | shortHash := strings.TrimSpace(parts[2]) |
| 567 | deref := strings.TrimSpace(parts[3]) |
| 568 | |
| 569 | // If annotated tag, use dereferenced commit hash |
| 570 | if deref != "" { |
| 571 | hash = deref |
| 572 | if len(hash) > 8 { |
| 573 | shortHash = hash[:8] |
| 574 | } |
| 575 | } |
| 576 | if len(shortHash) > 8 { |
| 577 | shortHash = shortHash[:8] |
| 578 | } |
| 579 | |
| 580 | refs = append(refs, RefInfo{ |
| 581 | Name: strings.TrimSpace(parts[0]), |
| 582 | Hash: hash, |
| 583 | ShortHash: shortHash, |
| 584 | Subject: strings.TrimSpace(parts[4]), |
| 585 | Author: strings.TrimSpace(parts[5]), |
| 586 | Date: t, |
| 587 | IsTag: true, |
| 588 | }) |
| 589 | } |
| 590 | return refs |
| 591 | } |
| 592 | |
| 593 | func (g *gitCLIBackend) isTreePath(hash string, path string) bool { |
| 594 | if path == "" { |
| 595 | return true |
| 596 | } |
| 597 | out, err := g.cmd("cat-file", "-t", hash+":"+path) |
| 598 | if err != nil { |
| 599 | return false |
| 600 | } |
| 601 | return strings.TrimSpace(out) == "tree" |
| 602 | } |
| 603 | |
| 604 | func (g *gitCLIBackend) buildTreeNodes(hash string, activePath string) []TreeNode { |
| 605 | var activeComponents []string |
| 606 | if activePath != "" { |
| 607 | activeComponents = strings.Split(activePath, "/") |
| 608 | } |
| 609 | return g.buildTreeLevel(hash, "", 0, activeComponents) |
| 610 | } |
| 611 | |
| 612 | func (g *gitCLIBackend) buildTreeLevel(hash string, basePath string, depth int, activeComponents []string) []TreeNode { |
| 613 | entries, err := g.getTree(hash, basePath) |
| 614 | if err != nil { |
| 615 | return nil |
| 616 | } |
| 617 | |
| 618 | var nodes []TreeNode |
| 619 | for _, e := range entries { |
| 620 | fullPath := e.Name |
| 621 | if basePath != "" { |
| 622 | fullPath = basePath + "/" + e.Name |
| 623 | } |
| 624 | |
| 625 | isOpen := false |
| 626 | isActive := false |
| 627 | |
| 628 | if len(activeComponents) > 0 && e.Name == activeComponents[0] { |
| 629 | if e.IsDir { |
| 630 | isOpen = true |
| 631 | if len(activeComponents) == 1 { |
| 632 | isActive = true |
| 633 | } |
| 634 | } else if len(activeComponents) == 1 { |
| 635 | isActive = true |
| 636 | } |
| 637 | } |
| 638 | |
| 639 | nodes = append(nodes, TreeNode{ |
| 640 | Name: e.Name, |
| 641 | IsDir: e.IsDir, |
| 642 | Size: e.Size, |
| 643 | Path: fullPath, |
| 644 | Depth: depth, |
| 645 | IsOpen: isOpen, |
| 646 | IsActive: isActive, |
| 647 | }) |
| 648 | |
| 649 | if isOpen { |
| 650 | var childActive []string |
| 651 | if len(activeComponents) > 1 { |
| 652 | childActive = activeComponents[1:] |
| 653 | } |
| 654 | children := g.buildTreeLevel(hash, fullPath, depth+1, childActive) |
| 655 | nodes = append(nodes, children...) |
| 656 | } |
| 657 | } |
| 658 | return nodes |
| 659 | } |
| 660 | |
| 661 | func (g *gitCLIBackend) getReadme(hash string, dir string) *BlobInfo { |
| 662 | treeish := hash |
| 663 | if dir != "" { |
| 664 | treeish = hash + ":" + dir |
| 665 | } |
| 666 | out, err := g.cmd("ls-tree", treeish) |
| 667 | if err != nil { |
| 668 | return nil |
| 669 | } |
| 670 | |
| 671 | scanner := bufio.NewScanner(strings.NewReader(out)) |
| 672 | for scanner.Scan() { |
| 673 | line := scanner.Text() |
| 674 | tabIdx := strings.IndexByte(line, '\t') |
| 675 | if tabIdx < 0 { |
| 676 | continue |
| 677 | } |
| 678 | name := line[tabIdx+1:] |
| 679 | lower := strings.ToLower(name) |
| 680 | if lower == "readme" || lower == "readme.md" || lower == "readme.txt" { |
| 681 | path := name |
| 682 | if dir != "" { |
| 683 | path = dir + "/" + name |
| 684 | } |
| 685 | blob, err := g.getBlob(hash, path) |
| 686 | if err != nil { |
| 687 | return nil |
| 688 | } |
| 689 | return blob |
| 690 | } |
| 691 | } |
| 692 | return nil |
| 693 | } |