diff options
| author | mo khan <mo@mokhan.ca> | 2026-01-30 18:16:31 -0700 |
|---|---|---|
| committer | mo khan <mo@mokhan.ca> | 2026-01-30 18:16:31 -0700 |
| commit | feee7d43ef63ae607c6fd4cca88a356a93553ebe (patch) | |
| tree | 2969055a894dc4e72d8d79a9ac74cc30d78aff64 /pkg | |
| parent | e0db8f82e96acadf6968e0cf9c805a7b22d835db (diff) | |
refactor: move packages to internal/
Diffstat (limited to 'pkg')
33 files changed, 0 insertions, 7642 deletions
diff --git a/pkg/git/git.go b/pkg/git/git.go deleted file mode 100644 index 1e05d60..0000000 --- a/pkg/git/git.go +++ /dev/null @@ -1,365 +0,0 @@ -package git - -import ( - "bufio" - "fmt" - "io" - "os/exec" - "path/filepath" - "regexp" - "strconv" - "strings" - "time" -) - -func Branches(repoDir string, filter *regexp.Regexp, defaultBranch string) ([]Ref, error) { - cmd := exec.Command("git", "for-each-ref", "--format=%(refname:short)", "refs/heads/") - if repoDir != "" { - cmd.Dir = repoDir - } - out, err := cmd.Output() - if err != nil { - return nil, fmt.Errorf("failed to list branches: %w", err) - } - lines := strings.Split(string(out), "\n") - branches := make([]Ref, 0, len(lines)) - for _, line := range lines { - if line == "" { - continue - } - - if filter != nil && !filter.MatchString(line) && line != defaultBranch { - continue - } - branches = append(branches, NewRef(line)) - } - return branches, nil -} - -func Tags(repoDir string) ([]Tag, error) { - format := []string{ - "%(refname:short)", // tag name - "%(creatordate:unix)", // creation date - "%(objectname)", // commit hash for lightweight tags - "%(*objectname)", // peeled object => commit hash - } - args := []string{ - "for-each-ref", - "--sort=-creatordate", - "--format=" + strings.Join(format, "%00"), - "refs/tags", - } - cmd := exec.Command("git", args...) - if repoDir != "" { - cmd.Dir = repoDir - } - out, err := cmd.Output() - if err != nil { - return nil, fmt.Errorf("failed to list tags: %w", err) - } - - lines := strings.Split(strings.TrimSpace(string(out)), "\n") - tags := make([]Tag, 0, len(lines)) - - for _, line := range lines { - if line == "" { - continue - } - parts := strings.Split(line, "\x00") - if len(parts) != len(format) { - continue - } - name, timestamp, objectName, commitHash := parts[0], parts[1], parts[2], parts[3] - timestampInt, err := strconv.Atoi(timestamp) - if err != nil { - return nil, fmt.Errorf("failed to parse tag creation date: %w", err) - } - if commitHash == "" { - commitHash = objectName // tag is lightweight - } - tags = append(tags, Tag{ - Name: name, - Date: time.Unix(int64(timestampInt), 0), - CommitHash: commitHash, - }) - } - - return tags, nil -} - -func Files(ref Ref, repoDir string) ([]Blob, error) { - if ref.IsEmpty() { - ref = NewRef("HEAD") - } - - // -r: recurse into subtrees - // -l: include blob size - cmd := exec.Command("git", "ls-tree", "--full-tree", "-r", "-l", ref.String()) - if repoDir != "" { - cmd.Dir = repoDir - } - stdout, err := cmd.StdoutPipe() - if err != nil { - return nil, fmt.Errorf("failed to get stdout pipe: %w", err) - } - - stderr, err := cmd.StderrPipe() - if err != nil { - return nil, fmt.Errorf("failed to get stderr pipe: %w", err) - } - - if err := cmd.Start(); err != nil { - return nil, fmt.Errorf("failed to start git ls-tree: %w", err) - } - - files := make([]Blob, 0, 256) - - // Read stdout line by line; each line is like: - // <mode> <type> <object> <size>\t<path> - // Example: "100644 blob e69de29... 12\tREADME.md" - scanner := bufio.NewScanner(stdout) - - // Allow long paths by increasing the scanner buffer limit - scanner.Buffer(make([]byte, 0, 64*1024), 2*1024*1024) - - for scanner.Scan() { - line := scanner.Text() - if line == "" { - continue - } - - // Split header and path using the tab delimiter - // to preserve spaces in file names - tab := strings.IndexByte(line, '\t') - if tab == -1 { - return nil, fmt.Errorf("expected tab delimiter in ls-tree output: %s", line) - } - header := line[:tab] - path := line[tab+1:] - - // header fields: mode, type, object, size - parts := strings.Fields(header) - if len(parts) < 4 { - return nil, fmt.Errorf("unexpected ls-tree output format: %s", line) - } - modeNumber := parts[0] - typ := parts[1] - // object := parts[2] - sizeStr := parts[3] - - if typ != "blob" { - // We only care about files (blobs) - continue - } - - // Size could be "-" for non-blobs in some forms; - // for blobs it should be a number. - size, err := strconv.ParseInt(sizeStr, 10, 64) - if err != nil { - return nil, err - } - - mode, err := ParseFileMode(modeNumber) - if err != nil { - return nil, err - } - - files = append(files, Blob{ - Ref: ref, - Mode: mode, - Path: path, - FileName: filepath.Base(path), - Size: size, - }) - } - - if err := scanner.Err(); err != nil { - // Drain stderr to include any git error message - _ = cmd.Wait() - b, _ := io.ReadAll(stderr) - if len(b) > 0 { - return nil, fmt.Errorf("failed to read ls-tree output: %v: %s", err, string(b)) - } - return nil, fmt.Errorf("failed to read ls-tree output: %w", err) - } - - // Ensure the command completed successfully - if err := cmd.Wait(); err != nil { - b, _ := io.ReadAll(stderr) - if len(b) > 0 { - return nil, fmt.Errorf("git ls-tree %q failed: %v: %s", ref, err, string(b)) - } - return nil, fmt.Errorf("git ls-tree %q failed: %w", ref, err) - } - - return files, nil -} - -func BlobContent(ref Ref, path string, repoDir string) ([]byte, bool, error) { - if ref.IsEmpty() { - ref = NewRef("HEAD") - } - // Use `git show ref:path` to get the blob content at that ref - cmd := exec.Command("git", "show", ref.String()+":"+path) - if repoDir != "" { - cmd.Dir = repoDir - } - out, err := cmd.Output() - if err != nil { - // include stderr if available - if ee, ok := err.(*exec.ExitError); ok { - return nil, false, fmt.Errorf("git show failed: %v: %s", err, string(ee.Stderr)) - } - return nil, false, fmt.Errorf("git show failed: %w", err) - } - return out, IsBinary(out), nil -} - -func Commits(ref Ref, repoDir string) ([]Commit, error) { - format := []string{ - "%H", // commit hash - "%h", // abbreviated commit hash - "%s", // subject - "%b", // body - "%an", // author name - "%ae", // author email - "%ad", // author date - "%cn", // committer name - "%ce", // committer email - "%cd", // committer date - "%P", // parent hashes - "%D", // ref names without the "(", ")" wrapping. - } - - args := []string{ - "log", - "--date=unix", - "--pretty=format:" + strings.Join(format, "\x1F"), - "-z", // Separate the commits with NULs instead of newlines - ref.String(), - } - - cmd := exec.Command("git", args...) - if repoDir != "" { - cmd.Dir = repoDir - } - - out, err := cmd.Output() - if err != nil { - return nil, err - } - - lines := strings.Split(string(out), "\x00") - commits := make([]Commit, 0, len(lines)) - for _, line := range lines { - if line == "" { - continue - } - parts := strings.Split(line, "\x1F") - if len(parts) != len(format) { - return nil, fmt.Errorf("unexpected commit format: %s", line) - } - full, short, subject, body, author, email, date := - parts[0], parts[1], parts[2], parts[3], parts[4], parts[5], parts[6] - committerName, committerEmail, committerDate, parents, refs := - parts[7], parts[8], parts[9], parts[10], parts[11] - timestamp, err := strconv.Atoi(date) - if err != nil { - return nil, fmt.Errorf("failed to parse commit date: %w", err) - } - committerTimestamp, err := strconv.Atoi(committerDate) - if err != nil { - return nil, fmt.Errorf("failed to parse committer date: %w", err) - } - commits = append(commits, Commit{ - Hash: full, - ShortHash: short, - Subject: subject, - Body: body, - Author: author, - Email: email, - Date: time.Unix(int64(timestamp), 0), - CommitterName: committerName, - CommitterEmail: committerEmail, - CommitterDate: time.Unix(int64(committerTimestamp), 0), - Parents: strings.Fields(parents), - RefNames: parseRefNames(refs), - }) - } - return commits, nil -} - -func parseRefNames(refNames string) []RefName { - refNames = strings.TrimSpace(refNames) - if refNames == "" { - return nil - } - - parts := strings.Split(refNames, ", ") - out := make([]RefName, 0, len(parts)) - for _, p := range parts { - p = strings.TrimSpace(p) - if p == "" { - continue - } - - // tag: v1.2.3 - if strings.HasPrefix(p, "tag: ") { - out = append(out, RefName{ - Kind: RefKindTag, - Name: strings.TrimSpace(strings.TrimPrefix(p, "tag: ")), - }) - continue - } - - // HEAD -> main - if strings.HasPrefix(p, "HEAD -> ") { - out = append(out, RefName{ - Kind: RefKindHEAD, - Name: "HEAD", - Target: strings.TrimSpace(strings.TrimPrefix(p, "HEAD -> ")), - }) - continue - } - - // origin/HEAD -> origin/main - if strings.Contains(p, " -> ") && strings.HasSuffix(strings.SplitN(p, " -> ", 2)[0], "/HEAD") { - leftRight := strings.SplitN(p, " -> ", 2) - out = append(out, RefName{ - Kind: RefKindRemoteHEAD, - Name: strings.TrimSpace(leftRight[0]), - Target: strings.TrimSpace(leftRight[1]), - }) - continue - } - - // Remote branch like origin/main - if strings.Contains(p, "/") { - out = append(out, RefName{ - Kind: RefKindRemote, - Name: p, - }) - continue - } - - // Local branch - out = append(out, RefName{ - Kind: RefKindBranch, - Name: p, - }) - } - return out -} - -func CommitDiff(hash, repoDir string) (string, error) { - // unified diff without a commit header - cmd := exec.Command("git", "show", "--pretty=format:", "--patch", hash) - if repoDir != "" { - cmd.Dir = repoDir - } - out, err := cmd.Output() - if err != nil { - return "", err - } - return string(out), nil -} diff --git a/pkg/git/types.go b/pkg/git/types.go deleted file mode 100644 index beecfa4..0000000 --- a/pkg/git/types.go +++ /dev/null @@ -1,76 +0,0 @@ -package git - -import ( - "time" -) - -type Ref struct { - ref string - dirName string -} - -func NewRef(ref string) Ref { - return Ref{ - ref: ref, - dirName: RefToFileName(ref), - } -} - -func (r Ref) IsEmpty() bool { - return r.ref == "" -} - -func (r Ref) String() string { - return r.ref -} - -func (r Ref) DirName() string { - return r.dirName -} - -type Blob struct { - Ref Ref - Mode string - Path string - FileName string - Size int64 -} - -type Commit struct { - Hash string - ShortHash string - Subject string - Body string - Author string - Email string - Date time.Time - CommitterName string - CommitterEmail string - CommitterDate time.Time - Parents []string - Branch Ref - RefNames []RefName - Href string -} - -type RefKind string - -const ( - RefKindHEAD RefKind = "HEAD" - RefKindRemoteHEAD RefKind = "RemoteHEAD" - RefKindBranch RefKind = "Branch" - RefKindRemote RefKind = "Remote" - RefKindTag RefKind = "Tag" -) - -type RefName struct { - Kind RefKind - Name string // Name is the primary name of the ref as shown by `git log %D` token (left side for pointers) - Target string // Target is set for symbolic refs like "HEAD -> main" or "origin/HEAD -> origin/main" -} - -type Tag struct { - Name string - Date time.Time - CommitHash string -} diff --git a/pkg/git/utils.go b/pkg/git/utils.go deleted file mode 100644 index 68e4497..0000000 --- a/pkg/git/utils.go +++ /dev/null @@ -1,91 +0,0 @@ -package git - -import ( - "fmt" - "strconv" - "strings" -) - -// ParseFileMode converts a git-style file mode (e.g. "100644") -// into a human-readable string like "rw-r--r--". -func ParseFileMode(modeStr string) (string, error) { - // Git modes are typically 6 digits. The last 3 represent permissions. - // e.g. 100644 → 644 - if len(modeStr) < 3 { - return "", fmt.Errorf("invalid mode: %s", modeStr) - } - - permStr := modeStr[len(modeStr)-3:] - permVal, err := strconv.Atoi(permStr) - if err != nil { - return "", err - } - - return numericPermToLetters(permVal), nil -} - -func numericPermToLetters(perm int) string { - // Map each octal digit to rwx letters - lookup := map[int]string{ - 0: "---", - 1: "--x", - 2: "-w-", - 3: "-wx", - 4: "r--", - 5: "r-x", - 6: "rw-", - 7: "rwx", - } - - u := perm / 100 // user - g := (perm / 10) % 10 // group - o := perm % 10 // others - - return lookup[u] + lookup[g] + lookup[o] -} - -// IsBinary performs a heuristic check to determine if data is binary. -// Rules: -// - Any NUL byte => binary -// - Consider only a sample (up to 8 KiB). If >30% of bytes are control characters -// outside the common whitespace/newline range, treat as binary. -func IsBinary(b []byte) bool { - n := len(b) - if n == 0 { - return false - } - if n > 8192 { - n = 8192 - } - sample := b[:n] - bad := 0 - for _, c := range sample { - if c == 0x00 { - return true - } - // Allow common whitespace and control: tab(9), LF(10), CR(13) - if c == 9 || c == 10 || c == 13 { - continue - } - // Count other control chars and DEL as non-text - if c < 32 || c == 127 { - bad++ - } - } - // If more than 30% of sampled bytes are non-text, consider binary - return bad*100 > n*30 -} - -func RefToFileName(ref string) string { - var result strings.Builder - for _, c := range ref { - if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '.' { - result.WriteByte(byte(c)) - } else if c >= 'A' && c <= 'Z' { - result.WriteByte(byte(c - 'A' + 'a')) - } else { - result.WriteByte('-') - } - } - return result.String() -} diff --git a/pkg/gitdiff/apply.go b/pkg/gitdiff/apply.go deleted file mode 100644 index 44bbcca..0000000 --- a/pkg/gitdiff/apply.go +++ /dev/null @@ -1,147 +0,0 @@ -package gitdiff - -import ( - "errors" - "fmt" - "io" - "sort" -) - -// Conflict indicates an apply failed due to a conflict between the patch and -// the source content. -// -// Users can test if an error was caused by a conflict by using errors.Is with -// an empty Conflict: -// -// if errors.Is(err, &Conflict{}) { -// // handle conflict -// } -type Conflict struct { - msg string -} - -func (c *Conflict) Error() string { - return "conflict: " + c.msg -} - -// Is implements error matching for Conflict. Passing an empty instance of -// Conflict always returns true. -func (c *Conflict) Is(other error) bool { - if other, ok := other.(*Conflict); ok { - return other.msg == "" || other.msg == c.msg - } - return false -} - -// ApplyError wraps an error that occurs during patch application with -// additional location information, if it is available. -type ApplyError struct { - // Line is the one-indexed line number in the source data - Line int64 - // Fragment is the one-indexed fragment number in the file - Fragment int - // FragmentLine is the one-indexed line number in the fragment - FragmentLine int - - err error -} - -// Unwrap returns the wrapped error. -func (e *ApplyError) Unwrap() error { - return e.err -} - -func (e *ApplyError) Error() string { - return fmt.Sprintf("%v", e.err) -} - -type lineNum int -type fragNum int -type fragLineNum int - -// applyError creates a new *ApplyError wrapping err or augments the information -// in err with args if it is already an *ApplyError. Returns nil if err is nil. -func applyError(err error, args ...interface{}) error { - if err == nil { - return nil - } - - e, ok := err.(*ApplyError) - if !ok { - if err == io.EOF { - err = io.ErrUnexpectedEOF - } - e = &ApplyError{err: err} - } - for _, arg := range args { - switch v := arg.(type) { - case lineNum: - e.Line = int64(v) + 1 - case fragNum: - e.Fragment = int(v) + 1 - case fragLineNum: - e.FragmentLine = int(v) + 1 - } - } - return e -} - -var ( - errApplyInProgress = errors.New("gitdiff: incompatible apply in progress") - errApplierClosed = errors.New("gitdiff: applier is closed") -) - -// Apply applies the changes in f to src, writing the result to dst. It can -// apply both text and binary changes. -// -// If an error occurs while applying, Apply returns an *ApplyError that -// annotates the error with additional information. If the error is because of -// a conflict with the source, the wrapped error will be a *Conflict. -func Apply(dst io.Writer, src io.ReaderAt, f *File) error { - if f.IsBinary { - if len(f.TextFragments) > 0 { - return applyError(errors.New("binary file contains text fragments")) - } - if f.BinaryFragment == nil { - return applyError(errors.New("binary file does not contain a binary fragment")) - } - } else { - if f.BinaryFragment != nil { - return applyError(errors.New("text file contains a binary fragment")) - } - } - - switch { - case f.BinaryFragment != nil: - applier := NewBinaryApplier(dst, src) - if err := applier.ApplyFragment(f.BinaryFragment); err != nil { - return err - } - return applier.Close() - - case len(f.TextFragments) > 0: - frags := make([]*TextFragment, len(f.TextFragments)) - copy(frags, f.TextFragments) - - sort.Slice(frags, func(i, j int) bool { - return frags[i].OldPosition < frags[j].OldPosition - }) - - // TODO(bkeyes): consider merging overlapping fragments - // right now, the application fails if fragments overlap, but it should be - // possible to precompute the result of applying them in order - - applier := NewTextApplier(dst, src) - for i, frag := range frags { - if err := applier.ApplyFragment(frag); err != nil { - return applyError(err, fragNum(i)) - } - } - return applier.Close() - - default: - // nothing to apply, just copy all the data - _, err := copyFrom(dst, src, 0) - return err - } -} diff --git a/pkg/gitdiff/apply_binary.go b/pkg/gitdiff/apply_binary.go deleted file mode 100644 index b34772d..0000000 --- a/pkg/gitdiff/apply_binary.go +++ /dev/null @@ -1,206 +0,0 @@ -package gitdiff - -import ( - "errors" - "fmt" - "io" -) - -// BinaryApplier applies binary changes described in a fragment to source data. -// The applier must be closed after use. -type BinaryApplier struct { - dst io.Writer - src io.ReaderAt - - closed bool - dirty bool -} - -// NewBinaryApplier creates an BinaryApplier that reads data from src and -// writes modified data to dst. -func NewBinaryApplier(dst io.Writer, src io.ReaderAt) *BinaryApplier { - a := BinaryApplier{ - dst: dst, - src: src, - } - return &a -} - -// ApplyFragment applies the changes in the fragment f and writes the result to -// dst. ApplyFragment can be called at most once. -// -// If an error occurs while applying, ApplyFragment returns an *ApplyError that -// annotates the error with additional information. If the error is because of -// a conflict between a fragment and the source, the wrapped error will be a -// *Conflict. -func (a *BinaryApplier) ApplyFragment(f *BinaryFragment) error { - if f == nil { - return applyError(errors.New("nil fragment")) - } - if a.closed { - return applyError(errApplierClosed) - } - if a.dirty { - return applyError(errApplyInProgress) - } - - // mark an apply as in progress, even if it fails before making changes - a.dirty = true - - switch f.Method { - case BinaryPatchLiteral: - if _, err := a.dst.Write(f.Data); err != nil { - return applyError(err) - } - case BinaryPatchDelta: - if err := applyBinaryDeltaFragment(a.dst, a.src, f.Data); err != nil { - return applyError(err) - } - default: - return applyError(fmt.Errorf("unsupported binary patch method: %v", f.Method)) - } - return nil -} - -// Close writes any data following the last applied fragment and prevents -// future calls to ApplyFragment. -func (a *BinaryApplier) Close() (err error) { - if a.closed { - return nil - } - - a.closed = true - if !a.dirty { - _, err = copyFrom(a.dst, a.src, 0) - } else { - // do nothing, applying a binary fragment copies all data - } - return err -} - -func applyBinaryDeltaFragment(dst io.Writer, src io.ReaderAt, frag []byte) error { - srcSize, delta := readBinaryDeltaSize(frag) - if err := checkBinarySrcSize(src, srcSize); err != nil { - return err - } - - dstSize, delta := readBinaryDeltaSize(delta) - - for len(delta) > 0 { - op := delta[0] - if op == 0 { - return errors.New("invalid delta opcode 0") - } - - var n int64 - var err error - switch op & 0x80 { - case 0x80: - n, delta, err = applyBinaryDeltaCopy(dst, op, delta[1:], src) - case 0x00: - n, delta, err = applyBinaryDeltaAdd(dst, op, delta[1:]) - } - if err != nil { - return err - } - dstSize -= n - } - - if dstSize != 0 { - return errors.New("corrupt binary delta: insufficient or extra data") - } - return nil -} - -// readBinaryDeltaSize reads a variable length size from a delta-encoded binary -// fragment, returing the size and the unused data. Data is encoded as: -// -// [[1xxxxxxx]...] [0xxxxxxx] -// -// in little-endian order, with 7 bits of the value per byte. -func readBinaryDeltaSize(d []byte) (size int64, rest []byte) { - shift := uint(0) - for i, b := range d { - size |= int64(b&0x7F) << shift - shift += 7 - if b <= 0x7F { - return size, d[i+1:] - } - } - return size, nil -} - -// applyBinaryDeltaAdd applies an add opcode in a delta-encoded binary -// fragment, returning the amount of data written and the usused part of the -// fragment. An add operation takes the form: -// -// [0xxxxxx][[data1]...] -// -// where the lower seven bits of the opcode is the number of data bytes -// following the opcode. See also pack-format.txt in the Git source. -func applyBinaryDeltaAdd(w io.Writer, op byte, delta []byte) (n int64, rest []byte, err error) { - size := int(op) - if len(delta) < size { - return 0, delta, errors.New("corrupt binary delta: incomplete add") - } - _, err = w.Write(delta[:size]) - return int64(size), delta[size:], err -} - -// applyBinaryDeltaCopy applies a copy opcode in a delta-encoded binary -// fragment, returing the amount of data written and the unused part of the -// fragment. A copy operation takes the form: -// -// [1xxxxxxx][offset1][offset2][offset3][offset4][size1][size2][size3] -// -// where the lower seven bits of the opcode determine which non-zero offset and -// size bytes are present in little-endian order: if bit 0 is set, offset1 is -// present, etc. If no offset or size bytes are present, offset is 0 and size -// is 0x10000. See also pack-format.txt in the Git source. -func applyBinaryDeltaCopy(w io.Writer, op byte, delta []byte, src io.ReaderAt) (n int64, rest []byte, err error) { - const defaultSize = 0x10000 - - unpack := func(start, bits uint) (v int64) { - for i := uint(0); i < bits; i++ { - mask := byte(1 << (i + start)) - if op&mask > 0 { - if len(delta) == 0 { - err = errors.New("corrupt binary delta: incomplete copy") - return - } - v |= int64(delta[0]) << (8 * i) - delta = delta[1:] - } - } - return - } - - offset := unpack(0, 4) - size := unpack(4, 3) - if err != nil { - return 0, delta, err - } - if size == 0 { - size = defaultSize - } - - // TODO(bkeyes): consider pooling these buffers - b := make([]byte, size) - if _, err := src.ReadAt(b, offset); err != nil { - return 0, delta, err - } - - _, err = w.Write(b) - return size, delta, err -} - -func checkBinarySrcSize(r io.ReaderAt, size int64) error { - ok, err := isLen(r, size) - if err != nil { - return err - } - if !ok { - return &Conflict{"fragment src size does not match actual src size"} - } - return nil -} diff --git a/pkg/gitdiff/apply_text.go b/pkg/gitdiff/apply_text.go deleted file mode 100644 index 8d0accb..0000000 --- a/pkg/gitdiff/apply_text.go +++ /dev/null @@ -1,158 +0,0 @@ -package gitdiff - -import ( - "errors" - "io" -) - -// TextApplier applies changes described in text fragments to source data. If -// changes are described in multiple fragments, those fragments must be applied -// in order. The applier must be closed after use. -// -// By default, TextApplier operates in "strict" mode, where fragment content -// and positions must exactly match those of the source. -type TextApplier struct { - dst io.Writer - src io.ReaderAt - lineSrc LineReaderAt - nextLine int64 - - closed bool - dirty bool -} - -// NewTextApplier creates a TextApplier that reads data from src and writes -// modified data to dst. If src implements LineReaderAt, it is used directly. -func NewTextApplier(dst io.Writer, src io.ReaderAt) *TextApplier { - a := TextApplier{ - dst: dst, - src: src, - } - - if lineSrc, ok := src.(LineReaderAt); ok { - a.lineSrc = lineSrc - } else { - a.lineSrc = &lineReaderAt{r: src} - } - - return &a -} - -// ApplyFragment applies the changes in the fragment f, writing unwritten data -// before the start of the fragment and any changes from the fragment. If -// multiple text fragments apply to the same content, ApplyFragment must be -// called in order of increasing start position. As a result, each fragment can -// be applied at most once. -// -// If an error occurs while applying, ApplyFragment returns an *ApplyError that -// annotates the error with additional information. If the error is because of -// a conflict between the fragment and the source, the wrapped error will be a -// *Conflict. -func (a *TextApplier) ApplyFragment(f *TextFragment) error { - if a.closed { - return applyError(errApplierClosed) - } - - // mark an apply as in progress, even if it fails before making changes - a.dirty = true - - // application code assumes fragment fields are consistent - if err := f.Validate(); err != nil { - return applyError(err) - } - - // lines are 0-indexed, positions are 1-indexed (but new files have position = 0) - fragStart := f.OldPosition - 1 - if fragStart < 0 { - fragStart = 0 - } - fragEnd := fragStart + f.OldLines - - start := a.nextLine - if fragStart < start { - return applyError(&Conflict{"fragment overlaps with an applied fragment"}) - } - - if f.OldPosition == 0 { - ok, err := isLen(a.src, 0) - if err != nil { - return applyError(err) - } - if !ok { - return applyError(&Conflict{"cannot create new file from non-empty src"}) - } - } - - preimage := make([][]byte, fragEnd-start) - n, err := a.lineSrc.ReadLinesAt(preimage, start) - if err != nil { - // an EOF indicates that source file is shorter than the patch expects, - // which should be reported as a conflict rather than a generic error - if errors.Is(err, io.EOF) { - err = &Conflict{"src has fewer lines than required by fragment"} - } - return applyError(err, lineNum(start+int64(n))) - } - - // copy leading data before the fragment starts - for i, line := range preimage[:fragStart-start] { - if _, err := a.dst.Write(line); err != nil { - a.nextLine = start + int64(i) - return applyError(err, lineNum(a.nextLine)) - } - } - preimage = preimage[fragStart-start:] - - // apply the changes in the fragment - used := int64(0) - for i, line := range f.Lines { - if err := applyTextLine(a.dst, line, preimage, used); err != nil { - a.nextLine = fragStart + used - return applyError(err, lineNum(a.nextLine), fragLineNum(i)) - } - if line.Old() { - used++ - } - } - a.nextLine = fragStart + used - - // new position of +0,0 mean a full delete, so check for leftovers - if f.NewPosition == 0 && f.NewLines == 0 { - var b [1][]byte - n, err := a.lineSrc.ReadLinesAt(b[:], a.nextLine) - if err != nil && err != io.EOF { - return applyError(err, lineNum(a.nextLine)) - } - if n > 0 { - return applyError(&Conflict{"src still has content after full delete"}, lineNum(a.nextLine)) - } - } - - return nil -} - -func applyTextLine(dst io.Writer, line Line, preimage [][]byte, i int64) (err error) { - if line.Old() && string(preimage[i]) != line.Line { - return &Conflict{"fragment line does not match src line"} - } - if line.New() { - _, err = io.WriteString(dst, line.Line) - } - return err -} - -// Close writes any data following the last applied fragment and prevents -// future calls to ApplyFragment. -func (a *TextApplier) Close() (err error) { - if a.closed { - return nil - } - - a.closed = true - if !a.dirty { - _, err = copyFrom(a.dst, a.src, 0) - } else { - _, err = copyLinesFrom(a.dst, a.lineSrc, a.nextLine) - } - return err -} diff --git a/pkg/gitdiff/base85.go b/pkg/gitdiff/base85.go deleted file mode 100644 index 86db117..0000000 --- a/pkg/gitdiff/base85.go +++ /dev/null @@ -1,91 +0,0 @@ -package gitdiff - -import ( - "fmt" -) - -var ( - b85Table map[byte]byte - b85Alpha = []byte( - "0123456789" + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "abcdefghijklmnopqrstuvwxyz" + "!#$%&()*+-;<=>?@^_`{|}~", - ) -) - -func init() { - b85Table = make(map[byte]byte) - for i, c := range b85Alpha { - b85Table[c] = byte(i) - } -} - -// base85Decode decodes Base85-encoded data from src into dst. It uses the -// alphabet defined by base85.c in the Git source tree. src must contain at -// least len(dst) bytes of encoded data. -func base85Decode(dst, src []byte) error { - var v uint32 - var n, ndst int - for i, b := range src { - if b, ok := b85Table[b]; ok { - v = 85*v + uint32(b) - n++ - } else { - return fmt.Errorf("invalid base85 byte at index %d: 0x%X", i, src[i]) - } - if n == 5 { - rem := len(dst) - ndst - for j := 0; j < 4 && j < rem; j++ { - dst[ndst] = byte(v >> 24) - ndst++ - v <<= 8 - } - v = 0 - n = 0 - } - } - if n > 0 { - return fmt.Errorf("base85 data terminated by underpadded sequence") - } - if ndst < len(dst) { - return fmt.Errorf("base85 data underrun: %d < %d", ndst, len(dst)) - } - return nil -} - -// base85Encode encodes src in Base85, writing the result to dst. It uses the -// alphabet defined by base85.c in the Git source tree. -func base85Encode(dst, src []byte) { - var di, si int - - encode := func(v uint32) { - dst[di+0] = b85Alpha[(v/(85*85*85*85))%85] - dst[di+1] = b85Alpha[(v/(85*85*85))%85] - dst[di+2] = b85Alpha[(v/(85*85))%85] - dst[di+3] = b85Alpha[(v/85)%85] - dst[di+4] = b85Alpha[v%85] - } - - n := (len(src) / 4) * 4 - for si < n { - encode(uint32(src[si+0])<<24 | uint32(src[si+1])<<16 | uint32(src[si+2])<<8 | uint32(src[si+3])) - si += 4 - di += 5 - } - - var v uint32 - switch len(src) - si { - case 3: - v |= uint32(src[si+2]) << 8 - fallthrough - case 2: - v |= uint32(src[si+1]) << 16 - fallthrough - case 1: - v |= uint32(src[si+0]) << 24 - encode(v) - } -} - -// base85Len returns the length of n bytes of Base85 encoded data. -func base85Len(n int) int { - return (n + 3) / 4 * 5 -} diff --git a/pkg/gitdiff/binary.go b/pkg/gitdiff/binary.go deleted file mode 100644 index 282e323..0000000 --- a/pkg/gitdiff/binary.go +++ /dev/null @@ -1,186 +0,0 @@ -package gitdiff - -import ( - "bytes" - "compress/zlib" - "fmt" - "io" - "io/ioutil" - "strconv" - "strings" -) - -func (p *parser) ParseBinaryFragments(f *File) (n int, err error) { - isBinary, hasData, err := p.ParseBinaryMarker() - if err != nil || !isBinary { - return 0, err - } - - f.IsBinary = true - if !hasData { - return 0, nil - } - - forward, err := p.ParseBinaryFragmentHeader() - if err != nil { - return 0, err - } - if forward == nil { - return 0, p.Errorf(0, "missing data for binary patch") - } - if err := p.ParseBinaryChunk(forward); err != nil { - return 0, err - } - f.BinaryFragment = forward - - // valid for reverse to not exist, but it must be valid if present - reverse, err := p.ParseBinaryFragmentHeader() - if err != nil { - return 1, err - } - if reverse == nil { - return 1, nil - } - if err := p.ParseBinaryChunk(reverse); err != nil { - return 1, err - } - f.ReverseBinaryFragment = reverse - - return 1, nil -} - -func (p *parser) ParseBinaryMarker() (isBinary bool, hasData bool, err error) { - line := p.Line(0) - switch { - case line == "GIT binary patch\n": - hasData = true - case isBinaryNoDataMarker(line): - default: - return false, false, nil - } - - if err = p.Next(); err != nil && err != io.EOF { - return false, false, err - } - return true, hasData, nil -} - -func isBinaryNoDataMarker(line string) bool { - if strings.HasSuffix(line, " differ\n") { - return strings.HasPrefix(line, "Binary files ") || strings.HasPrefix(line, "Files ") - } - return false -} - -func (p *parser) ParseBinaryFragmentHeader() (*BinaryFragment, error) { - parts := strings.SplitN(strings.TrimSuffix(p.Line(0), "\n"), " ", 2) - if len(parts) < 2 { - return nil, nil - } - - frag := &BinaryFragment{} - switch parts[0] { - case "delta": - frag.Method = BinaryPatchDelta - case "literal": - frag.Method = BinaryPatchLiteral - default: - return nil, nil - } - - var err error - if frag.Size, err = strconv.ParseInt(parts[1], 10, 64); err != nil { - nerr := err.(*strconv.NumError) - return nil, p.Errorf(0, "binary patch: invalid size: %v", nerr.Err) - } - - if err := p.Next(); err != nil && err != io.EOF { - return nil, err - } - return frag, nil -} - -func (p *parser) ParseBinaryChunk(frag *BinaryFragment) error { - // Binary fragments are encoded as a series of base85 encoded lines. Each - // line starts with a character in [A-Za-z] giving the number of bytes on - // the line, where A = 1 and z = 52, and ends with a newline character. - // - // The base85 encoding means each line is a multiple of 5 characters + 2 - // additional characters for the length byte and the newline. The fragment - // ends with a blank line. - const ( - shortestValidLine = "A00000\n" - maxBytesPerLine = 52 - ) - - var data bytes.Buffer - buf := make([]byte, maxBytesPerLine) - for { - line := p.Line(0) - if line == "\n" { - break - } - if len(line) < len(shortestValidLine) || (len(line)-2)%5 != 0 { - return p.Errorf(0, "binary patch: corrupt data line") - } - - byteCount, seq := int(line[0]), line[1:len(line)-1] - switch { - case 'A' <= byteCount && byteCount <= 'Z': - byteCount = byteCount - 'A' + 1 - case 'a' <= byteCount && byteCount <= 'z': - byteCount = byteCount - 'a' + 27 - default: - return p.Errorf(0, "binary patch: invalid length byte") - } - - // base85 encodes every 4 bytes into 5 characters, with up to 3 bytes of end padding - maxByteCount := len(seq) / 5 * 4 - if byteCount > maxByteCount || byteCount < maxByteCount-3 { - return p.Errorf(0, "binary patch: incorrect byte count") - } - - if err := base85Decode(buf[:byteCount], []byte(seq)); err != nil { - return p.Errorf(0, "binary patch: %v", err) - } - data.Write(buf[:byteCount]) - - if err := p.Next(); err != nil { - if err == io.EOF { - return p.Errorf(0, "binary patch: unexpected EOF") - } - return err - } - } - - if err := inflateBinaryChunk(frag, &data); err != nil { - return p.Errorf(0, "binary patch: %v", err) - } - - // consume the empty line that ended the fragment - if err := p.Next(); err != nil && err != io.EOF { - return err - } - return nil -} - -func inflateBinaryChunk(frag *BinaryFragment, r io.Reader) error { - zr, err := zlib.NewReader(r) - if err != nil { - return err - } - - data, err := ioutil.ReadAll(zr) - if err != nil { - return err - } - if err := zr.Close(); err != nil { - return err - } - - if int64(len(data)) != frag.Size { - return fmt.Errorf("%d byte fragment inflated to %d", frag.Size, len(data)) - } - frag.Data = data - return nil -} diff --git a/pkg/gitdiff/file_header.go b/pkg/gitdiff/file_header.go deleted file mode 100644 index 7ae4bc9..0000000 --- a/pkg/gitdiff/file_header.go +++ /dev/null @@ -1,546 +0,0 @@ -package gitdiff - -import ( - "fmt" - "io" - "os" - "strconv" - "strings" - "time" -) - -const ( - devNull = "/dev/null" -) - -// ParseNextFileHeader finds and parses the next file header in the stream. If -// a header is found, it returns a file and all input before the header. It -// returns nil if no headers are found before the end of the input. -func (p *parser) ParseNextFileHeader() (*File, string, error) { - var preamble strings.Builder - var file *File - for { - // check for disconnected fragment headers (corrupt patch) - frag, err := p.ParseTextFragmentHeader() - if err != nil { - // not a valid header, nothing to worry about - goto NextLine - } - if frag != nil { - return nil, "", p.Errorf(-1, "patch fragment without file header: %s", frag.Header()) - } - - // check for a git-generated patch - file, err = p.ParseGitFileHeader() - if err != nil { - return nil, "", err - } - if file != nil { - return file, preamble.String(), nil - } - - // check for a "traditional" patch - file, err = p.ParseTraditionalFileHeader() - if err != nil { - return nil, "", err - } - if file != nil { - return file, preamble.String(), nil - } - - NextLine: - preamble.WriteString(p.Line(0)) - if err := p.Next(); err != nil { - if err == io.EOF { - break - } - return nil, "", err - } - } - return nil, preamble.String(), nil -} - -func (p *parser) ParseGitFileHeader() (*File, error) { - const prefix = "diff --git " - - if !strings.HasPrefix(p.Line(0), prefix) { - return nil, nil - } - header := p.Line(0)[len(prefix):] - - defaultName, err := parseGitHeaderName(header) - if err != nil { - return nil, p.Errorf(0, "git file header: %v", err) - } - - f := &File{} - for { - end, err := parseGitHeaderData(f, p.Line(1), defaultName) - if err != nil { - return nil, p.Errorf(1, "git file header: %v", err) - } - - if err := p.Next(); err != nil { - if err == io.EOF { - break - } - return nil, err - } - - if end { - break - } - } - - if f.OldName == "" && f.NewName == "" { - if defaultName == "" { - return nil, p.Errorf(0, "git file header: missing filename information") - } - f.OldName = defaultName - f.NewName = defaultName - } - - if (f.NewName == "" && !f.IsDelete) || (f.OldName == "" && !f.IsNew) { - return nil, p.Errorf(0, "git file header: missing filename information") - } - - return f, nil -} - -func (p *parser) ParseTraditionalFileHeader() (*File, error) { - const shortestValidFragHeader = "@@ -1 +1 @@\n" - const ( - oldPrefix = "--- " - newPrefix = "+++ " - ) - - oldLine, newLine := p.Line(0), p.Line(1) - - if !strings.HasPrefix(oldLine, oldPrefix) || !strings.HasPrefix(newLine, newPrefix) { - return nil, nil - } - // heuristic: only a file header if followed by a (probable) fragment header - if len(p.Line(2)) < len(shortestValidFragHeader) || !strings.HasPrefix(p.Line(2), "@@ -") { - return nil, nil - } - - // advance past the first two lines so parser is after the header - // no EOF check needed because we know there are >=3 valid lines - if err := p.Next(); err != nil { - return nil, err - } - if err := p.Next(); err != nil { - return nil, err - } - - oldName, _, err := parseName(oldLine[len(oldPrefix):], '\t', 0) - if err != nil { - return nil, p.Errorf(0, "file header: %v", err) - } - - newName, _, err := parseName(newLine[len(newPrefix):], '\t', 0) - if err != nil { - return nil, p.Errorf(1, "file header: %v", err) - } - - f := &File{} - switch { - case oldName == devNull || hasEpochTimestamp(oldLine): - f.IsNew = true - f.NewName = newName - case newName == devNull || hasEpochTimestamp(newLine): - f.IsDelete = true - f.OldName = oldName - default: - // if old name is a prefix of new name, use that instead - // this avoids picking variants like "file.bak" or "file~" - if strings.HasPrefix(newName, oldName) { - f.OldName = oldName - f.NewName = oldName - } else { - f.OldName = newName - f.NewName = newName - } - } - - return f, nil -} - -// parseGitHeaderName extracts a default file name from the Git file header -// line. This is required for mode-only changes and creation/deletion of empty -// files. Other types of patch include the file name(s) in the header data. -// If the names in the header do not match because the patch is a rename, -// return an empty default name. -func parseGitHeaderName(header string) (string, error) { - header = strings.TrimSuffix(header, "\n") - if len(header) == 0 { - return "", nil - } - - var err error - var first, second string - - // there are 4 cases to account for: - // - // 1) unquoted unquoted - // 2) unquoted "quoted" - // 3) "quoted" unquoted - // 4) "quoted" "quoted" - // - quote := strings.IndexByte(header, '"') - switch { - case quote < 0: - // case 1 - first = header - - case quote > 0: - // case 2 - first = header[:quote-1] - if !isSpace(header[quote-1]) { - return "", fmt.Errorf("missing separator") - } - - second, _, err = parseQuotedName(header[quote:]) - if err != nil { - return "", err - } - - case quote == 0: - // case 3 or case 4 - var n int - first, n, err = parseQuotedName(header) - if err != nil { - return "", err - } - - // git accepts multiple spaces after a quoted name, but not after an - // unquoted name, since the name might end with one or more spaces - for n < len(header) && isSpace(header[n]) { - n++ - } - if n == len(header) { - return "", nil - } - - if header[n] == '"' { - second, _, err = parseQuotedName(header[n:]) - if err != nil { - return "", err - } - } else { - second = header[n:] - } - } - - first = trimTreePrefix(first, 1) - if second != "" { - if first == trimTreePrefix(second, 1) { - return first, nil - } - return "", nil - } - - // at this point, both names are unquoted (case 1) - // since names may contain spaces, we can't use a known separator - // instead, look for a split that produces two equal names - - for i := 0; i < len(first)-1; i++ { - if !isSpace(first[i]) { - continue - } - second = trimTreePrefix(first[i+1:], 1) - if name := first[:i]; name == second { - return name, nil - } - } - return "", nil -} - -// parseGitHeaderData parses a single line of metadata from a Git file header. -// It returns true when header parsing is complete; in that case, line was the -// first line of non-header content. -func parseGitHeaderData(f *File, line, defaultName string) (end bool, err error) { - if len(line) > 0 && line[len(line)-1] == '\n' { - line = line[:len(line)-1] - } - - for _, hdr := range []struct { - prefix string - end bool - parse func(*File, string, string) error - }{ - {"@@ -", true, nil}, - {"--- ", false, parseGitHeaderOldName}, - {"+++ ", false, parseGitHeaderNewName}, - {"old mode ", false, parseGitHeaderOldMode}, - {"new mode ", false, parseGitHeaderNewMode}, - {"deleted file mode ", false, parseGitHeaderDeletedMode}, - {"new file mode ", false, parseGitHeaderCreatedMode}, - {"copy from ", false, parseGitHeaderCopyFrom}, - {"copy to ", false, parseGitHeaderCopyTo}, - {"rename old ", false, parseGitHeaderRenameFrom}, - {"rename new ", false, parseGitHeaderRenameTo}, - {"rename from ", false, parseGitHeaderRenameFrom}, - {"rename to ", false, parseGitHeaderRenameTo}, - {"similarity index ", false, parseGitHeaderScore}, - {"dissimilarity index ", false, parseGitHeaderScore}, - {"index ", false, parseGitHeaderIndex}, - } { - if strings.HasPrefix(line, hdr.prefix) { - if hdr.parse != nil { - err = hdr.parse(f, line[len(hdr.prefix):], defaultName) - } - return hdr.end, err - } - } - - // unknown line indicates the end of the header - // this usually happens if the diff is empty - return true, nil -} - -func parseGitHeaderOldName(f *File, line, defaultName string) error { - name, _, err := parseName(line, '\t', 1) - if err != nil { - return err - } - if f.OldName == "" && !f.IsNew { - f.OldName = name - return nil - } - return verifyGitHeaderName(name, f.OldName, f.IsNew, "old") -} - -func parseGitHeaderNewName(f *File, line, defaultName string) error { - name, _, err := parseName(line, '\t', 1) - if err != nil { - return err - } - if f.NewName == "" && !f.IsDelete { - f.NewName = name - return nil - } - return verifyGitHeaderName(name, f.NewName, f.IsDelete, "new") -} - -func parseGitHeaderOldMode(f *File, line, defaultName string) (err error) { - f.OldMode, err = parseMode(strings.TrimSpace(line)) - return -} - -func parseGitHeaderNewMode(f *File, line, defaultName string) (err error) { - f.NewMode, err = parseMode(strings.TrimSpace(line)) - return -} - -func parseGitHeaderDeletedMode(f *File, line, defaultName string) error { - f.IsDelete = true - f.OldName = defaultName - return parseGitHeaderOldMode(f, line, defaultName) -} - -func parseGitHeaderCreatedMode(f *File, line, defaultName string) error { - f.IsNew = true - f.NewName = defaultName - return parseGitHeaderNewMode(f, line, defaultName) -} - -func parseGitHeaderCopyFrom(f *File, line, defaultName string) (err error) { - f.IsCopy = true - f.OldName, _, err = parseName(line, 0, 0) - return -} - -func parseGitHeaderCopyTo(f *File, line, defaultName string) (err error) { - f.IsCopy = true - f.NewName, _, err = parseName(line, 0, 0) - return -} - -func parseGitHeaderRenameFrom(f *File, line, defaultName string) (err error) { - f.IsRename = true - f.OldName, _, err = parseName(line, 0, 0) - return -} - -func parseGitHeaderRenameTo(f *File, line, defaultName string) (err error) { - f.IsRename = true - f.NewName, _, err = parseName(line, 0, 0) - return -} - -func parseGitHeaderScore(f *File, line, defaultName string) error { - score, err := strconv.ParseInt(strings.TrimSuffix(line, "%"), 10, 32) - if err != nil { - nerr := err.(*strconv.NumError) - return fmt.Errorf("invalid score line: %v", nerr.Err) - } - if score <= 100 { - f.Score = int(score) - } - return nil -} - -func parseGitHeaderIndex(f *File, line, defaultName string) error { - const sep = ".." - - // note that git stops parsing if the OIDs are too long to be valid - // checking this requires knowing if the repository uses SHA1 or SHA256 - // hashes, which we don't know, so we just skip that check - - parts := strings.SplitN(line, " ", 2) - oids := strings.SplitN(parts[0], sep, 2) - - if len(oids) < 2 { - return fmt.Errorf("invalid index line: missing %q", sep) - } - f.OldOIDPrefix, f.NewOIDPrefix = oids[0], oids[1] - - if len(parts) > 1 { - return parseGitHeaderOldMode(f, parts[1], defaultName) - } - return nil -} - -func parseMode(s string) (os.FileMode, error) { - mode, err := strconv.ParseInt(s, 8, 32) - if err != nil { - nerr := err.(*strconv.NumError) - return os.FileMode(0), fmt.Errorf("invalid mode line: %v", nerr.Err) - } - return os.FileMode(mode), nil -} - -// parseName extracts a file name from the start of a string and returns the -// name and the index of the first character after the name. If the name is -// unquoted and term is non-zero, parsing stops at the first occurrence of -// term. -// -// If the name is exactly "/dev/null", no further processing occurs. Otherwise, -// if dropPrefix is greater than zero, that number of prefix components -// separated by forward slashes are dropped from the name and any duplicate -// slashes are collapsed. -func parseName(s string, term byte, dropPrefix int) (name string, n int, err error) { - if len(s) > 0 && s[0] == '"' { - name, n, err = parseQuotedName(s) - } else { - name, n, err = parseUnquotedName(s, term) - } - if err != nil { - return "", 0, err - } - if name == devNull { - return name, n, nil - } - return cleanName(name, dropPrefix), n, nil -} - -func parseQuotedName(s string) (name string, n int, err error) { - for n = 1; n < len(s); n++ { - if s[n] == '"' && s[n-1] != '\\' { - n++ - break - } - } - if n == 2 { - return "", 0, fmt.Errorf("missing name") - } - if name, err = strconv.Unquote(s[:n]); err != nil { - return "", 0, err - } - return name, n, err -} - -func parseUnquotedName(s string, term byte) (name string, n int, err error) { - for n = 0; n < len(s); n++ { - if s[n] == '\n' { - break - } - if term > 0 && s[n] == term { - break - } - } - if n == 0 { - return "", 0, fmt.Errorf("missing name") - } - return s[:n], n, nil -} - -// verifyGitHeaderName checks a parsed name against state set by previous lines -func verifyGitHeaderName(parsed, existing string, isNull bool, side string) error { - if existing != "" { - if isNull { - return fmt.Errorf("expected %s, but filename is set to %s", devNull, existing) - } - if existing != parsed { - return fmt.Errorf("inconsistent %s filename", side) - } - } - if isNull && parsed != devNull { - return fmt.Errorf("expected %s", devNull) - } - return nil -} - -// cleanName removes double slashes and drops prefix segments. -func cleanName(name string, drop int) string { - var b strings.Builder - for i := 0; i < len(name); i++ { - if name[i] == '/' { - if i < len(name)-1 && name[i+1] == '/' { - continue - } - if drop > 0 { - drop-- - b.Reset() - continue - } - } - b.WriteByte(name[i]) - } - return b.String() -} - -// trimTreePrefix removes up to n leading directory components from name. -func trimTreePrefix(name string, n int) string { - i := 0 - for ; i < len(name) && n > 0; i++ { - if name[i] == '/' { - n-- - } - } - return name[i:] -} - -// hasEpochTimestamp returns true if the string ends with a POSIX-formatted -// timestamp for the UNIX epoch after a tab character. According to git, this -// is used by GNU diff to mark creations and deletions. -func hasEpochTimestamp(s string) bool { - const posixTimeLayout = "2006-01-02 15:04:05.9 -0700" - - start := strings.IndexRune(s, '\t') - if start < 0 { - return false - } - - ts := strings.TrimSuffix(s[start+1:], "\n") - - // a valid timestamp can have optional ':' in zone specifier - // remove that if it exists so we have a single format - if len(ts) >= 3 && ts[len(ts)-3] == ':' { - ts = ts[:len(ts)-3] + ts[len(ts)-2:] - } - - t, err := time.Parse(posixTimeLayout, ts) - if err != nil { - return false - } - if !t.Equal(time.Unix(0, 0)) { - return false - } - return true -} - -func isSpace(c byte) bool { - return c == ' ' || c == '\t' || c == '\n' -} diff --git a/pkg/gitdiff/format.go b/pkg/gitdiff/format.go deleted file mode 100644 index d97aba9..0000000 --- a/pkg/gitdiff/format.go +++ /dev/null @@ -1,281 +0,0 @@ -package gitdiff - -import ( - "bytes" - "compress/zlib" - "fmt" - "io" - "strconv" -) - -type formatter struct { - w io.Writer - err error -} - -func newFormatter(w io.Writer) *formatter { - return &formatter{w: w} -} - -func (fm *formatter) Write(p []byte) (int, error) { - if fm.err != nil { - return len(p), nil - } - if _, err := fm.w.Write(p); err != nil { - fm.err = err - } - return len(p), nil -} - -func (fm *formatter) WriteString(s string) (int, error) { - fm.Write([]byte(s)) - return len(s), nil -} - -func (fm *formatter) WriteByte(c byte) error { - fm.Write([]byte{c}) - return nil -} - -func (fm *formatter) WriteQuotedName(s string) { - qpos := 0 - for i := 0; i < len(s); i++ { - ch := s[i] - if q, quoted := quoteByte(ch); quoted { - if qpos == 0 { - fm.WriteByte('"') - } - fm.WriteString(s[qpos:i]) - fm.Write(q) - qpos = i + 1 - } - } - fm.WriteString(s[qpos:]) - if qpos > 0 { - fm.WriteByte('"') - } -} - -var quoteEscapeTable = map[byte]byte{ - '\a': 'a', - '\b': 'b', - '\t': 't', - '\n': 'n', - '\v': 'v', - '\f': 'f', - '\r': 'r', - '"': '"', - '\\': '\\', -} - -func quoteByte(b byte) ([]byte, bool) { - if q, ok := quoteEscapeTable[b]; ok { - return []byte{'\\', q}, true - } - if b < 0x20 || b >= 0x7F { - return []byte{ - '\\', - '0' + (b>>6)&0o3, - '0' + (b>>3)&0o7, - '0' + (b>>0)&0o7, - }, true - } - return nil, false -} - -func (fm *formatter) FormatFile(f *File) { - fm.WriteString("diff --git ") - - var aName, bName string - switch { - case f.OldName == "": - aName = f.NewName - bName = f.NewName - - case f.NewName == "": - aName = f.OldName - bName = f.OldName - - default: - aName = f.OldName - bName = f.NewName - } - - fm.WriteQuotedName("a/" + aName) - fm.WriteByte(' ') - fm.WriteQuotedName("b/" + bName) - fm.WriteByte('\n') - - if f.OldMode != 0 { - if f.IsDelete { - fmt.Fprintf(fm, "deleted file mode %o\n", f.OldMode) - } else if f.NewMode != 0 { - fmt.Fprintf(fm, "old mode %o\n", f.OldMode) - } - } - - if f.NewMode != 0 { - if f.IsNew { - fmt.Fprintf(fm, "new file mode %o\n", f.NewMode) - } else if f.OldMode != 0 { - fmt.Fprintf(fm, "new mode %o\n", f.NewMode) - } - } - - if f.Score > 0 { - if f.IsCopy || f.IsRename { - fmt.Fprintf(fm, "similarity index %d%%\n", f.Score) - } else { - fmt.Fprintf(fm, "dissimilarity index %d%%\n", f.Score) - } - } - - if f.IsCopy { - if f.OldName != "" { - fm.WriteString("copy from ") - fm.WriteQuotedName(f.OldName) - fm.WriteByte('\n') - } - if f.NewName != "" { - fm.WriteString("copy to ") - fm.WriteQuotedName(f.NewName) - fm.WriteByte('\n') - } - } - - if f.IsRename { - if f.OldName != "" { - fm.WriteString("rename from ") - fm.WriteQuotedName(f.OldName) - fm.WriteByte('\n') - } - if f.NewName != "" { - fm.WriteString("rename to ") - fm.WriteQuotedName(f.NewName) - fm.WriteByte('\n') - } - } - - if f.OldOIDPrefix != "" && f.NewOIDPrefix != "" { - fmt.Fprintf(fm, "index %s..%s", f.OldOIDPrefix, f.NewOIDPrefix) - - // Mode is only included on the index line when it is not changing - if f.OldMode != 0 && ((f.NewMode == 0 && !f.IsDelete) || f.OldMode == f.NewMode) { - fmt.Fprintf(fm, " %o", f.OldMode) - } - - fm.WriteByte('\n') - } - - if f.IsBinary { - if f.BinaryFragment == nil { - fm.WriteString("Binary files ") - fm.WriteQuotedName("a/" + aName) - fm.WriteString(" and ") - fm.WriteQuotedName("b/" + bName) - fm.WriteString(" differ\n") - } else { - fm.WriteString("GIT binary patch\n") - fm.FormatBinaryFragment(f.BinaryFragment) - if f.ReverseBinaryFragment != nil { - fm.FormatBinaryFragment(f.ReverseBinaryFragment) - } - } - } - - // The "---" and "+++" lines only appear for text patches with fragments - if len(f.TextFragments) > 0 { - fm.WriteString("--- ") - if f.OldName == "" { - fm.WriteString("/dev/null") - } else { - fm.WriteQuotedName("a/" + f.OldName) - } - fm.WriteByte('\n') - - fm.WriteString("+++ ") - if f.NewName == "" { - fm.WriteString("/dev/null") - } else { - fm.WriteQuotedName("b/" + f.NewName) - } - fm.WriteByte('\n') - - for _, frag := range f.TextFragments { - fm.FormatTextFragment(frag) - } - } -} - -func (fm *formatter) FormatTextFragment(f *TextFragment) { - fm.FormatTextFragmentHeader(f) - fm.WriteByte('\n') - - for _, line := range f.Lines { - fm.WriteString(line.Op.String()) - fm.WriteString(line.Line) - if line.NoEOL() { - fm.WriteString("\n\\ No newline at end of file\n") - } - } -} - -func (fm *formatter) FormatTextFragmentHeader(f *TextFragment) { - fmt.Fprintf(fm, "@@ -%d,%d +%d,%d @@", f.OldPosition, f.OldLines, f.NewPosition, f.NewLines) - if f.Comment != "" { - fm.WriteByte(' ') - fm.WriteString(f.Comment) - } -} - -func (fm *formatter) FormatBinaryFragment(f *BinaryFragment) { - const ( - maxBytesPerLine = 52 - ) - - switch f.Method { - case BinaryPatchDelta: - fm.WriteString("delta ") - case BinaryPatchLiteral: - fm.WriteString("literal ") - } - fm.Write(strconv.AppendInt(nil, f.Size, 10)) - fm.WriteByte('\n') - - data := deflateBinaryChunk(f.Data) - n := (len(data) / maxBytesPerLine) * maxBytesPerLine - - buf := make([]byte, base85Len(maxBytesPerLine)) - for i := 0; i < n; i += maxBytesPerLine { - base85Encode(buf, data[i:i+maxBytesPerLine]) - fm.WriteByte('z') - fm.Write(buf) - fm.WriteByte('\n') - } - if remainder := len(data) - n; remainder > 0 { - buf = buf[0:base85Len(remainder)] - - sizeChar := byte(remainder) - if remainder <= 26 { - sizeChar = 'A' + sizeChar - 1 - } else { - sizeChar = 'a' + sizeChar - 27 - } - - base85Encode(buf, data[n:]) - fm.WriteByte(sizeChar) - fm.Write(buf) - fm.WriteByte('\n') - } - fm.WriteByte('\n') -} - -func deflateBinaryChunk(data []byte) []byte { - var b bytes.Buffer - - zw := zlib.NewWriter(&b) - _, _ = zw.Write(data) - _ = zw.Close() - - return b.Bytes() -} diff --git a/pkg/gitdiff/gitdiff.go b/pkg/gitdiff/gitdiff.go deleted file mode 100644 index 5365645..0000000 --- a/pkg/gitdiff/gitdiff.go +++ /dev/null @@ -1,230 +0,0 @@ -package gitdiff - -import ( - "errors" - "fmt" - "os" - "strings" -) - -// File describes changes to a single file. It can be either a text file or a -// binary file. -type File struct { - OldName string - NewName string - - IsNew bool - IsDelete bool - IsCopy bool - IsRename bool - - OldMode os.FileMode - NewMode os.FileMode - - OldOIDPrefix string - NewOIDPrefix string - Score int - - // TextFragments contains the fragments describing changes to a text file. It - // may be empty if the file is empty or if only the mode changes. - TextFragments []*TextFragment - - // IsBinary is true if the file is a binary file. If the patch includes - // binary data, BinaryFragment will be non-nil and describe the changes to - // the data. If the patch is reversible, ReverseBinaryFragment will also be - // non-nil and describe the changes needed to restore the original file - // after applying the changes in BinaryFragment. - IsBinary bool - BinaryFragment *BinaryFragment - ReverseBinaryFragment *BinaryFragment -} - -// String returns a git diff representation of this file. The value can be -// parsed by this library to obtain the same File, but may not be the same as -// the original input. -func (f *File) String() string { - var diff strings.Builder - newFormatter(&diff).FormatFile(f) - return diff.String() -} - -// TextFragment describes changed lines starting at a specific line in a text file. -type TextFragment struct { - Comment string - - OldPosition int64 - OldLines int64 - - NewPosition int64 - NewLines int64 - - LinesAdded int64 - LinesDeleted int64 - - LeadingContext int64 - TrailingContext int64 - - Lines []Line -} - -// String returns a git diff format of this fragment. See [File.String] for -// more details on this format. -func (f *TextFragment) String() string { - var diff strings.Builder - newFormatter(&diff).FormatTextFragment(f) - return diff.String() -} - -// Header returns a git diff header of this fragment. See [File.String] for -// more details on this format. -func (f *TextFragment) Header() string { - var hdr strings.Builder - newFormatter(&hdr).FormatTextFragmentHeader(f) - return hdr.String() -} - -// Validate checks that the fragment is self-consistent and appliable. Validate -// returns an error if and only if the fragment is invalid. -func (f *TextFragment) Validate() error { - if f == nil { - return errors.New("nil fragment") - } - - var ( - oldLines, newLines int64 - leadingContext, trailingContext int64 - contextLines, addedLines, deletedLines int64 - ) - - // count the types of lines in the fragment content - for i, line := range f.Lines { - switch line.Op { - case OpContext: - oldLines++ - newLines++ - contextLines++ - if addedLines == 0 && deletedLines == 0 { - leadingContext++ - } else { - trailingContext++ - } - case OpAdd: - newLines++ - addedLines++ - trailingContext = 0 - case OpDelete: - oldLines++ - deletedLines++ - trailingContext = 0 - default: - return fmt.Errorf("unknown operator %q on line %d", line.Op, i+1) - } - } - - // check the actual counts against the reported counts - if oldLines != f.OldLines { - return lineCountErr("old", oldLines, f.OldLines) - } - if newLines != f.NewLines { - return lineCountErr("new", newLines, f.NewLines) - } - if leadingContext != f.LeadingContext { - return lineCountErr("leading context", leadingContext, f.LeadingContext) - } - if trailingContext != f.TrailingContext { - return lineCountErr("trailing context", trailingContext, f.TrailingContext) - } - if addedLines != f.LinesAdded { - return lineCountErr("added", addedLines, f.LinesAdded) - } - if deletedLines != f.LinesDeleted { - return lineCountErr("deleted", deletedLines, f.LinesDeleted) - } - - // if a file is being created, it can only contain additions - if f.OldPosition == 0 && f.OldLines != 0 { - return errors.New("file creation fragment contains context or deletion lines") - } - - return nil -} - -func lineCountErr(kind string, actual, reported int64) error { - return fmt.Errorf("fragment contains %d %s lines but reports %d", actual, kind, reported) -} - -// Line is a line in a text fragment. -type Line struct { - Op LineOp - Line string -} - -func (fl Line) String() string { - return fl.Op.String() + fl.Line -} - -// Old returns true if the line appears in the old content of the fragment. -func (fl Line) Old() bool { - return fl.Op == OpContext || fl.Op == OpDelete -} - -// New returns true if the line appears in the new content of the fragment. -func (fl Line) New() bool { - return fl.Op == OpContext || fl.Op == OpAdd -} - -// NoEOL returns true if the line is missing a trailing newline character. -func (fl Line) NoEOL() bool { - return len(fl.Line) == 0 || fl.Line[len(fl.Line)-1] != '\n' -} - -// LineOp describes the type of a text fragment line: context, added, or removed. -type LineOp int - -const ( - // OpContext indicates a context line - OpContext LineOp = iota - // OpDelete indicates a deleted line - OpDelete - // OpAdd indicates an added line - OpAdd -) - -func (op LineOp) String() string { - switch op { - case OpContext: - return " " - case OpDelete: - return "-" - case OpAdd: - return "+" - } - return "?" -} - -// BinaryFragment describes changes to a binary file. -type BinaryFragment struct { - Method BinaryPatchMethod - Size int64 - Data []byte -} - -// BinaryPatchMethod is the method used to create and apply the binary patch. -type BinaryPatchMethod int - -const ( - // BinaryPatchDelta indicates the data uses Git's packfile encoding - BinaryPatchDelta BinaryPatchMethod = iota - // BinaryPatchLiteral indicates the data is the exact file content - BinaryPatchLiteral -) - -// String returns a git diff format of this fragment. Due to differences in -// zlib implementation between Go and Git, encoded binary data in the result -// will likely differ from what Git produces for the same input. See -// [File.String] for more details on this format. -func (f *BinaryFragment) String() string { - var diff strings.Builder - newFormatter(&diff).FormatBinaryFragment(f) - return diff.String() -} diff --git a/pkg/gitdiff/io.go b/pkg/gitdiff/io.go deleted file mode 100644 index 8143238..0000000 --- a/pkg/gitdiff/io.go +++ /dev/null @@ -1,220 +0,0 @@ -package gitdiff - -import ( - "errors" - "io" -) - -const ( - byteBufferSize = 32 * 1024 // from io.Copy - lineBufferSize = 32 - indexBufferSize = 1024 -) - -// LineReaderAt is the interface that wraps the ReadLinesAt method. -// -// ReadLinesAt reads len(lines) into lines starting at line offset. It returns -// the number of lines read (0 <= n <= len(lines)) and any error encountered. -// Line numbers are zero-indexed. -// -// If n < len(lines), ReadLinesAt returns a non-nil error explaining why more -// lines were not returned. -// -// Lines read by ReadLinesAt include the newline character. The last line does -// not have a final newline character if the input ends without one. -type LineReaderAt interface { - ReadLinesAt(lines [][]byte, offset int64) (n int, err error) -} - -type lineReaderAt struct { - r io.ReaderAt - index []int64 - eof bool -} - -func (r *lineReaderAt) ReadLinesAt(lines [][]byte, offset int64) (n int, err error) { - if offset < 0 { - return 0, errors.New("ReadLinesAt: negative offset") - } - if len(lines) == 0 { - return 0, nil - } - - count := len(lines) - startLine := offset - endLine := startLine + int64(count) - - if endLine > int64(len(r.index)) && !r.eof { - if err := r.indexTo(endLine); err != nil { - return 0, err - } - } - if startLine >= int64(len(r.index)) { - return 0, io.EOF - } - - buf, byteOffset, err := r.readBytes(startLine, int64(count)) - if err != nil { - return 0, err - } - - for n = 0; n < count && startLine+int64(n) < int64(len(r.index)); n++ { - lineno := startLine + int64(n) - start, end := int64(0), r.index[lineno]-byteOffset - if lineno > 0 { - start = r.index[lineno-1] - byteOffset - } - lines[n] = buf[start:end] - } - - if n < count { - return n, io.EOF - } - return n, nil -} - -// indexTo reads data and computes the line index until there is information -// for line or a read returns io.EOF. It returns an error if and only if there -// is an error reading data. -func (r *lineReaderAt) indexTo(line int64) error { - var buf [indexBufferSize]byte - - offset := r.lastOffset() - for int64(len(r.index)) < line { - n, err := r.r.ReadAt(buf[:], offset) - if err != nil && err != io.EOF { - return err - } - for _, b := range buf[:n] { - offset++ - if b == '\n' { - r.index = append(r.index, offset) - } - } - if err == io.EOF { - if offset > r.lastOffset() { - r.index = append(r.index, offset) - } - r.eof = true - break - } - } - return nil -} - -func (r *lineReaderAt) lastOffset() int64 { - if n := len(r.index); n > 0 { - return r.index[n-1] - } - return 0 -} - -// readBytes reads the bytes of the n lines starting at line and returns the -// bytes and the offset of the first byte in the underlying source. -func (r *lineReaderAt) readBytes(line, n int64) (b []byte, offset int64, err error) { - indexLen := int64(len(r.index)) - - var size int64 - if line > indexLen { - offset = r.index[indexLen-1] - } else if line > 0 { - offset = r.index[line-1] - } - if n > 0 { - if line+n > indexLen { - size = r.index[indexLen-1] - offset - } else { - size = r.index[line+n-1] - offset - } - } - - b = make([]byte, size) - if _, err := r.r.ReadAt(b, offset); err != nil { - if err == io.EOF { - err = errors.New("ReadLinesAt: corrupt line index or changed source data") - } - return nil, 0, err - } - return b, offset, nil -} - -func isLen(r io.ReaderAt, n int64) (bool, error) { - off := n - 1 - if off < 0 { - off = 0 - } - - var b [2]byte - nr, err := r.ReadAt(b[:], off) - if err == io.EOF { - return (n == 0 && nr == 0) || (n > 0 && nr == 1), nil - } - return false, err -} - -// copyFrom writes bytes starting from offset off in src to dst stopping at the -// end of src or at the first error. copyFrom returns the number of bytes -// written and any error. -func copyFrom(dst io.Writer, src io.ReaderAt, off int64) (written int64, err error) { - buf := make([]byte, byteBufferSize) - for { - nr, rerr := src.ReadAt(buf, off) - if nr > 0 { - nw, werr := dst.Write(buf[0:nr]) - if nw > 0 { - written += int64(nw) - } - if werr != nil { - err = werr - break - } - if nr != nw { - err = io.ErrShortWrite - break - } - off += int64(nr) - } - if rerr != nil { - if rerr != io.EOF { - err = rerr - } - break - } - } - return written, err -} - -// copyLinesFrom writes lines starting from line off in src to dst stopping at -// the end of src or at the first error. copyLinesFrom returns the number of -// lines written and any error. -func copyLinesFrom(dst io.Writer, src LineReaderAt, off int64) (written int64, err error) { - buf := make([][]byte, lineBufferSize) -ReadLoop: - for { - nr, rerr := src.ReadLinesAt(buf, off) - if nr > 0 { - for _, line := range buf[0:nr] { - nw, werr := dst.Write(line) - if nw > 0 { - written++ - } - if werr != nil { - err = werr - break ReadLoop - } - if len(line) != nw { - err = io.ErrShortWrite - break ReadLoop - } - } - off += int64(nr) - } - if rerr != nil { - if rerr != io.EOF { - err = rerr - } - break - } - } - return written, err -} diff --git a/pkg/gitdiff/parser.go b/pkg/gitdiff/parser.go deleted file mode 100644 index e8f8430..0000000 --- a/pkg/gitdiff/parser.go +++ /dev/null @@ -1,142 +0,0 @@ -// Package gitdiff parses and applies patches generated by Git. It supports -// line-oriented text patches, binary patches, and can also parse standard -// unified diffs generated by other tools. -package gitdiff - -import ( - "bufio" - "fmt" - "io" -) - -// Parse parses a patch with changes to one or more files. Any content before -// the first file is returned as the second value. If an error occurs while -// parsing, it returns all files parsed before the error. -// -// Parse expects to receive a single patch. If the input may contain multiple -// patches (for example, if it is an mbox file), callers should split it into -// individual patches and call Parse on each one. -func Parse(r io.Reader) ([]*File, string, error) { - p := newParser(r) - - if err := p.Next(); err != nil { - if err == io.EOF { - return nil, "", nil - } - return nil, "", err - } - - var preamble string - var files []*File - for { - file, pre, err := p.ParseNextFileHeader() - if err != nil { - return files, preamble, err - } - if len(files) == 0 { - preamble = pre - } - if file == nil { - break - } - - for _, fn := range []func(*File) (int, error){ - p.ParseTextFragments, - p.ParseBinaryFragments, - } { - n, err := fn(file) - if err != nil { - return files, preamble, err - } - if n > 0 { - break - } - } - - files = append(files, file) - } - - return files, preamble, nil -} - -// TODO(bkeyes): consider exporting the parser type with configuration -// this would enable OID validation, p-value guessing, and prefix stripping -// by allowing users to set or override defaults - -// parser invariants: -// - methods that parse objects: -// - start with the parser on the first line of the first object -// - if returning nil, do not advance -// - if returning an error, do not advance past the object -// - if returning an object, advance to the first line after the object -// - any exported parsing methods must initialize the parser by calling Next() - -type stringReader interface { - ReadString(delim byte) (string, error) -} - -type parser struct { - r stringReader - - eof bool - lineno int64 - lines [3]string -} - -func newParser(r io.Reader) *parser { - if r, ok := r.(stringReader); ok { - return &parser{r: r} - } - return &parser{r: bufio.NewReader(r)} -} - -// Next advances the parser by one line. It returns any error encountered while -// reading the line, including io.EOF when the end of stream is reached. -func (p *parser) Next() error { - if p.eof { - return io.EOF - } - - if p.lineno == 0 { - // on first call to next, need to shift in all lines - for i := 0; i < len(p.lines)-1; i++ { - if err := p.shiftLines(); err != nil && err != io.EOF { - return err - } - } - } - - err := p.shiftLines() - if err != nil && err != io.EOF { - return err - } - - p.lineno++ - if p.lines[0] == "" { - p.eof = true - return io.EOF - } - return nil -} - -func (p *parser) shiftLines() (err error) { - for i := 0; i < len(p.lines)-1; i++ { - p.lines[i] = p.lines[i+1] - } - p.lines[len(p.lines)-1], err = p.r.ReadString('\n') - return -} - -// Line returns a line from the parser without advancing it. A delta of 0 -// returns the current line, while higher deltas return read-ahead lines. It -// returns an empty string if the delta is higher than the available lines, -// either because of the buffer size or because the parser reached the end of -// the input. Valid lines always contain at least a newline character. -func (p *parser) Line(delta uint) string { - return p.lines[delta] -} - -// Errorf generates an error and appends the current line information. -func (p *parser) Errorf(delta int64, msg string, args ...interface{}) error { - return fmt.Errorf("gitdiff: line %d: %s", p.lineno+delta, fmt.Sprintf(msg, args...)) -} diff --git a/pkg/gitdiff/patch_header.go b/pkg/gitdiff/patch_header.go deleted file mode 100644 index f047059..0000000 --- a/pkg/gitdiff/patch_header.go +++ /dev/null @@ -1,470 +0,0 @@ -package gitdiff - -import ( - "bufio" - "errors" - "fmt" - "io" - "io/ioutil" - "mime/quotedprintable" - "net/mail" - "strconv" - "strings" - "time" - "unicode" -) - -const ( - mailHeaderPrefix = "From " - prettyHeaderPrefix = "commit " - mailMinimumHeaderPrefix = "From:" -) - -// PatchHeader is a parsed version of the preamble content that appears before -// the first diff in a patch. It includes metadata about the patch, such as the -// author and a subject. -type PatchHeader struct { - // The SHA of the commit the patch was generated from. Empty if the SHA is - // not included in the header. - SHA string - - // The author details of the patch. If these details are not included in - // the header, Author is nil and AuthorDate is the zero time. - Author *PatchIdentity - AuthorDate time.Time - - // The committer details of the patch. If these details are not included in - // the header, Committer is nil and CommitterDate is the zero time. - Committer *PatchIdentity - CommitterDate time.Time - - // The title and body of the commit message describing the changes in the - // patch. Empty if no message is included in the header. - Title string - Body string - - // If the preamble looks like an email, ParsePatchHeader will - // remove prefixes such as `Re: ` and `[PATCH v3 5/17]` from the - // Title and place them here. - SubjectPrefix string - - // If the preamble looks like an email, and it contains a `---` - // line, that line will be removed and everything after it will be - // placed in BodyAppendix. - BodyAppendix string -} - -// Message returns the commit message for the header. The message consists of -// the title and the body separated by an empty line. -func (h *PatchHeader) Message() string { - var msg strings.Builder - if h != nil { - msg.WriteString(h.Title) - if h.Body != "" { - msg.WriteString("\n\n") - msg.WriteString(h.Body) - } - } - return msg.String() -} - -// ParsePatchDate parses a patch date string. It returns the parsed time or an -// error if s has an unknown format. ParsePatchDate supports the iso, rfc, -// short, raw, unix, and default formats (with local variants) used by the -// --date flag in Git. -func ParsePatchDate(s string) (time.Time, error) { - const ( - isoFormat = "2006-01-02 15:04:05 -0700" - isoStrictFormat = "2006-01-02T15:04:05-07:00" - rfc2822Format = "Mon, 2 Jan 2006 15:04:05 -0700" - shortFormat = "2006-01-02" - defaultFormat = "Mon Jan 2 15:04:05 2006 -0700" - defaultLocalFormat = "Mon Jan 2 15:04:05 2006" - ) - - if s == "" { - return time.Time{}, nil - } - - for _, fmt := range []string{ - isoFormat, - isoStrictFormat, - rfc2822Format, - shortFormat, - defaultFormat, - defaultLocalFormat, - } { - if t, err := time.ParseInLocation(fmt, s, time.Local); err == nil { - return t, nil - } - } - - // unix format - if unix, err := strconv.ParseInt(s, 10, 64); err == nil { - return time.Unix(unix, 0), nil - } - - // raw format - if space := strings.IndexByte(s, ' '); space > 0 { - unix, uerr := strconv.ParseInt(s[:space], 10, 64) - zone, zerr := time.Parse("-0700", s[space+1:]) - if uerr == nil && zerr == nil { - return time.Unix(unix, 0).In(zone.Location()), nil - } - } - - return time.Time{}, fmt.Errorf("unknown date format: %s", s) -} - -// A PatchHeaderOption modifies the behavior of ParsePatchHeader. -type PatchHeaderOption func(*patchHeaderOptions) - -// SubjectCleanMode controls how ParsePatchHeader cleans subject lines when -// parsing mail-formatted patches. -type SubjectCleanMode int - -const ( - // SubjectCleanWhitespace removes leading and trailing whitespace. - SubjectCleanWhitespace SubjectCleanMode = iota - - // SubjectCleanAll removes leading and trailing whitespace, leading "Re:", - // "re:", and ":" strings, and leading strings enclosed by '[' and ']'. - // This is the default behavior of git (see `git mailinfo`) and this - // package. - SubjectCleanAll - - // SubjectCleanPatchOnly is the same as SubjectCleanAll, but only removes - // leading strings enclosed by '[' and ']' if they start with "PATCH". - SubjectCleanPatchOnly -) - -// WithSubjectCleanMode sets the SubjectCleanMode for header parsing. By -// default, uses SubjectCleanAll. -func WithSubjectCleanMode(m SubjectCleanMode) PatchHeaderOption { - return func(opts *patchHeaderOptions) { - opts.subjectCleanMode = m - } -} - -type patchHeaderOptions struct { - subjectCleanMode SubjectCleanMode -} - -// ParsePatchHeader parses the preamble string returned by [Parse] into a -// PatchHeader. Due to the variety of header formats, some fields of the parsed -// PatchHeader may be unset after parsing. -// -// Supported formats are the short, medium, full, fuller, and email pretty -// formats used by `git diff`, `git log`, and `git show` and the UNIX mailbox -// format used by `git format-patch`. -// -// When parsing mail-formatted headers, ParsePatchHeader tries to remove -// email-specific content from the title and body: -// -// - Based on the SubjectCleanMode, remove prefixes like reply markers and -// "[PATCH]" strings from the subject, saving any removed content in the -// SubjectPrefix field. Parsing always discards leading and trailing -// whitespace from the subject line. The default mode is SubjectCleanAll. -// -// - If the body contains a "---" line (3 hyphens), remove that line and any -// content after it from the body and save it in the BodyAppendix field. -// -// ParsePatchHeader tries to process content it does not understand wthout -// returning errors, but will return errors if well-identified content like -// dates or identies uses unknown or invalid formats. -func ParsePatchHeader(header string, options ...PatchHeaderOption) (*PatchHeader, error) { - opts := patchHeaderOptions{ - subjectCleanMode: SubjectCleanAll, // match git defaults - } - for _, optFn := range options { - optFn(&opts) - } - - header = strings.TrimSpace(header) - if header == "" { - return &PatchHeader{}, nil - } - - var firstLine, rest string - if idx := strings.IndexByte(header, '\n'); idx >= 0 { - firstLine = header[:idx] - rest = header[idx+1:] - } else { - firstLine = header - rest = "" - } - - switch { - case strings.HasPrefix(firstLine, mailHeaderPrefix): - return parseHeaderMail(firstLine, strings.NewReader(rest), opts) - - case strings.HasPrefix(firstLine, mailMinimumHeaderPrefix): - // With a minimum header, the first line is part of the actual mail - // content and needs to be parsed as part of the "rest" - return parseHeaderMail("", strings.NewReader(header), opts) - - case strings.HasPrefix(firstLine, prettyHeaderPrefix): - return parseHeaderPretty(firstLine, strings.NewReader(rest)) - } - - return nil, errors.New("unrecognized patch header format") -} - -func parseHeaderPretty(prettyLine string, r io.Reader) (*PatchHeader, error) { - const ( - authorPrefix = "Author:" - commitPrefix = "Commit:" - datePrefix = "Date:" - authorDatePrefix = "AuthorDate:" - commitDatePrefix = "CommitDate:" - ) - - h := &PatchHeader{} - - prettyLine = strings.TrimPrefix(prettyLine, prettyHeaderPrefix) - if i := strings.IndexByte(prettyLine, ' '); i > 0 { - h.SHA = prettyLine[:i] - } else { - h.SHA = prettyLine - } - - s := bufio.NewScanner(r) - for s.Scan() { - line := s.Text() - - // empty line marks end of fields, remaining lines are title/message - if strings.TrimSpace(line) == "" { - break - } - - switch { - case strings.HasPrefix(line, authorPrefix): - u, err := ParsePatchIdentity(line[len(authorPrefix):]) - if err != nil { - return nil, err - } - h.Author = &u - - case strings.HasPrefix(line, commitPrefix): - u, err := ParsePatchIdentity(line[len(commitPrefix):]) - if err != nil { - return nil, err - } - h.Committer = &u - - case strings.HasPrefix(line, datePrefix): - d, err := ParsePatchDate(strings.TrimSpace(line[len(datePrefix):])) - if err != nil { - return nil, err - } - h.AuthorDate = d - - case strings.HasPrefix(line, authorDatePrefix): - d, err := ParsePatchDate(strings.TrimSpace(line[len(authorDatePrefix):])) - if err != nil { - return nil, err - } - h.AuthorDate = d - - case strings.HasPrefix(line, commitDatePrefix): - d, err := ParsePatchDate(strings.TrimSpace(line[len(commitDatePrefix):])) - if err != nil { - return nil, err - } - h.CommitterDate = d - } - } - if s.Err() != nil { - return nil, s.Err() - } - - title, indent := scanMessageTitle(s) - if s.Err() != nil { - return nil, s.Err() - } - h.Title = title - - if title != "" { - // Don't check for an appendix, pretty headers do not contain them - body, _ := scanMessageBody(s, indent, false) - if s.Err() != nil { - return nil, s.Err() - } - h.Body = body - } - - return h, nil -} - -func scanMessageTitle(s *bufio.Scanner) (title string, indent string) { - var b strings.Builder - for i := 0; s.Scan(); i++ { - line := s.Text() - trimLine := strings.TrimSpace(line) - if trimLine == "" { - break - } - - if i == 0 { - if start := strings.IndexFunc(line, func(c rune) bool { return !unicode.IsSpace(c) }); start > 0 { - indent = line[:start] - } - } - if b.Len() > 0 { - b.WriteByte(' ') - } - b.WriteString(trimLine) - } - return b.String(), indent -} - -func scanMessageBody(s *bufio.Scanner, indent string, separateAppendix bool) (string, string) { - // Body and appendix - var body, appendix strings.Builder - c := &body - var empty int - for i := 0; s.Scan(); i++ { - line := s.Text() - - line = strings.TrimRightFunc(line, unicode.IsSpace) - line = strings.TrimPrefix(line, indent) - - if line == "" { - empty++ - continue - } - - // If requested, parse out "appendix" information (often added - // by `git format-patch` and removed by `git am`). - if separateAppendix && c == &body && line == "---" { - c = &appendix - continue - } - - if c.Len() > 0 { - c.WriteByte('\n') - if empty > 0 { - c.WriteByte('\n') - } - } - empty = 0 - - c.WriteString(line) - } - return body.String(), appendix.String() -} - -func parseHeaderMail(mailLine string, r io.Reader, opts patchHeaderOptions) (*PatchHeader, error) { - msg, err := mail.ReadMessage(r) - if err != nil { - return nil, err - } - - h := &PatchHeader{} - - if strings.HasPrefix(mailLine, mailHeaderPrefix) { - mailLine = strings.TrimPrefix(mailLine, mailHeaderPrefix) - if i := strings.IndexByte(mailLine, ' '); i > 0 { - h.SHA = mailLine[:i] - } - } - - from := msg.Header.Get("From") - if from != "" { - u, err := ParsePatchIdentity(from) - if err != nil { - return nil, err - } - h.Author = &u - } - - date := msg.Header.Get("Date") - if date != "" { - d, err := ParsePatchDate(date) - if err != nil { - return nil, err - } - h.AuthorDate = d - } - - subject := msg.Header.Get("Subject") - h.SubjectPrefix, h.Title = cleanSubject(subject, opts.subjectCleanMode) - - s := bufio.NewScanner(msg.Body) - h.Body, h.BodyAppendix = scanMessageBody(s, "", true) - if s.Err() != nil { - return nil, s.Err() - } - - return h, nil -} - -func cleanSubject(s string, mode SubjectCleanMode) (prefix string, subject string) { - switch mode { - case SubjectCleanAll, SubjectCleanPatchOnly: - case SubjectCleanWhitespace: - return "", strings.TrimSpace(decodeSubject(s)) - default: - panic(fmt.Sprintf("unknown clean mode: %d", mode)) - } - - // Based on the algorithm from Git in mailinfo.c:cleanup_subject() - // If compatibility with `git am` drifts, go there to see if there are any updates. - - at := 0 - for at < len(s) { - switch s[at] { - case 'r', 'R': - // Detect re:, Re:, rE: and RE: - if at+2 < len(s) && (s[at+1] == 'e' || s[at+1] == 'E') && s[at+2] == ':' { - at += 3 - continue - } - - case ' ', '\t', ':': - // Delete whitespace and duplicate ':' characters - at++ - continue - - case '[': - if i := strings.IndexByte(s[at:], ']'); i > 0 { - if mode == SubjectCleanAll || strings.Contains(s[at:at+i+1], "PATCH") { - at += i + 1 - continue - } - } - } - - // Nothing was removed, end processing - break - } - - prefix = strings.TrimLeftFunc(s[:at], unicode.IsSpace) - subject = strings.TrimRightFunc(decodeSubject(s[at:]), unicode.IsSpace) - return -} - -// Decodes a subject line. Currently only supports quoted-printable UTF-8. This format is the result -// of a `git format-patch` when the commit title has a non-ASCII character (i.e. an emoji). -// See for reference: https://stackoverflow.com/questions/27695749/gmail-api-not-respecting-utf-encoding-in-subject -func decodeSubject(encoded string) string { - if !strings.HasPrefix(encoded, "=?UTF-8?q?") { - // not UTF-8 encoded - return encoded - } - - // If the subject is too long, `git format-patch` may produce a subject line across - // multiple lines. When parsed, this can look like the following: - // <UTF8-prefix><first-line> <UTF8-prefix><second-line> - payload := " " + encoded - payload = strings.ReplaceAll(payload, " =?UTF-8?q?", "") - payload = strings.ReplaceAll(payload, "?=", "") - - decoded, err := ioutil.ReadAll(quotedprintable.NewReader(strings.NewReader(payload))) - if err != nil { - // if err, abort decoding and return original subject - return encoded - } - - return string(decoded) -} diff --git a/pkg/gitdiff/patch_identity.go b/pkg/gitdiff/patch_identity.go deleted file mode 100644 index 018f80c..0000000 --- a/pkg/gitdiff/patch_identity.go +++ /dev/null @@ -1,166 +0,0 @@ -package gitdiff - -import ( - "fmt" - "strings" -) - -// PatchIdentity identifies a person who authored or committed a patch. -type PatchIdentity struct { - Name string - Email string -} - -func (i PatchIdentity) String() string { - name := i.Name - if name == "" { - name = `""` - } - return fmt.Sprintf("%s <%s>", name, i.Email) -} - -// ParsePatchIdentity parses a patch identity string. A patch identity contains -// an email address and an optional name in [RFC 5322] format. This is either a -// plain email adddress or a name followed by an address in angle brackets: -// -// author@example.com -// Author Name <author@example.com> -// -// If the input is not one of these formats, ParsePatchIdentity applies a -// heuristic to separate the name and email portions. If both the name and -// email are missing or empty, ParsePatchIdentity returns an error. It -// otherwise does not validate the result. -// -// [RFC 5322]: https://datatracker.ietf.org/doc/html/rfc5322 -func ParsePatchIdentity(s string) (PatchIdentity, error) { - s = normalizeSpace(s) - s = unquotePairs(s) - - var name, email string - if at := strings.IndexByte(s, '@'); at >= 0 { - start, end := at, at - for start >= 0 && !isRFC5332Space(s[start]) && s[start] != '<' { - start-- - } - for end < len(s) && !isRFC5332Space(s[end]) && s[end] != '>' { - end++ - } - email = s[start+1 : end] - - // Adjust the boundaries so that we drop angle brackets, but keep - // spaces when removing the email to form the name. - if start < 0 || s[start] != '<' { - start++ - } - if end >= len(s) || s[end] != '>' { - end-- - } - name = s[:start] + s[end+1:] - } else { - start, end := 0, 0 - for i := 0; i < len(s); i++ { - if s[i] == '<' && start == 0 { - start = i + 1 - } - if s[i] == '>' && start > 0 { - end = i - break - } - } - if start > 0 && end >= start { - email = strings.TrimSpace(s[start:end]) - name = s[:start-1] - } - } - - // After extracting the email, the name might contain extra whitespace - // again and may be surrounded by comment characters. The git source gives - // these examples of when this can happen: - // - // "Name <email@domain>" - // "email@domain (Name)" - // "Name <email@domain> (Comment)" - // - name = normalizeSpace(name) - if strings.HasPrefix(name, "(") && strings.HasSuffix(name, ")") { - name = name[1 : len(name)-1] - } - name = strings.TrimSpace(name) - - // If the name is empty or contains email-like characters, use the email - // instead (assuming one exists) - if name == "" || strings.ContainsAny(name, "@<>") { - name = email - } - - if name == "" && email == "" { - return PatchIdentity{}, fmt.Errorf("invalid identity string %q", s) - } - return PatchIdentity{Name: name, Email: email}, nil -} - -// unquotePairs process the RFC5322 tokens "quoted-string" and "comment" to -// remove any "quoted-pairs" (backslash-espaced characters). It also removes -// the quotes from any quoted strings, but leaves the comment delimiters. -func unquotePairs(s string) string { - quote := false - comments := 0 - escaped := false - - var out strings.Builder - for i := 0; i < len(s); i++ { - if escaped { - escaped = false - } else { - switch s[i] { - case '\\': - // quoted-pair is only allowed in quoted-string/comment - if quote || comments > 0 { - escaped = true - continue // drop '\' character - } - - case '"': - if comments == 0 { - quote = !quote - continue // drop '"' character - } - - case '(': - if !quote { - comments++ - } - case ')': - if comments > 0 { - comments-- - } - } - } - out.WriteByte(s[i]) - } - return out.String() -} - -// normalizeSpace trims leading and trailing whitespace from s and converts -// inner sequences of one or more whitespace characters to single spaces. -func normalizeSpace(s string) string { - var sb strings.Builder - for i := 0; i < len(s); i++ { - c := s[i] - if !isRFC5332Space(c) { - if sb.Len() > 0 && isRFC5332Space(s[i-1]) { - sb.WriteByte(' ') - } - sb.WriteByte(c) - } - } - return sb.String() -} - -func isRFC5332Space(c byte) bool { - switch c { - case '\t', '\n', '\r', ' ': - return true - } - return false -} diff --git a/pkg/gitdiff/text.go b/pkg/gitdiff/text.go deleted file mode 100644 index ee30792..0000000 --- a/pkg/gitdiff/text.go +++ /dev/null @@ -1,192 +0,0 @@ -package gitdiff - -import ( - "fmt" - "io" - "strconv" - "strings" -) - -// ParseTextFragments parses text fragments until the next file header or the -// end of the stream and attaches them to the given file. It returns the number -// of fragments that were added. -func (p *parser) ParseTextFragments(f *File) (n int, err error) { - for { - frag, err := p.ParseTextFragmentHeader() - if err != nil { - return n, err - } - if frag == nil { - return n, nil - } - - if f.IsNew && frag.OldLines > 0 { - return n, p.Errorf(-1, "new file depends on old contents") - } - if f.IsDelete && frag.NewLines > 0 { - return n, p.Errorf(-1, "deleted file still has contents") - } - - if err := p.ParseTextChunk(frag); err != nil { - return n, err - } - - f.TextFragments = append(f.TextFragments, frag) - n++ - } -} - -func (p *parser) ParseTextFragmentHeader() (*TextFragment, error) { - const ( - startMark = "@@ -" - endMark = " @@" - ) - - if !strings.HasPrefix(p.Line(0), startMark) { - return nil, nil - } - - parts := strings.SplitAfterN(p.Line(0), endMark, 2) - if len(parts) < 2 { - return nil, p.Errorf(0, "invalid fragment header") - } - - f := &TextFragment{} - f.Comment = strings.TrimSpace(parts[1]) - - header := parts[0][len(startMark) : len(parts[0])-len(endMark)] - ranges := strings.Split(header, " +") - if len(ranges) != 2 { - return nil, p.Errorf(0, "invalid fragment header") - } - - var err error - if f.OldPosition, f.OldLines, err = parseRange(ranges[0]); err != nil { - return nil, p.Errorf(0, "invalid fragment header: %v", err) - } - if f.NewPosition, f.NewLines, err = parseRange(ranges[1]); err != nil { - return nil, p.Errorf(0, "invalid fragment header: %v", err) - } - - if err := p.Next(); err != nil && err != io.EOF { - return nil, err - } - return f, nil -} - -func (p *parser) ParseTextChunk(frag *TextFragment) error { - if p.Line(0) == "" { - return p.Errorf(0, "no content following fragment header") - } - - oldLines, newLines := frag.OldLines, frag.NewLines - for oldLines > 0 || newLines > 0 { - line := p.Line(0) - op, data := line[0], line[1:] - - switch op { - case '\n': - data = "\n" - fallthrough // newer GNU diff versions create empty context lines - case ' ': - oldLines-- - newLines-- - if frag.LinesAdded == 0 && frag.LinesDeleted == 0 { - frag.LeadingContext++ - } else { - frag.TrailingContext++ - } - frag.Lines = append(frag.Lines, Line{OpContext, data}) - case '-': - oldLines-- - frag.LinesDeleted++ - frag.TrailingContext = 0 - frag.Lines = append(frag.Lines, Line{OpDelete, data}) - case '+': - newLines-- - frag.LinesAdded++ - frag.TrailingContext = 0 - frag.Lines = append(frag.Lines, Line{OpAdd, data}) - case '\\': - // this may appear in middle of fragment if it's for a deleted line - if isNoNewlineMarker(line) { - removeLastNewline(frag) - break - } - fallthrough - default: - // TODO(bkeyes): if this is because we hit the next header, it - // would be helpful to return the miscounts line error. We could - // either test for the common headers ("@@ -", "diff --git") or - // assume any invalid op ends the fragment; git returns the same - // generic error in all cases so either is compatible - return p.Errorf(0, "invalid line operation: %q", op) - } - - if err := p.Next(); err != nil { - if err == io.EOF { - break - } - return err - } - } - - if oldLines != 0 || newLines != 0 { - hdr := max(frag.OldLines-oldLines, frag.NewLines-newLines) + 1 - return p.Errorf(-hdr, "fragment header miscounts lines: %+d old, %+d new", -oldLines, -newLines) - } - if frag.LinesAdded == 0 && frag.LinesDeleted == 0 { - return p.Errorf(0, "fragment contains no changes") - } - - // check for a final "no newline" marker since it is not included in the - // counters used to stop the loop above - if isNoNewlineMarker(p.Line(0)) { - removeLastNewline(frag) - if err := p.Next(); err != nil && err != io.EOF { - return err - } - } - - return nil -} - -func isNoNewlineMarker(s string) bool { - // test for "\ No newline at end of file" by prefix because the text - // changes by locale (git claims all versions are at least 12 chars) - return len(s) >= 12 && s[:2] == "\\ " -} - -func removeLastNewline(frag *TextFragment) { - if len(frag.Lines) > 0 { - last := &frag.Lines[len(frag.Lines)-1] - last.Line = strings.TrimSuffix(last.Line, "\n") - } -} - -func parseRange(s string) (start int64, end int64, err error) { - parts := strings.SplitN(s, ",", 2) - - if start, err = strconv.ParseInt(parts[0], 10, 64); err != nil { - nerr := err.(*strconv.NumError) - return 0, 0, fmt.Errorf("bad start of range: %s: %v", parts[0], nerr.Err) - } - - if len(parts) > 1 { - if end, err = strconv.ParseInt(parts[1], 10, 64); err != nil { - nerr := err.(*strconv.NumError) - return 0, 0, fmt.Errorf("bad end of range: %s: %v", parts[1], nerr.Err) - } - } else { - end = 1 - } - - return -} - -func max(a, b int64) int64 { - if a > b { - return a - } - return b -} diff --git a/pkg/links/links.go b/pkg/links/links.go deleted file mode 100644 index 0795dfc..0000000 --- a/pkg/links/links.go +++ /dev/null @@ -1,179 +0,0 @@ -package links - -import ( - "bytes" - "net/url" - "path" - "strings" - - "golang.org/x/net/html" - - "mokhan.ca/antonmedv/gitmal/pkg/git" -) - -type Set map[string]struct{} - -func BuildDirSet(files []git.Blob) Set { - dirs := make(Set) - for _, f := range files { - dir := path.Dir(f.Path) - for dir != "." && dir != "/" { - if _, ok := dirs[dir]; ok { - break - } - dirs[dir] = struct{}{} - if i := strings.LastIndex(dir, "/"); i != -1 { - dir = dir[:i] - } else { - break - } - } - } - return dirs -} - -func BuildFileSet(files []git.Blob) Set { - filesSet := make(Set) - for _, f := range files { - filesSet[f.Path] = struct{}{} - } - return filesSet -} - -func Resolve(content, currentPath, rootHref, ref string, dirs, files Set) string { - doc, err := html.Parse(strings.NewReader(content)) - if err != nil { - return content - } - - baseDir := path.Dir(currentPath) - - var walk func(*html.Node) - walk = func(n *html.Node) { - if n.Type == html.ElementNode { - switch n.Data { - case "a": - for i, attr := range n.Attr { - if attr.Key == "href" { - newHref := transformHref(attr.Val, baseDir, rootHref, ref, files, dirs) - n.Attr[i].Val = newHref - break - } - } - case "img": - for i, attr := range n.Attr { - if attr.Key == "src" { - newSrc := transformImgSrc(attr.Val, baseDir, rootHref, ref) - n.Attr[i].Val = newSrc - break - } - } - } - } - - for c := n.FirstChild; c != nil; c = c.NextSibling { - walk(c) - } - } - walk(doc) - - var buf bytes.Buffer - if err := html.Render(&buf, doc); err != nil { - return content - } - - return buf.String() -} - -func transformHref(href, baseDir, rootHref, ref string, files, dirs Set) string { - if href == "" { - return href - } - if strings.HasPrefix(href, "#") { - return href - } - - u, err := url.Parse(href) - if err != nil { - return href - } - - // Absolute URLs are left untouched - if u.IsAbs() { - return href - } - - // Skip mailto:, javascript:, data: etc. (url.Parse sets Scheme) - if u.Scheme != "" { - return href - } - - // Resolve against the directory of the current file - relPath := u.Path - if relPath == "" { - return href - } - - var repoPath string - - if strings.HasPrefix(relPath, "/") { - // Root-relative repo path - relPath = strings.TrimPrefix(relPath, "/") - repoPath = path.Clean(relPath) - } else { - // Relative to current file directory - repoPath = path.Clean(path.Join(baseDir, relPath)) - } - - // Decide if this is a file or a directory in the repo - var newPath string - - // 1) Exact file match - if _, ok := files[repoPath]; ok { - newPath = repoPath + ".html" - } else if _, ok := files[repoPath+".md"]; ok { - // 2) Maybe the link omitted ".md" but the repo has it - newPath = repoPath + ".md.html" - } else if _, ok := dirs[repoPath]; ok { - // 3) Directory: add /index.html - newPath = path.Join(repoPath, "index.html") - } else { - // Unknown target, leave as-is - return href - } - - // Link from the root href - newPath = path.Join(rootHref, "blob", ref, newPath) - - // Preserve any query/fragment if they existed - u.Path = newPath - return u.String() -} - -func transformImgSrc(src, baseDir, rootHref, ref string) string { - u, err := url.Parse(src) - if err != nil { - return src - } - - if u.IsAbs() { - return src - } - - relPath := u.Path - - var repoPath string - if strings.HasPrefix(relPath, "/") { - // Root-relative: drop leading slash - repoPath = strings.TrimPrefix(relPath, "/") - } else { - // Resolve against current file directory - repoPath = path.Clean(path.Join(baseDir, relPath)) - } - - final := path.Join(rootHref, "raw", ref, repoPath) - - // Preserve any query/fragment if they existed - u.Path = final - return u.String() -} diff --git a/pkg/progress_bar/progress_bar.go b/pkg/progress_bar/progress_bar.go deleted file mode 100644 index 8f39ec5..0000000 --- a/pkg/progress_bar/progress_bar.go +++ /dev/null @@ -1,84 +0,0 @@ -package progress_bar - -import ( - "fmt" - "os" - "strings" - "sync" - "sync/atomic" - "time" -) - -type ProgressBar struct { - label string - total int64 - current int64 - stop chan struct{} - wg sync.WaitGroup -} - -func NewProgressBar(label string, total int) *ProgressBar { - p := &ProgressBar{label: label, total: int64(total), stop: make(chan struct{})} - p.wg.Add(1) - go func() { - defer p.wg.Done() - ticker := time.NewTicker(100 * time.Millisecond) - defer ticker.Stop() - // initial draw - p.draw(atomic.LoadInt64(&p.current)) - for { - select { - case <-p.stop: - return - case <-ticker.C: - cur := atomic.LoadInt64(&p.current) - if cur > p.total { - cur = p.total - } - p.draw(cur) - } - } - }() - return p -} - -func (p *ProgressBar) Inc() { - for { - cur := atomic.LoadInt64(&p.current) - if cur >= p.total { - return - } - if atomic.CompareAndSwapInt64(&p.current, cur, cur+1) { - return - } - // retry on race - } -} - -func (p *ProgressBar) Done() { - atomic.StoreInt64(&p.current, p.total) - close(p.stop) - p.wg.Wait() - p.draw(p.total) - _, _ = fmt.Fprintln(os.Stderr) -} - -func (p *ProgressBar) draw(current int64) { - if p.total <= 0 { - return - } - percent := 0 - if p.total > 0 { - percent = int(current * 100 / p.total) - } - barLen := 24 - filled := 0 - if p.total > 0 { - filled = int(current * int64(barLen) / p.total) - } - if filled > barLen { - filled = barLen - } - bar := strings.Repeat("#", filled) + strings.Repeat(" ", barLen-filled) - _, _ = fmt.Fprintf(os.Stderr, "\r[%s] %4d/%-4d (%3d%%) %s", bar, current, p.total, percent, p.label) -} diff --git a/pkg/templates/blob.gohtml b/pkg/templates/blob.gohtml deleted file mode 100644 index b98e2b3..0000000 --- a/pkg/templates/blob.gohtml +++ /dev/null @@ -1,72 +0,0 @@ -{{- /*gotype: mokhan.ca/antonmedv/gitmal/pkg/templates.BlobParams */ -}} -{{ define "head" }} - <style> - {{ .CSS }} - - [id] { - scroll-margin-top: var(--header-height); - } - - pre { - border: 1px solid var(--c-border); - border-top: none; - border-bottom-left-radius: var(--border-radius); - border-bottom-right-radius: var(--border-radius); - } - - pre { - margin: 0; - padding: 8px 16px; - overflow-x: auto; - white-space: pre; - word-spacing: normal; - word-break: normal; - word-wrap: normal; - tab-size: 4; - font-family: var(--font-family-mono), monospace; - } - - pre > code { - display: block; - padding: 0 16px; - width: fit-content; - min-width: 100%; - line-height: var(--code-line-height); - font-size: var(--code-font-size); - } - - .border { - border: 1px solid var(--c-border); - border-top: none; - border-bottom-left-radius: 6px; - border-bottom-right-radius: 6px; - } - - .binary-file { - padding: 8px 16px; - font-style: italic; - } - - .image { - padding: 8px 16px; - text-align: center; - } - - .image img { - max-width: 100%; - height: auto; - } - </style> -{{ end }} - -{{ define "body" }} - {{ template "header" . }} - {{ if .IsImage }} - <div class="image border">{{.Content}}</div> - {{ else if .IsBinary}} - <div class="binary-file border">Binary file</div> - {{ else }} - {{ .Content }} - {{ end }} -{{ end }} - diff --git a/pkg/templates/branches.gohtml b/pkg/templates/branches.gohtml deleted file mode 100644 index 743461d..0000000 --- a/pkg/templates/branches.gohtml +++ /dev/null @@ -1,58 +0,0 @@ -{{- /*gotype: mokhan.ca/antonmedv/gitmal/pkg/templates.BranchesParams*/ -}} -{{ define "head" }} - <style> - .branches { - border: 1px solid var(--c-border); - border-radius: var(--border-radius); - overflow: hidden; - } - - .branch-row { - display: flex; - align-items: center; - gap: 12px; - height: 44px; - padding-inline: 16px; - border-bottom: 1px solid var(--c-border); - background-color: var(--c-bg-elv); - } - - .branch-row:last-child { - border-bottom: none; - } - - .branch-row a { - color: var(--c-text-1); - } - - .branch-row a:hover { - color: var(--c-brand-2); - text-decoration: none; - } - - .badge { - font-size: 12px; - color: var(--c-text-2); - border: 1px solid var(--c-border); - padding: 2px 6px; - border-radius: 999px; - } - </style> -{{ end }} - -{{ define "body" }} - <h1>Branches</h1> - <div class="branches"> - {{ if .Branches }} - {{ range .Branches }} - <div class="branch-row"> - <a href="{{ .Href }}">{{ .Name }}</a> - {{ if .IsDefault }}<span class="badge">default</span>{{ end }} - <a href="{{ .CommitsHref }}" style="margin-left:auto; font-size:13px; color: var(--c-text-2)">commits →</a> - </div> - {{ end }} - {{ else }} - <div class="branch-row">(no branches)</div> - {{ end }} - </div> -{{ end }} diff --git a/pkg/templates/commit.gohtml b/pkg/templates/commit.gohtml deleted file mode 100644 index 3176f97..0000000 --- a/pkg/templates/commit.gohtml +++ /dev/null @@ -1,314 +0,0 @@ -{{- /*gotype: mokhan.ca/antonmedv/gitmal/pkg/templates.CommitParams*/ -}} -{{ define "head" }} - <style> - h1 code { - border-radius: var(--border-radius); - background: var(--c-bg-alt); - padding: 4px 8px; - font-family: var(--font-family-mono), monospace; - font-weight: 500; - } - - .commit { - display: flex; - flex-direction: column; - gap: 16px; - margin-bottom: 16px; - } - - .commit-info { - display: flex; - flex-direction: row; - gap: 8px; - align-items: flex-end; - } - - .commit-author { - color: var(--c-text-1); - font-weight: 600; - min-width: 0; - overflow-wrap: break-word; - } - - .commit-date { - color: var(--c-text-2); - font-size: 12px; - font-family: var(--font-family-mono), monospace; - } - - .commit-message { - border: 1px solid var(--c-border); - border-radius: var(--border-radius); - padding: 16px; - } - - .ref-badges { - display: inline-flex; - gap: 6px; - margin-left: 8px; - } - - .badge { - font-size: 12px; - color: var(--c-text-2); - border: 1px solid var(--c-border); - padding: 2px 6px; - border-radius: 999px; - white-space: nowrap; - } - - .commit-subject { - font-size: 16px; - font-weight: 600; - line-height: 1.3; - hyphens: auto; - } - - .commit-body { - margin-top: 16px; - white-space: pre-wrap; - line-height: 1.5; - hyphens: auto; - } - - .commit-subinfo { - display: flex; - flex-direction: row; - gap: 16px; - justify-content: space-between; - } - - .commit-layout { - display: grid; - grid-template-columns: 1fr; - gap: 16px; - } - - @media (min-width: 960px) { - .commit-layout { - grid-template-columns: 300px 1fr; - align-items: start; - } - - .files-tree { - position: sticky; - top: 16px; - } - - .files-tree-content { - max-height: calc(100vh - var(--header-height) - 40px); - overflow: auto; - } - } - - .files-tree { - border: 1px solid var(--c-border); - border-radius: var(--border-radius); - } - - .files-tree-header { - display: flex; - flex-direction: row; - align-items: center; - padding-inline: 16px; - height: var(--header-height); - border-bottom: 1px solid var(--c-border); - border-top-left-radius: var(--border-radius); - border-top-right-radius: var(--border-radius); - background: var(--c-bg-alt); - font-size: 14px; - font-weight: 600; - } - - .files-tree-content { - display: block; - padding-block: 6px; - } - - .tree .children { - margin-left: 16px; - border-left: 1px dashed var(--c-border); - } - - .tree .node { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 16px; - } - - .tree .file-name { - flex: 1; - font-weight: 500; - color: var(--c-text-1); - cursor: pointer; - } - - .tree .file-name:hover { - color: var(--c-brand-2); - text-decoration: underline; - } - - .tree .dir { - color: var(--c-dir); - } - - .tree .file-added { - color: var(--c-green); - } - - .tree .file-deleted { - color: var(--c-red); - } - - .tree .file-renamed { - color: var(--c-yellow); - } - - /* Per-file sections */ - - .files { - min-width: 0; - } - - .file-section + .file-section { - margin-top: 16px; - } - - pre { - border: 1px solid var(--c-border); - border-top: none; - border-bottom-left-radius: var(--border-radius); - border-bottom-right-radius: var(--border-radius); - } - - pre { - margin: 0; - padding: 8px 16px; - overflow-x: auto; - white-space: pre; - word-spacing: normal; - word-break: normal; - word-wrap: normal; - tab-size: 4; - font-family: var(--font-family-mono), monospace; - } - - pre > code { - display: block; - padding: 0 16px; - width: fit-content; - min-width: 100%; - line-height: var(--code-line-height); - font-size: var(--code-font-size); - } - - .binary-file { - padding: 8px 16px; - font-style: italic; - } - - .border { - border: 1px solid var(--c-border); - border-top: none; - border-bottom-left-radius: 6px; - border-bottom-right-radius: 6px; - } - - {{ .DiffCSS }} - </style> -{{ end }} - -{{ define "body" }} - <h1>Commit <code>{{ .Commit.ShortHash }}</code></h1> - - <div class="commit"> - <div class="commit-info"> - <div class="commit-author">{{ .Commit.Author }} <{{ .Commit.Email }}></div> - <div class="commit-date">{{ .Commit.Date | FormatDate }}</div> - </div> - <div class="commit-message"> - <div class="commit-subject"> - {{ .Commit.Subject }} - {{ if .Commit.RefNames }} - <span class="ref-badges"> - {{ range .Commit.RefNames }} - {{ if or (eq .Kind "Branch") (eq .Kind "Tag") }} - <span class="badge">{{ if eq .Kind "Tag" }}tag: {{ end }}{{ .Name }}</span> - {{ end }} - {{ end }} - </span> - {{ end }} - </div> - {{ if .Commit.Body }} - <div class="commit-body">{{ .Commit.Body }}</div> - {{ end }} - </div> - <div class="commit-subinfo"> - <div class="commit-branch"> - {{ if .Commit.Branch }} - <a href="../commits/{{ .Commit.Branch.DirName }}/index.html" class="badge">{{ .Commit.Branch }}</a> - {{ end }} - </div> - {{ if .Commit.Parents }} - <div class="commit-parents"> - {{ if eq (len .Commit.Parents) 1 }} - 1 parent - {{ else }} - {{ .Commit.Parents | len }} parents - {{ end }} - {{ range $i, $p := .Commit.Parents }} - {{ if gt $i 0 }}, {{ end }} - <a href="{{ $p }}.html"><code>{{ $p | ShortHash }}</code></a> - {{ end }} - </div> - {{ end }} - </div> - </div> - - <div class="commit-layout"> - <div class="files-tree"> - <div class="files-tree-header">Changed files ({{ len .FileViews }})</div> - <div class="files-tree-content"> - {{ if .FileTree }} - <div class="tree"> - {{ template "file_tree" (FileTreeParams .FileTree) }} - </div> - {{ else }} - <div style="color: var(--c-text-3)">(no files)</div> - {{ end }} - </div> - </div> - - <div class="files"> - {{ range .FileViews }} - <section id="{{ .Path }}" class="file-section"> - <div class="header-container"> - <header class="file-header"> - {{ if .IsRename }} - <div class="path">{{ .OldName }} → {{ .NewName }}</div> - {{ else }} - <div class="path">{{ .Path }}</div> - {{ end }} - <button class="goto-top" onclick="window.scrollTo({top: 0, behavior: 'smooth'})"> - <svg aria-hidden="true" focusable="false" width="16" height="16"> - <use xlink:href="#arrow-top"></use> - </svg> - Top - </button> - </header> - </div> - {{ if .IsBinary }} - <div class="binary-file border">Binary file</div> - {{ else if (and .IsRename (not .HasChanges)) }} - <div class="binary-file border">File renamed without changes</div> - {{ else }} - <div class="file-diff"> - {{ .HTML }} - </div> - {{ end }} - </section> - {{ end }} - </div> - </div> -{{ end }} diff --git a/pkg/templates/commits_list.gohtml b/pkg/templates/commits_list.gohtml deleted file mode 100644 index 76dd43c..0000000 --- a/pkg/templates/commits_list.gohtml +++ /dev/null @@ -1,203 +0,0 @@ -{{- /*gotype: mokhan.ca/antonmedv/gitmal/pkg/templates.CommitsListParams*/ -}} -{{ define "head" }} - <style> - .commits { - border: 1px solid var(--c-border); - border-top: none; - border-bottom-left-radius: var(--border-radius); - border-bottom-right-radius: var(--border-radius); - overflow-x: auto; - } - - .row { - display: flex; - height: 40px; - border-bottom: 1px solid var(--c-border); - padding-inline: 16px; - gap: 16px; - } - - .row:last-child { - border-bottom: none; - } - - .row:hover { - background-color: var(--c-bg-alt); - } - - .cell { - display: flex; - gap: 8px; - align-items: center; - } - - .hash a { - font-family: var(--font-family-mono), monospace; - color: var(--c-text-2); - } - - .commit-title { - flex: 1; - } - - .commit-title a { - color: var(--c-text-1); - line-height: 1.5; - } - - .commit-title a:hover { - color: var(--c-brand-2); - } - - .ref-badges { - display: inline-flex; - gap: 6px; - margin-left: 8px; - } - - .badge { - font-size: 12px; - color: var(--c-text-2); - border: 1px solid var(--c-border); - padding: 2px 6px; - border-radius: 999px; - white-space: nowrap; - } - - .date { - font-family: var(--font-family-mono), monospace; - font-size: 12px; - color: var(--c-text-2); - } - - .pagination { - display: flex; - gap: 8px; - align-items: center; - justify-content: center; - margin-top: 12px; - padding: 8px 0; - color: var(--c-text-2); - } - - .pagination a, - .pagination span.btn { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 6px 10px; - border: 1px solid var(--c-border); - border-radius: 6px; - background: var(--c-bg-elv); - color: var(--c-text-1); - text-decoration: none; - font-size: 13px; - } - - .pagination a:hover { - color: var(--c-brand-2); - border-color: var(--c-brand-2); - } - - .pagination .disabled { - opacity: 0.5; - pointer-events: none; - } - - .pagination .page-indicator { - margin: 0 4px; - font-size: 13px; - color: var(--c-text-2); - } - - /* Mobile optimizations */ - @media (max-width: 767px) { - .row { - height: auto; - padding: 10px 12px; - gap: 12px; - flex-wrap: wrap; - align-items: flex-start; - } - - .commit-title { - flex-basis: 100%; - order: 1; - } - - .author, .date, .hash { - font-size: 12px; - } - - .author { - color: var(--c-text-2); - order: 2; - } - - .date { - order: 2; - } - - .hash { - margin-left: auto; - order: 3; - } - } - </style> -{{ end }} - -{{ define "body" }} - {{ template "header" . }} - <div class="commits"> - {{ range .Commits }} - <div class="row"> - <div class="cell hash"> - <a href="{{ .Href }}">{{ .ShortHash }}</a> - </div> - <div class="cell commit-title"> - <a href="{{ .Href }}">{{ .Subject }}</a> - {{ if .RefNames }} - <div class="ref-badges"> - {{ range .RefNames }} - {{ if or (eq .Kind "Branch") (eq .Kind "Tag") }} - <span class="badge">{{ if eq .Kind "Tag" }}tag: {{ end }}{{ .Name }}</span> - {{ end }} - {{ end }} - </div> - {{ end }} - </div> - <div class="cell author">{{ .Author }}</div> - <div class="cell date">{{ .Date | FormatDate }}</div> - </div> - {{ else}} - <div class="row">(no commits)</div> - {{ end }} - </div> - <div class="pagination"> - {{ if .Page.FirstHref }} - <a class="btn" href="{{ .Page.FirstHref }}">« First</a> - {{ else }} - <span class="btn disabled">« First</span> - {{ end }} - - {{ if .Page.PrevHref }} - <a class="btn" href="{{ .Page.PrevHref }}">← Newer</a> - {{ else }} - <span class="btn disabled">← Newer</span> - {{ end }} - - <span class="page-indicator">Page {{ .Page.Page }} of {{ .Page.TotalPages }}</span> - - {{ if .Page.NextHref }} - <a class="btn" href="{{ .Page.NextHref }}">Older →</a> - {{ else }} - <span class="btn disabled">Older →</span> - {{ end }} - - {{ if .Page.LastHref }} - <a class="btn" href="{{ .Page.LastHref }}">Last »</a> - {{ else }} - <span class="btn disabled">Last »</span> - {{ end }} - </div> -{{ end }} diff --git a/pkg/templates/css/markdown_dark.css b/pkg/templates/css/markdown_dark.css deleted file mode 100644 index 0d5d47d..0000000 --- a/pkg/templates/css/markdown_dark.css +++ /dev/null @@ -1,1042 +0,0 @@ -.markdown-body details, -.markdown-body figcaption, -.markdown-body figure { - display: block; -} - -.markdown-body summary { - display: list-item; -} - -.markdown-body [hidden] { - display: none !important; -} - -.markdown-body a { - background-color: transparent; - text-decoration: none; -} - -.markdown-body abbr[title] { - border-bottom: none; - text-decoration: underline dotted; -} - -.markdown-body b, -.markdown-body strong { - font-weight: 600; -} - -.markdown-body dfn { - font-style: italic; -} - -.markdown-body h1 { - margin: .67em 0; - font-weight: 600; - padding-bottom: .3em; - font-size: 2em; - border-bottom: 1px solid #3d444db3; -} - -.markdown-body mark { - background-color: #bb800926; - color: #f0f6fc; -} - -.markdown-body small { - font-size: 90%; -} - -.markdown-body sub, -.markdown-body sup { - font-size: 75%; - line-height: 0; - position: relative; - vertical-align: baseline; -} - -.markdown-body sub { - bottom: -0.25em; -} - -.markdown-body sup { - top: -0.5em; -} - -.markdown-body img { - border-style: none; - max-width: 100%; - box-sizing: content-box; -} - -.markdown-body code, -.markdown-body kbd, -.markdown-body pre, -.markdown-body samp { - font-family: monospace; - font-size: 1em; -} - -.markdown-body figure { - margin: 1em 2.5rem; -} - -.markdown-body hr { - box-sizing: content-box; - overflow: hidden; - border-bottom: 1px solid #3d444db3; - height: .25em; - padding: 0; - margin: 1.5rem 0; - background-color: #3d444d; -} - -.markdown-body input { - font: inherit; - margin: 0; - overflow: visible; - line-height: inherit; -} - -.markdown-body [type=button], -.markdown-body [type=reset], -.markdown-body [type=submit] { - -webkit-appearance: button; - appearance: button; -} - -.markdown-body [type=checkbox], -.markdown-body [type=radio] { - box-sizing: border-box; - padding: 0; -} - -.markdown-body [type=number]::-webkit-inner-spin-button, -.markdown-body [type=number]::-webkit-outer-spin-button { - height: auto; -} - -.markdown-body [type=search]::-webkit-search-cancel-button, -.markdown-body [type=search]::-webkit-search-decoration { - -webkit-appearance: none; - appearance: none; -} - -.markdown-body ::-webkit-input-placeholder { - color: inherit; - opacity: .54; -} - -.markdown-body ::-webkit-file-upload-button { - -webkit-appearance: button; - appearance: button; - font: inherit; -} - -.markdown-body a:hover { - text-decoration: underline; -} - -.markdown-body ::placeholder { - color: #9198a1; - opacity: 1; -} - -.markdown-body hr::before { - display: table; - content: ""; -} - -.markdown-body hr::after { - display: table; - clear: both; - content: ""; -} - -.markdown-body table { - border-spacing: 0; - border-collapse: collapse; - display: block; - width: max-content; - max-width: 100%; - overflow: auto; - font-variant: tabular-nums; -} - -.markdown-body td, -.markdown-body th { - padding: 0; -} - -.markdown-body details summary { - cursor: pointer; -} - -.markdown-body a:focus, -.markdown-body [role=button]:focus, -.markdown-body input[type=radio]:focus, -.markdown-body input[type=checkbox]:focus { - outline: 2px solid #1f6feb; - outline-offset: -2px; - box-shadow: none; -} - -.markdown-body a:focus:not(:focus-visible), -.markdown-body [role=button]:focus:not(:focus-visible), -.markdown-body input[type=radio]:focus:not(:focus-visible), -.markdown-body input[type=checkbox]:focus:not(:focus-visible) { - outline: solid 1px transparent; -} - -.markdown-body a:focus-visible, -.markdown-body [role=button]:focus-visible, -.markdown-body input[type=radio]:focus-visible, -.markdown-body input[type=checkbox]:focus-visible { - outline: 2px solid #1f6feb; - outline-offset: -2px; - box-shadow: none; -} - -.markdown-body a:not([class]):focus, -.markdown-body a:not([class]):focus-visible, -.markdown-body input[type=radio]:focus, -.markdown-body input[type=radio]:focus-visible, -.markdown-body input[type=checkbox]:focus, -.markdown-body input[type=checkbox]:focus-visible { - outline-offset: 0; -} - -.markdown-body kbd { - display: inline-block; - padding: 0.25rem; - font: 11px ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; - line-height: 10px; - color: #f0f6fc; - vertical-align: middle; - background-color: #151b23; - border: solid 1px #3d444db3; - border-radius: 6px; - box-shadow: inset 0 -1px 0 #3d444db3; -} - -.markdown-body h1, -.markdown-body h2, -.markdown-body h3, -.markdown-body h4, -.markdown-body h5, -.markdown-body h6 { - margin-top: 1.5rem; - margin-bottom: 1rem; - font-weight: 600; - line-height: 1.25; -} - -.markdown-body h2 { - font-weight: 600; - padding-bottom: .3em; - font-size: 1.5em; - border-bottom: 1px solid #3d444db3; -} - -.markdown-body h3 { - font-weight: 600; - font-size: 1.25em; -} - -.markdown-body h4 { - font-weight: 600; - font-size: 1em; -} - -.markdown-body h5 { - font-weight: 600; - font-size: .875em; -} - -.markdown-body h6 { - font-weight: 600; - font-size: .85em; - color: #9198a1; -} - -.markdown-body p { - margin-top: 0; - margin-bottom: 10px; -} - -.markdown-body blockquote { - margin: 0; - padding: 0 1em; - color: #9198a1; - border-left: .25em solid #3d444d; -} - -.markdown-body ul, -.markdown-body ol { - margin-top: 0; - margin-bottom: 0; - padding-left: 2em; -} - -.markdown-body ol ol, -.markdown-body ul ol { - list-style-type: lower-roman; -} - -.markdown-body ul ul ol, -.markdown-body ul ol ol, -.markdown-body ol ul ol, -.markdown-body ol ol ol { - list-style-type: lower-alpha; -} - -.markdown-body dd { - margin-left: 0; -} - -.markdown-body tt, -.markdown-body code, -.markdown-body samp { - font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; - font-size: 12px; -} - -.markdown-body pre { - margin-top: 0; - margin-bottom: 0; - font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; - font-size: 12px; - word-wrap: normal; -} - -.markdown-body .octicon { - display: inline-block; - overflow: visible !important; - vertical-align: text-bottom; - fill: currentColor; -} - -.markdown-body input::-webkit-outer-spin-button, -.markdown-body input::-webkit-inner-spin-button { - margin: 0; - appearance: none; -} - -.markdown-body .mr-2 { - margin-right: 0.5rem !important; -} - -.markdown-body::before { - display: table; - content: ""; -} - -.markdown-body::after { - display: table; - clear: both; - content: ""; -} - -.markdown-body > *:first-child { - margin-top: 0 !important; -} - -.markdown-body > *:last-child { - margin-bottom: 0 !important; -} - -.markdown-body a:not([href]) { - color: inherit; - text-decoration: none; -} - -.markdown-body .absent { - color: #f85149; -} - -.markdown-body .anchor { - float: left; - padding-right: 0.25rem; - margin-left: -20px; - line-height: 1; -} - -.markdown-body .anchor:focus { - outline: none; -} - -.markdown-body p, -.markdown-body blockquote, -.markdown-body ul, -.markdown-body ol, -.markdown-body dl, -.markdown-body table, -.markdown-body pre, -.markdown-body details { - margin-top: 0; - margin-bottom: 1rem; -} - -.markdown-body blockquote > :first-child { - margin-top: 0; -} - -.markdown-body blockquote > :last-child { - margin-bottom: 0; -} - -.markdown-body h1:hover .anchor, -.markdown-body h2:hover .anchor, -.markdown-body h3:hover .anchor, -.markdown-body h4:hover .anchor, -.markdown-body h5:hover .anchor, -.markdown-body h6:hover .anchor { - text-decoration: none; -} - -.markdown-body h1 tt, -.markdown-body h1 code, -.markdown-body h2 tt, -.markdown-body h2 code, -.markdown-body h3 tt, -.markdown-body h3 code, -.markdown-body h4 tt, -.markdown-body h4 code, -.markdown-body h5 tt, -.markdown-body h5 code, -.markdown-body h6 tt, -.markdown-body h6 code { - padding: 0 .2em; - font-size: inherit; -} - -.markdown-body summary h1, -.markdown-body summary h2, -.markdown-body summary h3, -.markdown-body summary h4, -.markdown-body summary h5, -.markdown-body summary h6 { - display: inline-block; -} - -.markdown-body summary h1 .anchor, -.markdown-body summary h2 .anchor, -.markdown-body summary h3 .anchor, -.markdown-body summary h4 .anchor, -.markdown-body summary h5 .anchor, -.markdown-body summary h6 .anchor { - margin-left: -40px; -} - -.markdown-body summary h1, -.markdown-body summary h2 { - padding-bottom: 0; - border-bottom: 0; -} - -.markdown-body ul.no-list, -.markdown-body ol.no-list { - padding: 0; - list-style-type: none; -} - -.markdown-body ol[type="a s"] { - list-style-type: lower-alpha; -} - -.markdown-body ol[type="A s"] { - list-style-type: upper-alpha; -} - -.markdown-body ol[type="i s"] { - list-style-type: lower-roman; -} - -.markdown-body ol[type="I s"] { - list-style-type: upper-roman; -} - -.markdown-body ol[type="1"] { - list-style-type: decimal; -} - -.markdown-body div > ol:not([type]) { - list-style-type: decimal; -} - -.markdown-body ul ul, -.markdown-body ul ol, -.markdown-body ol ol, -.markdown-body ol ul { - margin-top: 0; - margin-bottom: 0; -} - -.markdown-body li > p { - margin-top: 1rem; -} - -.markdown-body li + li { - margin-top: .25em; -} - -.markdown-body dl { - padding: 0; -} - -.markdown-body dl dt { - padding: 0; - margin-top: 1rem; - font-size: 1em; - font-style: italic; - font-weight: 600; -} - -.markdown-body dl dd { - padding: 0 1rem; - margin-bottom: 1rem; -} - -.markdown-body table th { - font-weight: 600; -} - -.markdown-body table th, -.markdown-body table td { - padding: 6px 13px; - border: 1px solid #3d444d; -} - -.markdown-body table td > :last-child { - margin-bottom: 0; -} - -.markdown-body table tr { - background-color: #0d1117; - border-top: 1px solid #3d444db3; -} - -.markdown-body table tr:nth-child(2n) { - background-color: #151b23; -} - -.markdown-body table img { - background-color: transparent; -} - -.markdown-body img[align=right] { - padding-left: 20px; -} - -.markdown-body img[align=left] { - padding-right: 20px; -} - -.markdown-body .emoji { - max-width: none; - vertical-align: text-top; - background-color: transparent; -} - -.markdown-body span.frame { - display: block; - overflow: hidden; -} - -.markdown-body span.frame > span { - display: block; - float: left; - width: auto; - padding: 7px; - margin: 13px 0 0; - overflow: hidden; - border: 1px solid #3d444d; -} - -.markdown-body span.frame span img { - display: block; - float: left; -} - -.markdown-body span.frame span span { - display: block; - padding: 5px 0 0; - clear: both; - color: #f0f6fc; -} - -.markdown-body span.align-center { - display: block; - overflow: hidden; - clear: both; -} - -.markdown-body span.align-center > span { - display: block; - margin: 13px auto 0; - overflow: hidden; - text-align: center; -} - -.markdown-body span.align-center span img { - margin: 0 auto; - text-align: center; -} - -.markdown-body span.align-right { - display: block; - overflow: hidden; - clear: both; -} - -.markdown-body span.align-right > span { - display: block; - margin: 13px 0 0; - overflow: hidden; - text-align: right; -} - -.markdown-body span.align-right span img { - margin: 0; - text-align: right; -} - -.markdown-body span.float-left { - display: block; - float: left; - margin-right: 13px; - overflow: hidden; -} - -.markdown-body span.float-left span { - margin: 13px 0 0; -} - -.markdown-body span.float-right { - display: block; - float: right; - margin-left: 13px; - overflow: hidden; -} - -.markdown-body span.float-right > span { - display: block; - margin: 13px auto 0; - overflow: hidden; - text-align: right; -} - -.markdown-body code, -.markdown-body tt { - padding: .2em .4em; - margin: 0; - font-size: 85%; - white-space: break-spaces; - background-color: #656c7633; - border-radius: 6px; -} - -.markdown-body code br, -.markdown-body tt br { - display: none; -} - -.markdown-body del code { - text-decoration: inherit; -} - -.markdown-body samp { - font-size: 85%; -} - -.markdown-body pre code { - font-size: 100%; -} - -.markdown-body pre > code { - padding: 0; - margin: 0; - word-break: normal; - white-space: pre; - background: transparent; - border: 0; -} - -.markdown-body .highlight { - margin-bottom: 1rem; -} - -.markdown-body .highlight pre { - margin-bottom: 0; - word-break: normal; -} - -.markdown-body .highlight pre, -.markdown-body pre { - padding: 1rem; - overflow: auto; - font-size: 85%; - line-height: 1.45; - color: #f0f6fc; - background-color: #151b23; - border-radius: 6px; -} - -.markdown-body pre code, -.markdown-body pre tt { - display: inline; - max-width: auto; - padding: 0; - margin: 0; - overflow: visible; - line-height: inherit; - word-wrap: normal; - background-color: transparent; - border: 0; -} - -.markdown-body .csv-data td, -.markdown-body .csv-data th { - padding: 5px; - overflow: hidden; - font-size: 12px; - line-height: 1; - text-align: left; - white-space: nowrap; -} - -.markdown-body .csv-data .blob-num { - padding: 10px 0.5rem 9px; - text-align: right; - background: #0d1117; - border: 0; -} - -.markdown-body .csv-data tr { - border-top: 0; -} - -.markdown-body .csv-data th { - font-weight: 600; - background: #151b23; - border-top: 0; -} - -.markdown-body [data-footnote-ref]::before { - content: "["; -} - -.markdown-body [data-footnote-ref]::after { - content: "]"; -} - -.markdown-body .footnotes { - font-size: 12px; - color: #9198a1; - border-top: 1px solid #3d444d; -} - -.markdown-body .footnotes ol { - padding-left: 1rem; -} - -.markdown-body .footnotes ol ul { - display: inline-block; - padding-left: 1rem; - margin-top: 1rem; -} - -.markdown-body .footnotes li { - position: relative; -} - -.markdown-body .footnotes li:target::before { - position: absolute; - top: calc(0.5rem * -1); - right: calc(0.5rem * -1); - bottom: calc(0.5rem * -1); - left: calc(1.5rem * -1); - pointer-events: none; - content: ""; - border: 2px solid #1f6feb; - border-radius: 6px; -} - -.markdown-body .footnotes li:target { - color: #f0f6fc; -} - -.markdown-body .footnotes .data-footnote-backref g-emoji { - font-family: monospace; -} - -.markdown-body body:has(:modal) { - padding-right: var(--dialog-scrollgutter) !important; -} - -.markdown-body .pl-c { - color: #9198a1; -} - -.markdown-body .pl-c1, -.markdown-body .pl-s .pl-v { - color: #79c0ff; -} - -.markdown-body .pl-e, -.markdown-body .pl-en { - color: #d2a8ff; -} - -.markdown-body .pl-smi, -.markdown-body .pl-s .pl-s1 { - color: #f0f6fc; -} - -.markdown-body .pl-ent { - color: #7ee787; -} - -.markdown-body .pl-k { - color: #ff7b72; -} - -.markdown-body .pl-s, -.markdown-body .pl-pds, -.markdown-body .pl-s .pl-pse .pl-s1, -.markdown-body .pl-sr, -.markdown-body .pl-sr .pl-cce, -.markdown-body .pl-sr .pl-sre, -.markdown-body .pl-sr .pl-sra { - color: #a5d6ff; -} - -.markdown-body .pl-v, -.markdown-body .pl-smw { - color: #ffa657; -} - -.markdown-body .pl-bu { - color: #f85149; -} - -.markdown-body .pl-ii { - color: #f0f6fc; - background-color: #8e1519; -} - -.markdown-body .pl-c2 { - color: #f0f6fc; - background-color: #b62324; -} - -.markdown-body .pl-sr .pl-cce { - font-weight: bold; - color: #7ee787; -} - -.markdown-body .pl-ml { - color: #f2cc60; -} - -.markdown-body .pl-mh, -.markdown-body .pl-mh .pl-en, -.markdown-body .pl-ms { - font-weight: bold; - color: #1f6feb; -} - -.markdown-body .pl-mi { - font-style: italic; - color: #f0f6fc; -} - -.markdown-body .pl-mb { - font-weight: bold; - color: #f0f6fc; -} - -.markdown-body .pl-md { - color: #ffdcd7; - background-color: #67060c; -} - -.markdown-body .pl-mi1 { - color: #aff5b4; - background-color: #033a16; -} - -.markdown-body .pl-mc { - color: #ffdfb6; - background-color: #5a1e02; -} - -.markdown-body .pl-mi2 { - color: #f0f6fc; - background-color: #1158c7; -} - -.markdown-body .pl-mdr { - font-weight: bold; - color: #d2a8ff; -} - -.markdown-body .pl-ba { - color: #9198a1; -} - -.markdown-body .pl-sg { - color: #3d444d; -} - -.markdown-body .pl-corl { - text-decoration: underline; - color: #a5d6ff; -} - -.markdown-body [role=button]:focus:not(:focus-visible), -.markdown-body [role=tabpanel][tabindex="0"]:focus:not(:focus-visible), -.markdown-body button:focus:not(:focus-visible), -.markdown-body summary:focus:not(:focus-visible), -.markdown-body a:focus:not(:focus-visible) { - outline: none; - box-shadow: none; -} - -.markdown-body [tabindex="0"]:focus:not(:focus-visible), -.markdown-body details-dialog:focus:not(:focus-visible) { - outline: none; -} - -.markdown-body g-emoji { - display: inline-block; - min-width: 1ch; - font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; - font-size: 1em; - font-style: normal !important; - font-weight: 400; - line-height: 1; - vertical-align: -0.075em; -} - -.markdown-body g-emoji img { - width: 1em; - height: 1em; -} - -.markdown-body .task-list-item { - list-style-type: none; -} - -.markdown-body .task-list-item label { - font-weight: 400; -} - -.markdown-body .task-list-item.enabled label { - cursor: pointer; -} - -.markdown-body .task-list-item + .task-list-item { - margin-top: 0.25rem; -} - -.markdown-body .task-list-item .handle { - display: none; -} - -.markdown-body .task-list-item-checkbox { - margin: 0 .2em .25em -1.4em; - vertical-align: middle; -} - -.markdown-body ul:dir(rtl) .task-list-item-checkbox { - margin: 0 -1.6em .25em .2em; -} - -.markdown-body ol:dir(rtl) .task-list-item-checkbox { - margin: 0 -1.6em .25em .2em; -} - -.markdown-body .contains-task-list:hover .task-list-item-convert-container, -.markdown-body .contains-task-list:focus-within .task-list-item-convert-container { - display: block; - width: auto; - height: 24px; - overflow: visible; - clip: auto; -} - -.markdown-body ::-webkit-calendar-picker-indicator { - filter: invert(50%); -} - -.markdown-body .markdown-alert { - padding: 0.5rem 1rem; - margin-bottom: 1rem; - color: inherit; - border-left: .25em solid #3d444d; -} - -.markdown-body .markdown-alert > :first-child { - margin-top: 0; -} - -.markdown-body .markdown-alert > :last-child { - margin-bottom: 0; -} - -.markdown-body .markdown-alert .markdown-alert-title { - display: flex; - font-weight: 500; - align-items: center; - line-height: 1; -} - -.markdown-body .markdown-alert.markdown-alert-note { - border-left-color: #1f6feb; -} - -.markdown-body .markdown-alert.markdown-alert-note .markdown-alert-title { - color: #4493f8; -} - -.markdown-body .markdown-alert.markdown-alert-important { - border-left-color: #8957e5; -} - -.markdown-body .markdown-alert.markdown-alert-important .markdown-alert-title { - color: #ab7df8; -} - -.markdown-body .markdown-alert.markdown-alert-warning { - border-left-color: #9e6a03; -} - -.markdown-body .markdown-alert.markdown-alert-warning .markdown-alert-title { - color: #d29922; -} - -.markdown-body .markdown-alert.markdown-alert-tip { - border-left-color: #238636; -} - -.markdown-body .markdown-alert.markdown-alert-tip .markdown-alert-title { - color: #3fb950; -} - -.markdown-body .markdown-alert.markdown-alert-caution { - border-left-color: #da3633; -} - -.markdown-body .markdown-alert.markdown-alert-caution .markdown-alert-title { - color: #f85149; -} - -.markdown-body > *:first-child > .heading-element:first-child { - margin-top: 0 !important; -} - -.markdown-body .highlight pre:has(+.zeroclipboard-container) { - min-height: 52px; -} diff --git a/pkg/templates/css/markdown_light.css b/pkg/templates/css/markdown_light.css deleted file mode 100644 index 7c36e19..0000000 --- a/pkg/templates/css/markdown_light.css +++ /dev/null @@ -1,1059 +0,0 @@ -.markdown-body details, -.markdown-body figcaption, -.markdown-body figure { - display: block; -} - -.markdown-body summary { - display: list-item; -} - -.markdown-body [hidden] { - display: none !important; -} - -.markdown-body a { - background-color: transparent; - text-decoration: none; -} - -.markdown-body abbr[title] { - border-bottom: none; - text-decoration: underline dotted; -} - -.markdown-body b, -.markdown-body strong { - font-weight: 600; -} - -.markdown-body dfn { - font-style: italic; -} - -.markdown-body h1 { - margin: .67em 0; - font-weight: 600; - padding-bottom: .3em; - font-size: 2em; - border-bottom: 1px solid #d1d9e0b3; -} - -.markdown-body mark { - background-color: #fff8c5; - color: #1f2328; -} - -.markdown-body small { - font-size: 90%; -} - -.markdown-body sub, -.markdown-body sup { - font-size: 75%; - line-height: 0; - position: relative; - vertical-align: baseline; -} - -.markdown-body sub { - bottom: -0.25em; -} - -.markdown-body sup { - top: -0.5em; -} - -.markdown-body img { - border-style: none; - max-width: 100%; - box-sizing: content-box; -} - -.markdown-body code, -.markdown-body kbd, -.markdown-body pre, -.markdown-body samp { - font-family: monospace; - font-size: 1em; -} - -.markdown-body figure { - margin: 1em 2.5rem; -} - -.markdown-body hr { - box-sizing: content-box; - overflow: hidden; - border-bottom: 1px solid #d1d9e0b3; - height: .25em; - padding: 0; - margin: 1.5rem 0; - background-color: #d1d9e0; -} - -.markdown-body input { - font: inherit; - margin: 0; - overflow: visible; - line-height: inherit; -} - -.markdown-body [type=button], -.markdown-body [type=reset], -.markdown-body [type=submit] { - -webkit-appearance: button; - appearance: button; -} - -.markdown-body [type=checkbox], -.markdown-body [type=radio] { - box-sizing: border-box; - padding: 0; -} - -.markdown-body [type=number]::-webkit-inner-spin-button, -.markdown-body [type=number]::-webkit-outer-spin-button { - height: auto; -} - -.markdown-body [type=search]::-webkit-search-cancel-button, -.markdown-body [type=search]::-webkit-search-decoration { - -webkit-appearance: none; - appearance: none; -} - -.markdown-body ::-webkit-input-placeholder { - color: inherit; - opacity: .54; -} - -.markdown-body ::-webkit-file-upload-button { - -webkit-appearance: button; - appearance: button; - font: inherit; -} - -.markdown-body a:hover { - text-decoration: underline; -} - -.markdown-body ::placeholder { - color: #59636e; - opacity: 1; -} - -.markdown-body hr::before { - display: table; - content: ""; -} - -.markdown-body hr::after { - display: table; - clear: both; - content: ""; -} - -.markdown-body table { - border-spacing: 0; - border-collapse: collapse; - display: block; - width: max-content; - max-width: 100%; - overflow: auto; - font-variant: tabular-nums; -} - -.markdown-body td, -.markdown-body th { - padding: 0; -} - -.markdown-body details summary { - cursor: pointer; -} - -.markdown-body a:focus, -.markdown-body [role=button]:focus, -.markdown-body input[type=radio]:focus, -.markdown-body input[type=checkbox]:focus { - outline: 2px solid #0969da; - outline-offset: -2px; - box-shadow: none; -} - -.markdown-body a:focus:not(:focus-visible), -.markdown-body [role=button]:focus:not(:focus-visible), -.markdown-body input[type=radio]:focus:not(:focus-visible), -.markdown-body input[type=checkbox]:focus:not(:focus-visible) { - outline: solid 1px transparent; -} - -.markdown-body a:focus-visible, -.markdown-body [role=button]:focus-visible, -.markdown-body input[type=radio]:focus-visible, -.markdown-body input[type=checkbox]:focus-visible { - outline: 2px solid #0969da; - outline-offset: -2px; - box-shadow: none; -} - -.markdown-body a:not([class]):focus, -.markdown-body a:not([class]):focus-visible, -.markdown-body input[type=radio]:focus, -.markdown-body input[type=radio]:focus-visible, -.markdown-body input[type=checkbox]:focus, -.markdown-body input[type=checkbox]:focus-visible { - outline-offset: 0; -} - -.markdown-body kbd { - display: inline-block; - padding: 0.25rem; - font: 11px ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; - line-height: 10px; - color: #1f2328; - vertical-align: middle; - background-color: #f6f8fa; - border: solid 1px #d1d9e0b3; - border-bottom-color: #d1d9e0b3; - border-radius: 6px; - box-shadow: inset 0 -1px 0 #d1d9e0b3; -} - -.markdown-body h1, -.markdown-body h2, -.markdown-body h3, -.markdown-body h4, -.markdown-body h5, -.markdown-body h6 { - margin-top: 1.5rem; - margin-bottom: 1rem; - font-weight: 600; - line-height: 1.25; -} - -.markdown-body h2 { - font-weight: 600; - padding-bottom: .3em; - font-size: 1.5em; - border-bottom: 1px solid #d1d9e0b3; -} - -.markdown-body h3 { - font-weight: 600; - font-size: 1.25em; -} - -.markdown-body h4 { - font-weight: 600; - font-size: 1em; -} - -.markdown-body h5 { - font-weight: 600; - font-size: .875em; -} - -.markdown-body h6 { - font-weight: 600; - font-size: .85em; - color: #59636e; -} - -.markdown-body p { - margin-top: 0; - margin-bottom: 10px; -} - -.markdown-body blockquote { - margin: 0; - padding: 0 1em; - color: #59636e; - border-left: .25em solid #d1d9e0; -} - -.markdown-body ul, -.markdown-body ol { - margin-top: 0; - margin-bottom: 0; - padding-left: 2em; -} - -.markdown-body ol ol, -.markdown-body ul ol { - list-style-type: lower-roman; -} - -.markdown-body ul ul ol, -.markdown-body ul ol ol, -.markdown-body ol ul ol, -.markdown-body ol ol ol { - list-style-type: lower-alpha; -} - -.markdown-body dd { - margin-left: 0; -} - -.markdown-body tt, -.markdown-body code, -.markdown-body samp { - font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; - font-size: 12px; -} - -.markdown-body pre { - margin-top: 0; - margin-bottom: 0; - font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; - font-size: 12px; - word-wrap: normal; -} - -.markdown-body .octicon { - display: inline-block; - overflow: visible !important; - vertical-align: text-bottom; - fill: currentColor; -} - -.markdown-body input::-webkit-outer-spin-button, -.markdown-body input::-webkit-inner-spin-button { - margin: 0; - appearance: none; -} - -.markdown-body .mr-2 { - margin-right: 0.5rem !important; -} - -.markdown-body::before { - display: table; - content: ""; -} - -.markdown-body::after { - display: table; - clear: both; - content: ""; -} - -.markdown-body > *:first-child { - margin-top: 0 !important; -} - -.markdown-body > *:last-child { - margin-bottom: 0 !important; -} - -.markdown-body a:not([href]) { - color: inherit; - text-decoration: none; -} - -.markdown-body .absent { - color: #d1242f; -} - -.markdown-body .anchor { - float: left; - padding-right: 0.25rem; - margin-left: -20px; - line-height: 1; -} - -.markdown-body .anchor:focus { - outline: none; -} - -.markdown-body p, -.markdown-body blockquote, -.markdown-body ul, -.markdown-body ol, -.markdown-body dl, -.markdown-body table, -.markdown-body pre, -.markdown-body details { - margin-top: 0; - margin-bottom: 1rem; -} - -.markdown-body blockquote > :first-child { - margin-top: 0; -} - -.markdown-body blockquote > :last-child { - margin-bottom: 0; -} - -.markdown-body h1 .octicon-link, -.markdown-body h2 .octicon-link, -.markdown-body h3 .octicon-link, -.markdown-body h4 .octicon-link, -.markdown-body h5 .octicon-link, -.markdown-body h6 .octicon-link { - color: #1f2328; - vertical-align: middle; - visibility: hidden; -} - -.markdown-body h1:hover .anchor, -.markdown-body h2:hover .anchor, -.markdown-body h3:hover .anchor, -.markdown-body h4:hover .anchor, -.markdown-body h5:hover .anchor, -.markdown-body h6:hover .anchor { - text-decoration: none; -} - -.markdown-body h1:hover .anchor .octicon-link, -.markdown-body h2:hover .anchor .octicon-link, -.markdown-body h3:hover .anchor .octicon-link, -.markdown-body h4:hover .anchor .octicon-link, -.markdown-body h5:hover .anchor .octicon-link, -.markdown-body h6:hover .anchor .octicon-link { - visibility: visible; -} - -.markdown-body h1 tt, -.markdown-body h1 code, -.markdown-body h2 tt, -.markdown-body h2 code, -.markdown-body h3 tt, -.markdown-body h3 code, -.markdown-body h4 tt, -.markdown-body h4 code, -.markdown-body h5 tt, -.markdown-body h5 code, -.markdown-body h6 tt, -.markdown-body h6 code { - padding: 0 .2em; - font-size: inherit; -} - -.markdown-body summary h1, -.markdown-body summary h2, -.markdown-body summary h3, -.markdown-body summary h4, -.markdown-body summary h5, -.markdown-body summary h6 { - display: inline-block; -} - -.markdown-body summary h1 .anchor, -.markdown-body summary h2 .anchor, -.markdown-body summary h3 .anchor, -.markdown-body summary h4 .anchor, -.markdown-body summary h5 .anchor, -.markdown-body summary h6 .anchor { - margin-left: -40px; -} - -.markdown-body summary h1, -.markdown-body summary h2 { - padding-bottom: 0; - border-bottom: 0; -} - -.markdown-body ul.no-list, -.markdown-body ol.no-list { - padding: 0; - list-style-type: none; -} - -.markdown-body ol[type="a s"] { - list-style-type: lower-alpha; -} - -.markdown-body ol[type="A s"] { - list-style-type: upper-alpha; -} - -.markdown-body ol[type="i s"] { - list-style-type: lower-roman; -} - -.markdown-body ol[type="I s"] { - list-style-type: upper-roman; -} - -.markdown-body ol[type="1"] { - list-style-type: decimal; -} - -.markdown-body div > ol:not([type]) { - list-style-type: decimal; -} - -.markdown-body ul ul, -.markdown-body ul ol, -.markdown-body ol ol, -.markdown-body ol ul { - margin-top: 0; - margin-bottom: 0; -} - -.markdown-body li > p { - margin-top: 1rem; -} - -.markdown-body li + li { - margin-top: .25em; -} - -.markdown-body dl { - padding: 0; -} - -.markdown-body dl dt { - padding: 0; - margin-top: 1rem; - font-size: 1em; - font-style: italic; - font-weight: 600; -} - -.markdown-body dl dd { - padding: 0 1rem; - margin-bottom: 1rem; -} - -.markdown-body table th { - font-weight: 600; -} - -.markdown-body table th, -.markdown-body table td { - padding: 6px 13px; - border: 1px solid #d1d9e0; -} - -.markdown-body table td > :last-child { - margin-bottom: 0; -} - -.markdown-body table tr { - background-color: #ffffff; - border-top: 1px solid #d1d9e0b3; -} - -.markdown-body table tr:nth-child(2n) { - background-color: #f6f8fa; -} - -.markdown-body table img { - background-color: transparent; -} - -.markdown-body img[align=right] { - padding-left: 20px; -} - -.markdown-body img[align=left] { - padding-right: 20px; -} - -.markdown-body .emoji { - max-width: none; - vertical-align: text-top; - background-color: transparent; -} - -.markdown-body span.frame { - display: block; - overflow: hidden; -} - -.markdown-body span.frame > span { - display: block; - float: left; - width: auto; - padding: 7px; - margin: 13px 0 0; - overflow: hidden; - border: 1px solid #d1d9e0; -} - -.markdown-body span.frame span img { - display: block; - float: left; -} - -.markdown-body span.frame span span { - display: block; - padding: 5px 0 0; - clear: both; - color: #1f2328; -} - -.markdown-body span.align-center { - display: block; - overflow: hidden; - clear: both; -} - -.markdown-body span.align-center > span { - display: block; - margin: 13px auto 0; - overflow: hidden; - text-align: center; -} - -.markdown-body span.align-center span img { - margin: 0 auto; - text-align: center; -} - -.markdown-body span.align-right { - display: block; - overflow: hidden; - clear: both; -} - -.markdown-body span.align-right > span { - display: block; - margin: 13px 0 0; - overflow: hidden; - text-align: right; -} - -.markdown-body span.align-right span img { - margin: 0; - text-align: right; -} - -.markdown-body span.float-left { - display: block; - float: left; - margin-right: 13px; - overflow: hidden; -} - -.markdown-body span.float-left span { - margin: 13px 0 0; -} - -.markdown-body span.float-right { - display: block; - float: right; - margin-left: 13px; - overflow: hidden; -} - -.markdown-body span.float-right > span { - display: block; - margin: 13px auto 0; - overflow: hidden; - text-align: right; -} - -.markdown-body code, -.markdown-body tt { - padding: .2em .4em; - margin: 0; - font-size: 85%; - white-space: break-spaces; - background-color: #818b981f; - border-radius: 6px; -} - -.markdown-body code br, -.markdown-body tt br { - display: none; -} - -.markdown-body del code { - text-decoration: inherit; -} - -.markdown-body samp { - font-size: 85%; -} - -.markdown-body pre code { - font-size: 100%; -} - -.markdown-body pre > code { - padding: 0; - margin: 0; - word-break: normal; - white-space: pre; - background: transparent; - border: 0; -} - -.markdown-body .highlight { - margin-bottom: 1rem; -} - -.markdown-body .highlight pre { - margin-bottom: 0; - word-break: normal; -} - -.markdown-body .highlight pre, -.markdown-body pre { - padding: 1rem; - overflow: auto; - font-size: 85%; - line-height: 1.45; - color: #1f2328; - background-color: #f6f8fa; - border-radius: 6px; -} - -.markdown-body pre code, -.markdown-body pre tt { - display: inline; - max-width: auto; - padding: 0; - margin: 0; - overflow: visible; - line-height: inherit; - word-wrap: normal; - background-color: transparent; - border: 0; -} - -.markdown-body .csv-data td, -.markdown-body .csv-data th { - padding: 5px; - overflow: hidden; - font-size: 12px; - line-height: 1; - text-align: left; - white-space: nowrap; -} - -.markdown-body .csv-data .blob-num { - padding: 10px 0.5rem 9px; - text-align: right; - background: #ffffff; - border: 0; -} - -.markdown-body .csv-data tr { - border-top: 0; -} - -.markdown-body .csv-data th { - font-weight: 600; - background: #f6f8fa; - border-top: 0; -} - -.markdown-body [data-footnote-ref]::before { - content: "["; -} - -.markdown-body [data-footnote-ref]::after { - content: "]"; -} - -.markdown-body .footnotes { - font-size: 12px; - color: #59636e; - border-top: 1px solid #d1d9e0; -} - -.markdown-body .footnotes ol { - padding-left: 1rem; -} - -.markdown-body .footnotes ol ul { - display: inline-block; - padding-left: 1rem; - margin-top: 1rem; -} - -.markdown-body .footnotes li { - position: relative; -} - -.markdown-body .footnotes li:target::before { - position: absolute; - top: calc(0.5rem * -1); - right: calc(0.5rem * -1); - bottom: calc(0.5rem * -1); - left: calc(1.5rem * -1); - pointer-events: none; - content: ""; - border: 2px solid #0969da; - border-radius: 6px; -} - -.markdown-body .footnotes li:target { - color: #1f2328; -} - -.markdown-body .footnotes .data-footnote-backref g-emoji { - font-family: monospace; -} - -.markdown-body .pl-c { - color: #59636e; -} - -.markdown-body .pl-c1, -.markdown-body .pl-s .pl-v { - color: #0550ae; -} - -.markdown-body .pl-e, -.markdown-body .pl-en { - color: #6639ba; -} - -.markdown-body .pl-smi, -.markdown-body .pl-s .pl-s1 { - color: #1f2328; -} - -.markdown-body .pl-ent { - color: #0550ae; -} - -.markdown-body .pl-k { - color: #cf222e; -} - -.markdown-body .pl-s, -.markdown-body .pl-pds, -.markdown-body .pl-s .pl-pse .pl-s1, -.markdown-body .pl-sr, -.markdown-body .pl-sr .pl-cce, -.markdown-body .pl-sr .pl-sre, -.markdown-body .pl-sr .pl-sra { - color: #0a3069; -} - -.markdown-body .pl-v, -.markdown-body .pl-smw { - color: #953800; -} - -.markdown-body .pl-bu { - color: #82071e; -} - -.markdown-body .pl-ii { - color: #f6f8fa; - background-color: #82071e; -} - -.markdown-body .pl-c2 { - color: #f6f8fa; - background-color: #cf222e; -} - -.markdown-body .pl-sr .pl-cce { - font-weight: bold; - color: #116329; -} - -.markdown-body .pl-ml { - color: #3b2300; -} - -.markdown-body .pl-mh, -.markdown-body .pl-mh .pl-en, -.markdown-body .pl-ms { - font-weight: bold; - color: #0550ae; -} - -.markdown-body .pl-mi { - font-style: italic; - color: #1f2328; -} - -.markdown-body .pl-mb { - font-weight: bold; - color: #1f2328; -} - -.markdown-body .pl-md { - color: #82071e; - background-color: #ffebe9; -} - -.markdown-body .pl-mi1 { - color: #116329; - background-color: #dafbe1; -} - -.markdown-body .pl-mc { - color: #953800; - background-color: #ffd8b5; -} - -.markdown-body .pl-mi2 { - color: #d1d9e0; - background-color: #0550ae; -} - -.markdown-body .pl-mdr { - font-weight: bold; - color: #8250df; -} - -.markdown-body .pl-ba { - color: #59636e; -} - -.markdown-body .pl-sg { - color: #818b98; -} - -.markdown-body .pl-corl { - text-decoration: underline; - color: #0a3069; -} - -.markdown-body [role=button]:focus:not(:focus-visible), -.markdown-body [role=tabpanel][tabindex="0"]:focus:not(:focus-visible), -.markdown-body button:focus:not(:focus-visible), -.markdown-body summary:focus:not(:focus-visible), -.markdown-body a:focus:not(:focus-visible) { - outline: none; - box-shadow: none; -} - -.markdown-body [tabindex="0"]:focus:not(:focus-visible), -.markdown-body details-dialog:focus:not(:focus-visible) { - outline: none; -} - -.markdown-body g-emoji { - display: inline-block; - min-width: 1ch; - font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; - font-size: 1em; - font-style: normal !important; - font-weight: 400; - line-height: 1; - vertical-align: -0.075em; -} - -.markdown-body g-emoji img { - width: 1em; - height: 1em; -} - -.markdown-body .task-list-item { - list-style-type: none; -} - -.markdown-body .task-list-item label { - font-weight: 400; -} - -.markdown-body .task-list-item.enabled label { - cursor: pointer; -} - -.markdown-body .task-list-item + .task-list-item { - margin-top: 0.25rem; -} - -.markdown-body .task-list-item .handle { - display: none; -} - -.markdown-body .task-list-item-checkbox { - margin: 0 .2em .25em -1.4em; - vertical-align: middle; -} - -.markdown-body ul:dir(rtl) .task-list-item-checkbox { - margin: 0 -1.6em .25em .2em; -} - -.markdown-body ol:dir(rtl) .task-list-item-checkbox { - margin: 0 -1.6em .25em .2em; -} - -.markdown-body .contains-task-list:hover .task-list-item-convert-container, -.markdown-body .contains-task-list:focus-within .task-list-item-convert-container { - display: block; - width: auto; - height: 24px; - overflow: visible; - clip: auto; -} - -.markdown-body ::-webkit-calendar-picker-indicator { - filter: invert(50%); -} - -.markdown-body .markdown-alert { - padding: 0.5rem 1rem; - margin-bottom: 1rem; - color: inherit; - border-left: .25em solid #d1d9e0; -} - -.markdown-body .markdown-alert > :first-child { - margin-top: 0; -} - -.markdown-body .markdown-alert > :last-child { - margin-bottom: 0; -} - -.markdown-body .markdown-alert .markdown-alert-title { - display: flex; - font-weight: 500; - align-items: center; - line-height: 1; -} - -.markdown-body .markdown-alert.markdown-alert-note { - border-left-color: #0969da; -} - -.markdown-body .markdown-alert.markdown-alert-note .markdown-alert-title { - color: #0969da; -} - -.markdown-body .markdown-alert.markdown-alert-important { - border-left-color: #8250df; -} - -.markdown-body .markdown-alert.markdown-alert-important .markdown-alert-title { - color: #8250df; -} - -.markdown-body .markdown-alert.markdown-alert-warning { - border-left-color: #9a6700; -} - -.markdown-body .markdown-alert.markdown-alert-warning .markdown-alert-title { - color: #9a6700; -} - -.markdown-body .markdown-alert.markdown-alert-tip { - border-left-color: #1a7f37; -} - -.markdown-body .markdown-alert.markdown-alert-tip .markdown-alert-title { - color: #1a7f37; -} - -.markdown-body .markdown-alert.markdown-alert-caution { - border-left-color: #cf222e; -} - -.markdown-body .markdown-alert.markdown-alert-caution .markdown-alert-title { - color: #d1242f; -} - -.markdown-body > *:first-child > .heading-element:first-child { - margin-top: 0 !important; -} - -.markdown-body .highlight pre:has(+.zeroclipboard-container) { - min-height: 52px; -} diff --git a/pkg/templates/file_tree.gohtml b/pkg/templates/file_tree.gohtml deleted file mode 100644 index 21fc07c..0000000 --- a/pkg/templates/file_tree.gohtml +++ /dev/null @@ -1,43 +0,0 @@ -{{- /*gotype:mokhan.ca/antonmedv/gitmal/pkg/templates.FileTreeParams*/ -}} -{{ define "file_tree" }} - {{ range .Nodes }} - {{ if .IsDir }} - <details open> - <summary class="node"> - <svg aria-hidden="true" focusable="false" width="16" height="16" class="dir"> - <use xlink:href="#dir"></use> - </svg> - <span class="file-name">{{ .Name }}</span> - </summary> - <div class="children"> - {{ template "file_tree" (FileTreeParams .Children) }} - </div> - </details> - {{ else }} - <div class="node"> - <div class="icon" aria-hidden="true"> - {{ if .IsNew }} - <svg aria-hidden="true" focusable="false" width="16" height="16" class="file-added"> - <use xlink:href="#file-added"></use> - </svg> - {{ else if .IsDelete }} - <svg aria-hidden="true" focusable="false" width="16" height="16" class="file-deleted"> - <use xlink:href="#file-deleted"></use> - </svg> - {{ else if .IsRename }} - <svg aria-hidden="true" focusable="false" width="16" height="16" class="file-renamed"> - <use xlink:href="#file-renamed"></use> - </svg> - {{ else }} - <svg aria-hidden="true" focusable="false" width="16" height="16"> - <use xlink:href="#file-modified"></use> - </svg> - {{ end }} - </div> - <a href="#{{.Path}}" class="file-name"> - {{ .Path | BaseName }} - </a> - </div> - {{ end }} - {{ end }} -{{ end }} diff --git a/pkg/templates/header.gohtml b/pkg/templates/header.gohtml deleted file mode 100644 index 4b5af05..0000000 --- a/pkg/templates/header.gohtml +++ /dev/null @@ -1,35 +0,0 @@ -{{- /*gotype: mokhan.ca/antonmedv/gitmal/pkg/templates.HeaderParams*/ -}} -{{ define "header" }} - <div class="header-container"> - <header> - {{ if .Ref }} - <div class="header-ref" aria-label="Ref">{{ .Ref }}</div> - {{ end }} - {{ if .Header }} - <h1 class="title">{{ .Header }}</h1> - {{ else if .Breadcrumbs }} - <nav class="breadcrumbs" aria-label="Breadcrumbs"> - {{ range $i, $b := .Breadcrumbs }} - {{ if gt $i 0 }} - <div>/</div> - {{ end }} - {{ if $b.Href }} - <a href="{{$b.Href}}">{{$b.Name}}</a> - {{ else }} - <h1>{{$b.Name}}</h1> - {{ if $b.IsDir}} - <div>/</div> - {{ end}} - {{ end }} - {{ end }} - </nav> - {{ end }} - <button class="goto-top" onclick="window.scrollTo({top: 0, behavior: 'smooth'})"> - <svg aria-hidden="true" focusable="false" width="16" height="16"> - <use xlink:href="#arrow-top"></use> - </svg> - Top - </button> - </header> - </div> -{{ end }} diff --git a/pkg/templates/layout.gohtml b/pkg/templates/layout.gohtml deleted file mode 100644 index 9438396..0000000 --- a/pkg/templates/layout.gohtml +++ /dev/null @@ -1,318 +0,0 @@ -{{- /*gotype: mokhan.ca/antonmedv/gitmal/pkg/templates.LayoutParams*/ -}} -<!DOCTYPE html> -<html lang="en"> -<head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1"> - <title>{{ .Title }}</title> - <style> - {{ if .Dark }} - :root { - --c-indigo-1: #a8b1ff; - --c-indigo-2: #5c73e7; - --c-indigo-3: #3e63dd; - --c-green: #57ab5a; - --c-red: #e5534b; - --c-yellow: #c69026; - --c-dir: #9198a1; - --c-gray-soft: rgba(101, 117, 133, .16); - --c-bg: #1b1b1f; - --c-bg-alt: #161618; - --c-bg-elv: #202127; - --c-text-1: rgba(255, 255, 245, .86); - --c-text-2: rgba(235, 235, 245, .6); - --c-text-3: rgba(235, 235, 245, .38); - --c-border: #3c3f44; - --c-divider: #2e2e32; - } - - {{ else }} - :root { - --c-indigo-1: #3451b2; - --c-indigo-2: #3a5ccc; - --c-indigo-3: #5672cd; - --c-green: #1a7f37; - --c-red: #c53030; - --c-yellow: #9a6700; - --c-dir: #54aeff; - --c-gray-soft: rgba(142, 150, 170, .14); - --c-bg: #ffffff; - --c-bg-alt: #f6f6f7; - --c-bg-elv: #ffffff; - --c-text-1: rgba(60, 60, 67); - --c-text-2: rgba(60, 60, 67, .78); - --c-text-3: rgba(60, 60, 67, .56); - --c-border: #c2c2c4; - --c-divider: #e2e2e3; - } - - {{ end }} - - :root { - --c-brand-1: var(--c-indigo-1); - --c-brand-2: var(--c-indigo-2); - --c-brand-3: var(--c-indigo-3); - --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; - --font-family-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - --code-line-height: 20px; - --code-font-size: 12px; - --code-color: var(--c-brand-1); - --code-bg: var(--c-gray-soft); - --code-block-bg: var(--c-bg-alt); - --code-block-color: var(--c-text-1); - --header-height: 46px; - --border-radius: 6px; - --max-content-width: 1470px; - } - - * { - box-sizing: border-box; - } - - body { - margin: 0; - padding: 0; - font-family: var(--font-family), sans-serif; - font-size: 14px; - line-height: 1; - color: var(--c-text-1); - background-color: var(--c-bg); - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - -moz-text-size-adjust: none; - -webkit-text-size-adjust: none; - text-size-adjust: none; - min-height: 100vh; - display: flex; - flex-direction: column; - } - - .nowrap { - white-space: nowrap; - } - - h1 { - margin-inline: 0; - margin-block: 16px; - font-size: 20px; - font-weight: 600; - } - - a { - color: var(--c-brand-1); - font-weight: 500; - text-decoration: underline; - text-underline-offset: 2px; - text-decoration: inherit; - touch-action: manipulation; - } - - a:hover { - color: var(--c-brand-2); - text-decoration: underline; - } - - .menu { - background-color: var(--c-bg-alt); - border-bottom: 1px solid var(--c-divider); - overflow-x: auto; - } - - .menu-content { - display: flex; - flex-direction: row; - align-items: center; - gap: 16px; - padding-inline: 16px; - max-width: var(--max-content-width); - margin-inline: auto; - } - - .menu-item { - display: flex; - align-items: center; - border-bottom: 2px solid transparent; - height: 56px; - padding-inline: 8px; - } - - .menu-item a { - display: flex; - flex-direction: row; - gap: 8px; - align-items: center; - color: var(--c-text-1); - padding: 8px 10px; - border-radius: 4px; - } - - .menu-item a:hover { - background-color: var(--c-bg-elv); - text-decoration: none; - } - - .menu-item.selected { - border-bottom-color: var(--c-brand-1); - } - - .project-name { - font-weight: 600; - font-size: 16px; - margin-inline: 16px; - color: var(--c-text-1); - text-decoration: none; - } - - main { - flex-grow: 1; - width: 100%; - max-width: var(--max-content-width); - margin: 16px auto; - } - - .main-content { - padding-inline: 16px; - } - - footer { - padding: 12px 16px; - background-color: var(--c-bg-alt); - border-top: 1px solid var(--c-divider); - color: var(--c-text-3); - font-size: 12px; - text-align: center; - } - - .header-container { - container-type: scroll-state; - position: sticky; - top: 0; - } - - .header-container { - @container scroll-state(stuck: top) { - header { - border-top: none; - border-top-left-radius: 0; - border-top-right-radius: 0; - } - - .goto-top { - display: flex; - } - } - } - - header { - display: flex; - flex-direction: row; - align-items: center; - min-height: var(--header-height); - padding-inline: 16px; - background: var(--c-bg-alt); - border: 1px solid var(--c-border); - border-top-left-radius: var(--border-radius); - border-top-right-radius: var(--border-radius); - } - - header h1 { - word-break: break-all; - font-weight: 600; - font-size: 16px; - margin: 0; - padding: 0; - } - - .header-ref { - color: var(--c-text-2); - border: 1px solid var(--c-border); - border-radius: 6px; - padding: 6px 10px; - margin-right: 10px; - margin-left: -6px; - } - - header .path { - font-size: 16px; - } - - .breadcrumbs { - display: flex; - flex-direction: row; - flex-wrap: wrap; - gap: 6px; - font-size: 16px; - } - - .breadcrumbs a { - word-break: break-all; - } - - .goto-top { - display: none; - margin-left: auto; - padding: 6px 10px; - background: none; - border: none; - border-radius: 6px; - gap: 4px; - align-items: center; - color: var(--c-text-1); - cursor: pointer; - } - - .goto-top:hover { - background: var(--c-bg-elv); - } - </style> - {{ template "head" . }} -</head> -<body> -{{ template "svg" . }} -<div class="menu"> - <div class="menu-content"> - <a href="{{ .RootHref }}index.html" class="project-name">{{ .Name }}</a> - <div class="menu-item {{ if eq .Selected "code" }}selected{{ end }}"> - <a href="{{ .RootHref }}blob/{{ .CurrentRefDir }}/index.html"> - <svg aria-hidden="true" width="16" height="16"> - <use xlink:href="#code"></use> - </svg> - Code - </a> - </div> - <div class="menu-item {{ if eq .Selected "branches" }}selected{{ end }}"> - <a href="{{ .RootHref }}branches.html"> - <svg aria-hidden="true" focusable="false" width="16" height="16"> - <use xlink:href="#branch"></use> - </svg> - Branches - </a> - </div> - <div class="menu-item {{ if eq .Selected "tags" }}selected{{ end }}"> - <a href="{{ .RootHref }}tags.html"> - <svg aria-hidden="true" focusable="false" width="16" height="16"> - <use xlink:href="#tag"></use> - </svg> - Tags - </a> - </div> - <div class="menu-item {{ if eq .Selected "commits" }}selected{{ end }}"> - <a href="{{ .RootHref }}commits/{{ .CurrentRefDir }}/index.html"> - <svg aria-hidden="true" focusable="false" width="16" height="16"> - <use xlink:href="#commit"></use> - </svg> - Commits - </a> - </div> - </div> -</div> -<main> - <div class="main-content"> - {{ template "body" . }} - </div> -</main> -<footer> -</footer> -</body> -</html> diff --git a/pkg/templates/list.gohtml b/pkg/templates/list.gohtml deleted file mode 100644 index 9780504..0000000 --- a/pkg/templates/list.gohtml +++ /dev/null @@ -1,145 +0,0 @@ -{{- /*gotype: mokhan.ca/antonmedv/gitmal/pkg/templates.ListParams*/ -}} -{{ define "head" }} - <style> - .files { - border: 1px solid var(--c-border); - border-top: none; - width: 100%; - border-bottom-left-radius: var(--border-radius); - border-bottom-right-radius: var(--border-radius); - overflow-x: auto; - } - - .files .dir { - color: var(--c-dir); - } - - .files a { - color: var(--c-text-1); - } - - .files a:hover { - color: var(--c-brand-2); - } - - .row { - display: flex; - height: 40px; - border-bottom: 1px solid var(--c-border); - padding-inline: 16px; - gap: 16px; - } - - .row:last-child { - border-bottom: none; - } - - .row:hover { - background-color: var(--c-bg-alt); - } - - .cell { - flex: 1; - display: flex; - gap: 8px; - align-items: center; - white-space: nowrap; - } - - .cell:not(:first-child) { - justify-content: flex-end; - max-width: 100px; - } - - @media (max-width: 767px) { - .file-mode { - display: none; - } - } - - {{ if .Readme }} - .markdown-container { - padding-inline: 16px; - border: 1px solid var(--c-divider); - border-radius: 8px; - overflow-x: auto; - margin-top: 16px; - } - - .markdown-body { - width: 100%; - min-width: 200px; - max-width: 980px; - margin: 0 auto; - padding: 32px; - word-wrap: break-word; - font-size: 16px; - line-height: 1.5; - } - - @media (max-width: 767px) { - .markdown-body { - padding: 16px; - } - } - - {{.CSSMarkdown}} - {{ end }} - </style> -{{ end }} - -{{ define "body" }} - {{ template "header" . }} - - <div class="files"> - {{ if .ParentHref }} - <div class="row"> - <div class="cell"> - <svg aria-hidden="true" focusable="false" width="16" height="16" class="dir"> - <use xlink:href="#dir"></use> - </svg> - <a href="{{ .ParentHref }}">..</a> - </div> - </div> - {{ end }} - - {{ if or .Dirs .Files }} - {{ range .Dirs }} - <div class="row"> - <div class="cell"> - <svg aria-hidden="true" focusable="false" width="16" height="16" class="dir"> - <use xlink:href="#dir"></use> - </svg> - <a href="{{ .Href }}">{{ .Name }}</a> - </div> - </div> - {{ end }} - - {{ range .Files }} - <div class="row"> - <div class="cell"> - <svg aria-hidden="true" focusable="false" width="16" height="16"> - <use xlink:href="#file"></use> - </svg> - <a href="{{ .Href }}">{{ .Name }}</a> - </div> - <div class="cell file-mode">{{ .Mode }}</div> - <div class="cell file-size">{{ .Size }}</div> - </div> - {{ end }} - {{ else }} - <div class="row"> - <div class="cell">(empty)</div> - </div> - {{ end }} - </div> - - {{ if .Readme }} - <div class="markdown-container"> - <div class="markdown-body"> - {{ .Readme }} - </div> - </div> - {{ end }} -{{ end }} - diff --git a/pkg/templates/markdown.gohtml b/pkg/templates/markdown.gohtml deleted file mode 100644 index 7827950..0000000 --- a/pkg/templates/markdown.gohtml +++ /dev/null @@ -1,45 +0,0 @@ -{{- /*gotype: mokhan.ca/antonmedv/gitmal/pkg/templates.MarkdownParams*/ -}} -{{ define "head" }} - <style> - [id] { - scroll-margin-top: var(--header-height); - } - - .markdown-container { - border: 1px solid var(--c-divider); - border-top: none; - border-bottom-left-radius: 8px; - border-bottom-right-radius: 8px; - overflow-x: auto; - } - - .markdown-body { - width: 100%; - min-width: 200px; - max-width: 980px; - margin: 0 auto; - padding: 32px; - word-wrap: break-word; - font-size: 16px; - line-height: 1.5; - } - - @media (max-width: 767px) { - .markdown-body { - padding: 16px; - } - } - - {{.CSSMarkdown}} - </style> -{{ end }} - -{{ define "body" }} - {{ template "header" . }} - <article class="markdown-container"> - <div class="markdown-body"> - {{ .Content }} - </div> - </article> -{{ end }} - diff --git a/pkg/templates/preview.gohtml b/pkg/templates/preview.gohtml deleted file mode 100644 index bc6f898..0000000 --- a/pkg/templates/preview.gohtml +++ /dev/null @@ -1,144 +0,0 @@ -{{- /*gotype: mokhan.ca/antonmedv/gitmal/pkg/templates.PreviewParams*/ -}} -<!DOCTYPE html> -<html lang="en"> -<head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1"> - <title>Chroma Themes Preview</title> - <style> - * { - box-sizing: border-box; - } - - body { - margin: 0; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; - background: #ffffff; - color: #3c3c43; - line-height: 1.4; - } - - header { - position: sticky; - top: 0; - background: #f6f6f7; - border-bottom: 1px solid #c2c2c4; - padding: 12px 16px; - z-index: 10; - } - - .meta { - color: rgba(60, 60, 67, .78); - font-size: 12px - } - - main { - padding: 16px - } - - .grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); - gap: 16px; - justify-content: center; - } - - .card { - border: 1px solid #c2c2c4; - border-radius: 10px; - overflow: hidden; - background: #fff; - } - - /* Dark tone variant for theme cards */ - .card.dark { - background: #1b1b1f; /* dark bg */ - color: rgba(255, 255, 245, .86); /* light text */ - border-color: #3c3f44; /* dark border */ - } - - .card h2 { - margin: 0; - padding: 10px 12px; - font-size: 14px; - border-bottom: 1px solid #c2c2c4; - display: flex; - justify-content: space-between; - align-items: center; - } - - .card.dark h2 { - border-bottom-color: #3c3f44; - } - - .badge { - font-size: 11px; - padding: 2px 6px; - border-radius: 6px; - border: 1px solid #c2c2c4; - color: rgba(60, 60, 67, .78); - background: #ffffff; - } - - .card.dark .badge { - border-color: #3c3f44; - color: rgba(235, 235, 245, .6); - background: #1b1b1f; - } - - .sample { - padding: 12px; - } - - pre { - margin: 0; - padding: 8px 16px; - overflow-x: auto; - white-space: pre; - word-spacing: normal; - word-break: normal; - word-wrap: normal; - tab-size: 4; - font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace, monospace; - border-radius: 6px; - background-color: #f6f6f7; - } - - .dark pre { - background-color: #161618; - } - - pre > code { - display: block; - padding: 0 16px; - width: fit-content; - min-width: 100%; - line-height: 20px; - font-size: 12px; - color: rgba(60, 60, 67); - } - - .dark pre > code { - color: rgba(255, 255, 245, .86); - } - </style> -</head> -<body> -<header> - <div><strong>Chroma themes preview</strong></div> - <div class="meta">Showing {{ .Count }} themes. Use <code>--theme <name></code> in the app.</div> -</header> -<main> - <div class="grid"> - {{ range .Themes }} - <section class="card {{ .Tone }}"> - <h2><span>{{ .Name }}</span><span class="badge">{{ .Tone }}</span></h2> - <div class="sample"> - {{ .HTML }} - </div> - </section> - {{ end }} - </div> -</main> -</body> -</html> diff --git a/pkg/templates/svg.gohtml b/pkg/templates/svg.gohtml deleted file mode 100644 index 833af42..0000000 --- a/pkg/templates/svg.gohtml +++ /dev/null @@ -1,50 +0,0 @@ -{{ define "svg" }} - <svg style="display: none" aria-hidden="true" focusable="false"> - <symbol id="dir" viewBox="0 0 16 16"> - <path fill="currentColor" - d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z"/> - </symbol> - <symbol id="file" viewBox="0 0 16 16"> - <path fill="currentColor" - d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z"/> - </symbol> - <symbol id="file-added" viewBox="0 0 16 16"> - <path fill="currentColor" - d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V4.664a.25.25 0 0 0-.073-.177l-2.914-2.914a.25.25 0 0 0-.177-.073Zm4.48 3.758a.75.75 0 0 1 .755.745l.01 1.497h1.497a.75.75 0 0 1 0 1.5H9v1.507a.75.75 0 0 1-1.5 0V9.005l-1.502.01a.75.75 0 0 1-.01-1.5l1.507-.01-.01-1.492a.75.75 0 0 1 .745-.755Z"/> - </symbol> - <symbol id="file-modified" viewBox="0 0 16 16"> - <path fill="currentColor" - d="M1 1.75C1 .784 1.784 0 2.75 0h7.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16H2.75A1.75 1.75 0 0 1 1 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25V4.664a.25.25 0 0 0-.073-.177l-2.914-2.914a.25.25 0 0 0-.177-.073ZM8 3.25a.75.75 0 0 1 .75.75v1.5h1.5a.75.75 0 0 1 0 1.5h-1.5v1.5a.75.75 0 0 1-1.5 0V7h-1.5a.75.75 0 0 1 0-1.5h1.5V4A.75.75 0 0 1 8 3.25Zm-3 8a.75.75 0 0 1 .75-.75h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1-.75-.75Z"/> - </symbol> - <symbol id="file-deleted" viewBox="0 0 16 16"> - <path fill="currentColor" - d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V4.664a.25.25 0 0 0-.073-.177l-2.914-2.914a.25.25 0 0 0-.177-.073Zm4.5 6h2.242a.75.75 0 0 1 0 1.5h-2.24l-2.254.015a.75.75 0 0 1-.01-1.5Z"/> - </symbol> - <symbol id="file-renamed" viewBox="0 0 16 16"> - <path fill="currentColor" - d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-3.5a.75.75 0 0 1 0-1.5h3.5a.25.25 0 0 0 .25-.25V4.664a.25.25 0 0 0-.073-.177l-2.914-2.914a.25.25 0 0 0-.177-.073H3.75a.25.25 0 0 0-.25.25v6.5a.75.75 0 0 1-1.5 0v-6.5Z"/> - <path fill="currentColor" - d="m5.427 15.573 3.146-3.146a.25.25 0 0 0 0-.354L5.427 8.927A.25.25 0 0 0 5 9.104V11.5H.75a.75.75 0 0 0 0 1.5H5v2.396c0 .223.27.335.427.177Z"/> - </symbol> - <symbol id="arrow-top" viewBox="0 0 16 16"> - <path fill="currentColor" - d="M3.47 7.78a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0l4.25 4.25a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018L9 4.81v7.44a.75.75 0 0 1-1.5 0V4.81L4.53 7.78a.75.75 0 0 1-1.06 0Z"></path> - </symbol> - <symbol id="commit" viewBox="0 0 16 16"> - <path fill="currentColor" - d="M11.93 8.5a4.002 4.002 0 0 1-7.86 0H.75a.75.75 0 0 1 0-1.5h3.32a4.002 4.002 0 0 1 7.86 0h3.32a.75.75 0 0 1 0 1.5Zm-1.43-.75a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"></path> - </symbol> - <symbol id="branch" viewBox="0 0 16 16"> - <path fill="currentColor" - d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"></path> - </symbol> - <symbol id="tag" viewBox="0 0 16 16"> - <path fill="currentColor" - d="M1 7.775V2.75C1 1.784 1.784 1 2.75 1h5.025c.464 0 .91.184 1.238.513l6.25 6.25a1.75 1.75 0 0 1 0 2.474l-5.026 5.026a1.75 1.75 0 0 1-2.474 0l-6.25-6.25A1.752 1.752 0 0 1 1 7.775Zm1.5 0c0 .066.026.13.073.177l6.25 6.25a.25.25 0 0 0 .354 0l5.025-5.025a.25.25 0 0 0 0-.354l-6.25-6.25a.25.25 0 0 0-.177-.073H2.75a.25.25 0 0 0-.25.25ZM6 5a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z"></path> - </symbol> - <symbol id="code" viewBox="0 0 16 16"> - <path fill="currentColor" - d="m11.28 3.22 4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L13.94 8l-3.72-3.72a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215Zm-6.56 0a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042L2.06 8l3.72 3.72a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L.47 8.53a.75.75 0 0 1 0-1.06Z"></path> - </symbol> - </svg> -{{ end }} diff --git a/pkg/templates/tags.gohtml b/pkg/templates/tags.gohtml deleted file mode 100644 index 97b77b0..0000000 --- a/pkg/templates/tags.gohtml +++ /dev/null @@ -1,71 +0,0 @@ -{{- /*gotype: mokhan.ca/antonmedv/gitmal/pkg/templates.TagsParams*/ -}} -{{ define "head" }} - <style> - .tags { - border: 1px solid var(--c-border); - border-radius: var(--border-radius); - overflow: hidden; - } - - .tag-row { - display: flex; - align-items: center; - gap: 12px; - height: 44px; - padding-inline: 16px; - border-bottom: 1px solid var(--c-border); - background-color: var(--c-bg-elv); - } - - .tag-row:last-child { - border-bottom: none; - } - - .cell { - display: flex; - gap: 8px; - align-items: center; - } - - .tag-title { - flex: 1; - } - - .tag-row a { - color: var(--c-text-1); - } - - .tag-row a:hover { - color: var(--c-brand-2); - text-decoration: none; - } - - .date { - font-family: var(--font-family-mono), monospace; - font-size: 12px; - color: var(--c-text-2); - } - - .hash a { - font-family: var(--font-family-mono), monospace; - color: var(--c-text-2); - } - </style> -{{ end }} - -{{ define "body" }} - <h1>Tags</h1> - <div class="tags"> - {{ if .Tags }} - {{ range .Tags }} - <div class="tag-row"> - <div class="cell tag-title"><a href="commit/{{ .CommitHash }}.html">{{ .Name }}</a></div> - <div class="cell date">{{ .Date | FormatDate }}</div> - <div class="cell hash"><a href="commit/{{ .CommitHash }}.html">{{ ShortHash .CommitHash }}</a></div> - </div> - {{ end }} - {{ else }} - <div class="tag-row">(no tags)</div> - {{ end }} - </div> -{{ end }} diff --git a/pkg/templates/templates.go b/pkg/templates/templates.go deleted file mode 100644 index 593f0a3..0000000 --- a/pkg/templates/templates.go +++ /dev/null @@ -1,213 +0,0 @@ -package templates - -import ( - "embed" - _ "embed" - . "html/template" - "path/filepath" - "time" - - "mokhan.ca/antonmedv/gitmal/pkg/git" -) - -var funcs = FuncMap{ - "BaseName": filepath.Base, - "FormatDate": func(date time.Time) HTML { - return HTML(date.Format(`<span class="nowrap">2006-01-02</span> <span class="nowrap">15:04:05</span>`)) - }, - "ShortHash": func(hash string) string { - return hash[:7] - }, - "FileTreeParams": func(node []*FileTree) FileTreeParams { - return FileTreeParams{Nodes: node} - }, -} - -//go:embed css/markdown_light.css -var CSSMarkdownLight string - -//go:embed css/markdown_dark.css -var CSSMarkdownDark string - -//go:embed layout.gohtml header.gohtml file_tree.gohtml svg.gohtml -var layoutContent embed.FS -var layout = Must(New("layout").Funcs(funcs).ParseFS(layoutContent, "*.gohtml")) - -//go:embed blob.gohtml -var blobContent string -var BlobTemplate = Must(Must(layout.Clone()).Parse(blobContent)) - -//go:embed markdown.gohtml -var markdownContent string -var MarkdownTemplate = Must(Must(layout.Clone()).Parse(markdownContent)) - -//go:embed list.gohtml -var listContent string -var ListTemplate = Must(Must(layout.Clone()).Parse(listContent)) - -//go:embed branches.gohtml -var branchesContent string -var BranchesTemplate = Must(Must(layout.Clone()).Parse(branchesContent)) - -//go:embed tags.gohtml -var tagsContent string -var TagsTemplate = Must(Must(layout.Clone()).Parse(tagsContent)) - -//go:embed commits_list.gohtml -var commitsListContent string -var CommitsListTemplate = Must(Must(layout.Clone()).Parse(commitsListContent)) - -//go:embed commit.gohtml -var commitContent string -var CommitTemplate = Must(Must(layout.Clone()).Parse(commitContent)) - -//go:embed preview.gohtml -var previewContent string -var PreviewTemplate = Must(New("preview").Parse(previewContent)) - -type LayoutParams struct { - Title string - Name string - Dark bool - CSSMarkdown CSS - RootHref string - CurrentRefDir string - Selected string -} - -type HeaderParams struct { - Ref git.Ref - Header string - Breadcrumbs []Breadcrumb -} - -type Breadcrumb struct { - Name string - Href string - IsDir bool -} - -type BlobParams struct { - LayoutParams - HeaderParams - CSS CSS - Blob git.Blob - IsImage bool - IsBinary bool - Content HTML -} - -type MarkdownParams struct { - LayoutParams - HeaderParams - Blob git.Blob - Content HTML -} - -type ListParams struct { - LayoutParams - HeaderParams - Ref git.Ref - ParentHref string - Dirs []ListEntry - Files []ListEntry - Readme HTML -} - -type ListEntry struct { - Name string - Href string - IsDir bool - Mode string - Size string -} - -type BranchesParams struct { - LayoutParams - Branches []BranchEntry -} - -type BranchEntry struct { - Name string - Href string - IsDefault bool - CommitsHref string -} - -type TagsParams struct { - LayoutParams - Tags []git.Tag -} - -type Pagination struct { - Page int - TotalPages int - PrevHref string - NextHref string - FirstHref string - LastHref string -} - -type CommitsListParams struct { - LayoutParams - HeaderParams - Ref git.Ref - Commits []git.Commit - Page Pagination -} - -type CommitParams struct { - LayoutParams - Commit git.Commit - DiffCSS CSS - FileTree []*FileTree - FileViews []FileView -} - -type FileTreeParams struct { - Nodes []*FileTree -} - -// FileTree represents a directory or file in a commit's changed files tree. -// For directories, IsDir is true and Children contains nested nodes. -// For files, status flags indicate the type of change. -type FileTree struct { - Name string - Path string - IsDir bool - Children []*FileTree - - // File status (applicable when IsDir == false) - IsNew bool - IsDelete bool - IsRename bool - OldName string - NewName string - // Anchor id for this file (empty for directories) - Anchor string -} - -// FileView represents a single file section on the commit page with its -// pre-rendered HTML diff and metadata used by the template. -type FileView struct { - Path string - OldName string - NewName string - IsNew bool - IsDelete bool - IsRename bool - IsBinary bool - HasChanges bool - HTML HTML // pre-rendered HTML for diff of this file -} - -type PreviewCard struct { - Name string - Tone string - HTML HTML -} - -type PreviewParams struct { - Count int - Themes []PreviewCard -} |
