From b5a545a3d883026d61beac5556fec2a45e9ec3d3 Mon Sep 17 00:00:00 2001 From: Runxi Yu Date: Fri, 20 Feb 2026 21:20:35 +0800 Subject: object: Add basic object code --- object/blob.go | 12 ++++ object/blob_parse.go | 6 ++ object/blob_serialize.go | 13 ++++ object/commit.go | 20 ++++++ object/commit_parse.go | 86 +++++++++++++++++++++++++ object/commit_serialize.go | 69 ++++++++++++++++++++ object/extraheader.go | 7 +++ object/ident.go | 122 ++++++++++++++++++++++++++++++++++++ object/object.go | 89 ++++++++++++++++++++++++++ object/tag.go | 18 ++++++ object/tag_parse.go | 81 ++++++++++++++++++++++++ object/tag_serialize.go | 57 +++++++++++++++++ object/tree.go | 153 +++++++++++++++++++++++++++++++++++++++++++++ object/tree_parse.go | 57 +++++++++++++++++ object/tree_serialize.go | 40 ++++++++++++ 15 files changed, 830 insertions(+) create mode 100644 object/blob.go create mode 100644 object/blob_parse.go create mode 100644 object/blob_serialize.go create mode 100644 object/commit.go create mode 100644 object/commit_parse.go create mode 100644 object/commit_serialize.go create mode 100644 object/extraheader.go create mode 100644 object/ident.go create mode 100644 object/object.go create mode 100644 object/tag.go create mode 100644 object/tag_parse.go create mode 100644 object/tag_serialize.go create mode 100644 object/tree.go create mode 100644 object/tree_parse.go create mode 100644 object/tree_serialize.go (limited to 'object') diff --git a/object/blob.go b/object/blob.go new file mode 100644 index 00000000..03d17df6 --- /dev/null +++ b/object/blob.go @@ -0,0 +1,12 @@ +package object + +// Blob represents a Git blob object. +type Blob struct { + Data []byte +} + +// ObjectType returns TypeBlob. +func (blob *Blob) ObjectType() Type { + _ = blob + return TypeBlob +} diff --git a/object/blob_parse.go b/object/blob_parse.go new file mode 100644 index 00000000..61aacfac --- /dev/null +++ b/object/blob_parse.go @@ -0,0 +1,6 @@ +package object + +// ParseBlob decodes a blob object body. +func ParseBlob(body []byte) (*Blob, error) { + return &Blob{Data: append([]byte(nil), body...)}, nil +} diff --git a/object/blob_serialize.go b/object/blob_serialize.go new file mode 100644 index 00000000..c1818c20 --- /dev/null +++ b/object/blob_serialize.go @@ -0,0 +1,13 @@ +package object + +// Serialize renders the raw object (header + body). +func (blob *Blob) Serialize() ([]byte, error) { + header, err := headerForType(TypeBlob, blob.Data) + if err != nil { + return nil, err + } + raw := make([]byte, len(header)+len(blob.Data)) + copy(raw, header) + copy(raw[len(header):], blob.Data) + return raw, nil +} diff --git a/object/commit.go b/object/commit.go new file mode 100644 index 00000000..0183ff02 --- /dev/null +++ b/object/commit.go @@ -0,0 +1,20 @@ +package object + +import "codeberg.org/lindenii/furgit/oid" + +// Commit represents a Git commit object. +type Commit struct { + Tree oid.ObjectID + Parents []oid.ObjectID + Author Ident + Committer Ident + Message []byte + ChangeID string + ExtraHeaders []ExtraHeader +} + +// ObjectType returns TypeCommit. +func (commit *Commit) ObjectType() Type { + _ = commit + return TypeCommit +} diff --git a/object/commit_parse.go b/object/commit_parse.go new file mode 100644 index 00000000..acc90951 --- /dev/null +++ b/object/commit_parse.go @@ -0,0 +1,86 @@ +package object + +import ( + "bytes" + "errors" + "fmt" + + "codeberg.org/lindenii/furgit/oid" +) + +// ParseCommit decodes a commit object body. +func ParseCommit(body []byte, algo oid.Algorithm) (*Commit, error) { + if algo.Size() == 0 { + return nil, ErrInvalidObject + } + + c := new(Commit) + i := 0 + for i < len(body) { + rel := bytes.IndexByte(body[i:], '\n') + if rel < 0 { + return nil, errors.New("object: commit: missing newline") + } + line := body[i : i+rel] + i += rel + 1 + if len(line) == 0 { + break + } + + key, value, found := bytes.Cut(line, []byte{' '}) + if !found { + return nil, errors.New("object: commit: malformed header") + } + + switch string(key) { + case "tree": + id, err := oid.ParseHex(algo, string(value)) + if err != nil { + return nil, fmt.Errorf("object: commit: tree: %w", err) + } + c.Tree = id + case "parent": + id, err := oid.ParseHex(algo, string(value)) + if err != nil { + return nil, fmt.Errorf("object: commit: parent: %w", err) + } + c.Parents = append(c.Parents, id) + case "author": + idt, err := ParseIdent(value) + if err != nil { + return nil, fmt.Errorf("object: commit: author: %w", err) + } + c.Author = *idt + case "committer": + idt, err := ParseIdent(value) + if err != nil { + return nil, fmt.Errorf("object: commit: committer: %w", err) + } + c.Committer = *idt + case "change-id": + c.ChangeID = string(value) + case "gpgsig", "gpgsig-sha256": + for i < len(body) { + nextRel := bytes.IndexByte(body[i:], '\n') + if nextRel < 0 { + return nil, errors.New("object: commit: unterminated gpgsig") + } + if body[i] != ' ' { + break + } + i += nextRel + 1 + } + default: + c.ExtraHeaders = append(c.ExtraHeaders, ExtraHeader{ + Key: string(key), + Value: append([]byte(nil), value...), + }) + } + } + + if i > len(body) { + return nil, ErrInvalidObject + } + c.Message = append([]byte(nil), body[i:]...) + return c, nil +} diff --git a/object/commit_serialize.go b/object/commit_serialize.go new file mode 100644 index 00000000..b06e597c --- /dev/null +++ b/object/commit_serialize.go @@ -0,0 +1,69 @@ +package object + +import ( + "bytes" + "fmt" +) + +func (commit *Commit) serialize() ([]byte, error) { + var buf bytes.Buffer + + if commit.Tree.Size() == 0 { + return nil, ErrInvalidObject + } + fmt.Fprintf(&buf, "tree %s\n", commit.Tree.String()) + for _, parent := range commit.Parents { + fmt.Fprintf(&buf, "parent %s\n", parent.String()) + } + + authorBytes, err := commit.Author.Serialize() + if err != nil { + return nil, err + } + buf.WriteString("author ") + buf.Write(authorBytes) + buf.WriteByte('\n') + + committerBytes, err := commit.Committer.Serialize() + if err != nil { + return nil, err + } + buf.WriteString("committer ") + buf.Write(committerBytes) + buf.WriteByte('\n') + + if commit.ChangeID != "" { + buf.WriteString("change-id ") + buf.WriteString(commit.ChangeID) + buf.WriteByte('\n') + } + for _, h := range commit.ExtraHeaders { + if h.Key == "" { + return nil, ErrInvalidObject + } + buf.WriteString(h.Key) + buf.WriteByte(' ') + buf.Write(h.Value) + buf.WriteByte('\n') + } + + buf.WriteByte('\n') + buf.Write(commit.Message) + return buf.Bytes(), nil +} + +// Serialize renders the raw object (header + body). +func (commit *Commit) Serialize() ([]byte, error) { + body, err := commit.serialize() + if err != nil { + return nil, err + } + header, err := headerForType(TypeCommit, body) + if err != nil { + return nil, err + } + raw := make([]byte, len(header)+len(body)) + copy(raw, header) + copy(raw[len(header):], body) + return raw, nil +} diff --git a/object/extraheader.go b/object/extraheader.go new file mode 100644 index 00000000..4ad1ec09 --- /dev/null +++ b/object/extraheader.go @@ -0,0 +1,7 @@ +package object + +// ExtraHeader represents an extra header in a Git object. +type ExtraHeader struct { + Key string + Value []byte +} diff --git a/object/ident.go b/object/ident.go new file mode 100644 index 00000000..e601802a --- /dev/null +++ b/object/ident.go @@ -0,0 +1,122 @@ +package object + +import ( + "bytes" + "errors" + "fmt" + "math" + "strconv" + "strings" + "time" +) + +// Ident represents a Git identity (author/committer/tagger). +type Ident struct { + Name []byte + Email []byte + WhenUnix int64 + OffsetMinutes int32 +} + +// ParseIdent parses a canonical Git identity line: +// "Name 123456789 +0000". +func ParseIdent(line []byte) (*Ident, error) { + lt := bytes.IndexByte(line, '<') + if lt < 0 { + return nil, errors.New("object: ident: missing opening <") + } + gtRel := bytes.IndexByte(line[lt+1:], '>') + if gtRel < 0 { + return nil, errors.New("object: ident: missing closing >") + } + gt := lt + 1 + gtRel + + nameBytes := append([]byte(nil), bytes.TrimRight(line[:lt], " ")...) + emailBytes := append([]byte(nil), line[lt+1:gt]...) + + rest := line[gt+1:] + if len(rest) == 0 || rest[0] != ' ' { + return nil, errors.New("object: ident: missing timestamp separator") + } + rest = rest[1:] + sp := bytes.IndexByte(rest, ' ') + if sp < 0 { + return nil, errors.New("object: ident: missing timezone separator") + } + when, err := strconv.ParseInt(string(rest[:sp]), 10, 64) + if err != nil { + return nil, fmt.Errorf("object: ident: invalid timestamp: %w", err) + } + + tz := rest[sp+1:] + if len(tz) < 5 { + return nil, errors.New("object: ident: invalid timezone encoding") + } + sign := 1 + switch tz[0] { + case '-': + sign = -1 + case '+': + default: + return nil, errors.New("object: ident: invalid timezone sign") + } + + hh, err := strconv.Atoi(string(tz[1:3])) + if err != nil { + return nil, fmt.Errorf("object: ident: invalid timezone hours: %w", err) + } + mm, err := strconv.Atoi(string(tz[3:5])) + if err != nil { + return nil, fmt.Errorf("object: ident: invalid timezone minutes: %w", err) + } + if hh < 0 || hh > 23 { + return nil, errors.New("object: ident: invalid timezone hours range") + } + if mm < 0 || mm > 59 { + return nil, errors.New("object: ident: invalid timezone minutes range") + } + total := int64(hh)*60 + int64(mm) + if total > math.MaxInt32 { + return nil, errors.New("object: ident: timezone overflow") + } + + offset := int32(total) + if sign < 0 { + offset = -offset + } + return &Ident{ + Name: nameBytes, + Email: emailBytes, + WhenUnix: when, + OffsetMinutes: offset, + }, nil +} + +// Serialize renders the identity in canonical Git format. +func (ident Ident) Serialize() ([]byte, error) { + var b strings.Builder + b.Grow(len(ident.Name) + len(ident.Email) + 32) + b.Write(ident.Name) + b.WriteString(" <") + b.Write(ident.Email) + b.WriteString("> ") + b.WriteString(strconv.FormatInt(ident.WhenUnix, 10)) + b.WriteByte(' ') + + offset := ident.OffsetMinutes + sign := '+' + if offset < 0 { + sign = '-' + offset = -offset + } + hh := offset / 60 + mm := offset % 60 + fmt.Fprintf(&b, "%c%02d%02d", sign, hh, mm) + return []byte(b.String()), nil +} + +// When returns a time.Time with the identity's timezone offset. +func (ident Ident) When() time.Time { + loc := time.FixedZone("git", int(ident.OffsetMinutes)*60) + return time.Unix(ident.WhenUnix, 0).In(loc) +} diff --git a/object/object.go b/object/object.go new file mode 100644 index 00000000..c40769a7 --- /dev/null +++ b/object/object.go @@ -0,0 +1,89 @@ +// Package object provides Git object models and codecs. +package object + +import ( + "bytes" + "errors" + "fmt" + "strconv" +) + +var ( + // ErrInvalidObject indicates malformed serialized data. + ErrInvalidObject = errors.New("object: invalid object encoding") + // ErrNotFound indicates missing entries in in-memory lookups. + ErrNotFound = errors.New("object: not found") +) + +// Type mirrors Git object type tags. +type Type uint8 + +const ( + TypeInvalid Type = 0 + TypeCommit Type = 1 + TypeTree Type = 2 + TypeBlob Type = 3 + TypeTag Type = 4 + TypeFuture Type = 5 + TypeOfsDelta Type = 6 + TypeRefDelta Type = 7 +) + +const ( + typeNameBlob = "blob" + typeNameTree = "tree" + typeNameCommit = "commit" + typeNameTag = "tag" +) + +// Object is a Git object that can serialize itself. +type Object interface { + ObjectType() Type + Serialize() ([]byte, error) +} + +// ParseTypeName parses a canonical Git object type name. +func ParseTypeName(name string) (Type, error) { + switch name { + case typeNameBlob: + return TypeBlob, nil + case typeNameTree: + return TypeTree, nil + case typeNameCommit: + return TypeCommit, nil + case typeNameTag: + return TypeTag, nil + default: + return TypeInvalid, ErrInvalidObject + } +} + +func typeName(ty Type) (string, error) { + switch ty { + case TypeBlob: + return typeNameBlob, nil + case TypeTree: + return typeNameTree, nil + case TypeCommit: + return typeNameCommit, nil + case TypeTag: + return typeNameTag, nil + default: + return "", fmt.Errorf("object: unsupported type %d", ty) + } +} + +func headerForType(ty Type, body []byte) ([]byte, error) { + tyStr, err := typeName(ty) + if err != nil { + return nil, err + } + size := strconv.Itoa(len(body)) + var buf bytes.Buffer + buf.Grow(len(tyStr) + len(size) + 2) + buf.WriteString(tyStr) + buf.WriteByte(' ') + buf.WriteString(size) + buf.WriteByte(0) + return buf.Bytes(), nil +} diff --git a/object/tag.go b/object/tag.go new file mode 100644 index 00000000..ec91ffde --- /dev/null +++ b/object/tag.go @@ -0,0 +1,18 @@ +package object + +import "codeberg.org/lindenii/furgit/oid" + +// Tag represents a Git annotated tag object. +type Tag struct { + Target oid.ObjectID + TargetType Type + Name []byte + Tagger *Ident + Message []byte +} + +// ObjectType returns TypeTag. +func (tag *Tag) ObjectType() Type { + _ = tag + return TypeTag +} diff --git a/object/tag_parse.go b/object/tag_parse.go new file mode 100644 index 00000000..565b9f89 --- /dev/null +++ b/object/tag_parse.go @@ -0,0 +1,81 @@ +package object + +import ( + "bytes" + "errors" + "fmt" + + "codeberg.org/lindenii/furgit/oid" +) + +// ParseTag decodes a tag object body. +func ParseTag(body []byte, algo oid.Algorithm) (*Tag, error) { + if algo.Size() == 0 { + return nil, ErrInvalidObject + } + + t := new(Tag) + i := 0 + var haveTarget, haveType bool + + for i < len(body) { + rel := bytes.IndexByte(body[i:], '\n') + if rel < 0 { + return nil, errors.New("object: tag: missing newline") + } + line := body[i : i+rel] + i += rel + 1 + if len(line) == 0 { + break + } + + key, value, found := bytes.Cut(line, []byte{' '}) + if !found { + return nil, errors.New("object: tag: malformed header") + } + + switch string(key) { + case "object": + id, err := oid.ParseHex(algo, string(value)) + if err != nil { + return nil, fmt.Errorf("object: tag: object: %w", err) + } + t.Target = id + haveTarget = true + case "type": + ty, err := ParseTypeName(string(value)) + if err != nil { + return nil, errors.New("object: tag: unknown target type") + } + t.TargetType = ty + haveType = true + case "tag": + t.Name = append([]byte(nil), value...) + case "tagger": + idt, err := ParseIdent(value) + if err != nil { + return nil, fmt.Errorf("object: tag: tagger: %w", err) + } + t.Tagger = idt + case "gpgsig", "gpgsig-sha256": + for i < len(body) { + nextRel := bytes.IndexByte(body[i:], '\n') + if nextRel < 0 { + return nil, errors.New("object: tag: unterminated gpgsig") + } + if body[i] != ' ' { + break + } + i += nextRel + 1 + } + default: + // Ignore unknown headers for now. + } + } + + if !haveTarget || !haveType { + return nil, errors.New("object: tag: missing required headers") + } + t.Message = append([]byte(nil), body[i:]...) + return t, nil +} diff --git a/object/tag_serialize.go b/object/tag_serialize.go new file mode 100644 index 00000000..9600b81f --- /dev/null +++ b/object/tag_serialize.go @@ -0,0 +1,57 @@ +package object + +import ( + "bytes" + "fmt" +) + +func (tag *Tag) serialize() ([]byte, error) { + if tag.Target.Size() == 0 { + return nil, ErrInvalidObject + } + + var buf bytes.Buffer + fmt.Fprintf(&buf, "object %s\n", tag.Target.String()) + + tyName, err := typeName(tag.TargetType) + if err != nil { + return nil, fmt.Errorf("object: tag: invalid target type %d", tag.TargetType) + } + buf.WriteString("type ") + buf.WriteString(tyName) + buf.WriteByte('\n') + + buf.WriteString("tag ") + buf.Write(tag.Name) + buf.WriteByte('\n') + + if tag.Tagger != nil { + taggerBytes, err := tag.Tagger.Serialize() + if err != nil { + return nil, err + } + buf.WriteString("tagger ") + buf.Write(taggerBytes) + buf.WriteByte('\n') + } + + buf.WriteByte('\n') + buf.Write(tag.Message) + return buf.Bytes(), nil +} + +// Serialize renders the raw object (header + body). +func (tag *Tag) Serialize() ([]byte, error) { + body, err := tag.serialize() + if err != nil { + return nil, err + } + header, err := headerForType(TypeTag, body) + if err != nil { + return nil, err + } + raw := make([]byte, len(header)+len(body)) + copy(raw, header) + copy(raw[len(header):], body) + return raw, nil +} diff --git a/object/tree.go b/object/tree.go new file mode 100644 index 00000000..a922f2ab --- /dev/null +++ b/object/tree.go @@ -0,0 +1,153 @@ +package object + +import ( + "bytes" + "fmt" + "sort" + + "codeberg.org/lindenii/furgit/oid" +) + +// FileMode represents the mode of a file in a Git tree. +type FileMode uint32 + +const ( + FileModeDir FileMode = 0o40000 + FileModeRegular FileMode = 0o100644 + FileModeExecutable FileMode = 0o100755 + FileModeSymlink FileMode = 0o120000 + FileModeGitlink FileMode = 0o160000 +) + +// TreeEntry represents a single entry in a tree. +type TreeEntry struct { + Mode FileMode + Name []byte + ID oid.ObjectID +} + +// Tree represents a Git tree object. +type Tree struct { + Entries []TreeEntry +} + +// ObjectType returns TypeTree. +func (tree *Tree) ObjectType() Type { + _ = tree + return TypeTree +} + +// Entry looks up a tree entry by name. +func (tree *Tree) Entry(name []byte) *TreeEntry { + if len(tree.Entries) == 0 { + return nil + } + if e := tree.entry(name, true); e != nil { + return e + } + return tree.entry(name, false) +} + +func (tree *Tree) entry(name []byte, searchIsTree bool) *TreeEntry { + low, high := 0, len(tree.Entries)-1 + for low <= high { + mid := low + (high-low)/2 + entry := &tree.Entries[mid] + cmp := TreeEntryNameCompare(entry.Name, entry.Mode, name, searchIsTree) + if cmp == 0 { + if bytes.Equal(entry.Name, name) { + return entry + } + return nil + } + if cmp < 0 { + low = mid + 1 + } else { + high = mid - 1 + } + } + return nil +} + +// InsertEntry inserts a tree entry while preserving Git ordering. +func (tree *Tree) InsertEntry(newEntry TreeEntry) error { + if tree == nil { + return ErrInvalidObject + } + if tree.entry(newEntry.Name, true) != nil || tree.entry(newEntry.Name, false) != nil { + return fmt.Errorf("object: tree: entry %q already exists", newEntry.Name) + } + newIsTree := newEntry.Mode == FileModeDir + insertAt := sort.Search(len(tree.Entries), func(i int) bool { + return TreeEntryNameCompare(tree.Entries[i].Name, tree.Entries[i].Mode, newEntry.Name, newIsTree) >= 0 + }) + tree.Entries = append(tree.Entries, TreeEntry{}) + copy(tree.Entries[insertAt+1:], tree.Entries[insertAt:]) + tree.Entries[insertAt] = newEntry + return nil +} + +// RemoveEntry removes a tree entry by name. +func (tree *Tree) RemoveEntry(name []byte) error { + if tree == nil { + return ErrInvalidObject + } + if len(tree.Entries) == 0 { + return ErrNotFound + } + for i := range tree.Entries { + if bytes.Equal(tree.Entries[i].Name, name) { + copy(tree.Entries[i:], tree.Entries[i+1:]) + tree.Entries = tree.Entries[:len(tree.Entries)-1] + return nil + } + } + return ErrNotFound +} + +// TreeEntryNameCompare compares names using Git tree ordering rules. +func TreeEntryNameCompare(entryName []byte, entryMode FileMode, searchName []byte, searchIsTree bool) int { + isEntryTree := entryMode == FileModeDir + + entryLen := len(entryName) + if isEntryTree { + entryLen++ + } + searchLen := len(searchName) + if searchIsTree { + searchLen++ + } + + n := entryLen + if searchLen < n { + n = searchLen + } + + for i := 0; i < n; i++ { + var ec, sc byte + if i < len(entryName) { + ec = entryName[i] + } else { + ec = '/' + } + if i < len(searchName) { + sc = searchName[i] + } else { + sc = '/' + } + if ec < sc { + return -1 + } + if ec > sc { + return 1 + } + } + + if entryLen < searchLen { + return -1 + } + if entryLen > searchLen { + return 1 + } + return 0 +} diff --git a/object/tree_parse.go b/object/tree_parse.go new file mode 100644 index 00000000..e1e560b6 --- /dev/null +++ b/object/tree_parse.go @@ -0,0 +1,57 @@ +package object + +import ( + "bytes" + "fmt" + "strconv" + + "codeberg.org/lindenii/furgit/oid" +) + +// ParseTree decodes a tree object body. +func ParseTree(body []byte, algo oid.Algorithm) (*Tree, error) { + if algo.Size() == 0 { + return nil, ErrInvalidObject + } + + var entries []TreeEntry + i := 0 + for i < len(body) { + space := bytes.IndexByte(body[i:], ' ') + if space < 0 { + return nil, fmt.Errorf("object: tree: missing mode terminator") + } + modeBytes := body[i : i+space] + i += space + 1 + + nul := bytes.IndexByte(body[i:], 0) + if nul < 0 { + return nil, fmt.Errorf("object: tree: missing name terminator") + } + nameBytes := body[i : i+nul] + i += nul + 1 + + idEnd := i + algo.Size() + if idEnd > len(body) { + return nil, fmt.Errorf("object: tree: truncated child object id") + } + id, err := oid.FromBytes(algo, body[i:idEnd]) + if err != nil { + return nil, err + } + i = idEnd + + mode, err := strconv.ParseUint(string(modeBytes), 8, 32) + if err != nil { + return nil, fmt.Errorf("object: tree: parse mode: %w", err) + } + + entries = append(entries, TreeEntry{ + Mode: FileMode(mode), + Name: append([]byte(nil), nameBytes...), + ID: id, + }) + } + + return &Tree{Entries: entries}, nil +} diff --git a/object/tree_serialize.go b/object/tree_serialize.go new file mode 100644 index 00000000..b6182a03 --- /dev/null +++ b/object/tree_serialize.go @@ -0,0 +1,40 @@ +package object + +import "strconv" + +func (tree *Tree) serialize() []byte { + var bodyLen int + for _, entry := range tree.Entries { + mode := strconv.FormatUint(uint64(entry.Mode), 8) + bodyLen += len(mode) + 1 + len(entry.Name) + 1 + entry.ID.Size() + } + + body := make([]byte, bodyLen) + pos := 0 + for _, entry := range tree.Entries { + mode := strconv.FormatUint(uint64(entry.Mode), 8) + pos += copy(body[pos:], mode) + body[pos] = ' ' + pos++ + pos += copy(body[pos:], entry.Name) + body[pos] = 0 + pos++ + id := entry.ID.Bytes() + pos += copy(body[pos:], id) + } + + return body +} + +// Serialize renders the raw object (header + body). +func (tree *Tree) Serialize() ([]byte, error) { + body := tree.serialize() + header, err := headerForType(TypeTree, body) + if err != nil { + return nil, err + } + raw := make([]byte, len(header)+len(body)) + copy(raw, header) + copy(raw[len(header):], body) + return raw, nil +} -- cgit v1.3.1-10-gc9f91