// Copyright 2019 Dolthub, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datas import ( "errors" "fmt" "time" errors2 "gopkg.in/src-d/go-errors.v1" "github.com/dolthub/dolt/go/libraries/doltcore/dconfig" ) const ( commitMetaNameKey = "name" commitMetaEmailKey = "email" commitMetaDescKey = "desc" commitMetaTimestampKey = "timestamp" commitMetaUserTSKey = "user_timestamp" commitMetaVersionKey = "metaversion" commitMetaSignature = "signature" commitMetaStName = "metadata" commitMetaVersion = "1.0" ) const defaultInitialCommitMessage = "Initialize data repository" var ErrNameNotConfigured = errors2.NewKind("Aborting commit due to empty %s name. Is your config set?") var ErrEmailNotConfigured = errors2.NewKind("Aborting commit due to empty %s email. Is your config set?") var ErrEmptyCommitMessage = errors.New("Aborting commit due to empty commit message.") // CommitNow is the function used to get the committer time when creating commits. Tests can replace // this function to produce deterministic commit hashes. var CommitNow = time.Now // CommitLoc is the location used when formatting commit timestamps for display. var CommitLoc = time.Local // CommitDate holds a timestamp for a commit author or committer. The zero value is "unset" - // the serialization path replaces it with [CommitNow] via [CommitDate.Or]. Use [CommitDateAt] // to set an explicit time; the zero time is a valid explicit value. // // TODO(elianddb): the flatbuffer schema stores only UTC milliseconds; the timezone is // dropped on write and reconstructed as the reader's local zone on read. See // [git commit-tree] for the format that preserves both. // // [git commit-tree]: https://git-scm.com/docs/git-commit-tree#_commit_information type CommitDate struct { t time.Time set bool } // CommitDateAt returns a [CommitDate] with the given explicit time. The zero time is a valid value. func CommitDateAt(t time.Time) CommitDate { return CommitDate{t: t, set: true} } // Or returns the receiver when an explicit time was set; otherwise it returns a [CommitDate] pinned to |t|. func (d CommitDate) Or(t time.Time) CommitDate { if d.set { return d } return CommitDateAt(t) } // Time returns the captured timestamp. Panics if the receiver is unset; callers must construct // with [CommitDateAt] or pin via [CommitDate.Or] before reading. func (d CommitDate) Time() time.Time { if !d.set { panic("datas: CommitDate.Time called on unset date; construct with CommitDateAt or pin via Or first") } return d.t } // NewCommitDate parses |dateStr| and returns a [CommitDateAt]. If |dateStr| is empty or cannot be // parsed, it returns the unset [CommitDate] zero value and an error. func NewCommitDate(dateStr string) (commitDate CommitDate, err error) { if dateStr == "" { return CommitDate{}, fmt.Errorf("date string cannot be empty for new CommitDate") } t, err := dconfig.ParseDate(dateStr) if err != nil { return CommitDate{}, err } return CommitDateAt(t), nil } // CommitIdent holds the name, email, and date for a single author or committer. Use [CommitDateAt] // for an explicit date or the [CommitDate] zero value to capture the current time at serialization. type CommitIdent struct { Name string Email string Date CommitDate } // CommitMeta holds the author and committer identities, commit message, and optional cryptographic // signature for a commit. type CommitMeta struct { Author CommitIdent Committer CommitIdent Description string Signature string } // TimestampMillis returns the committer date as milliseconds since Unix epoch. func (cm *CommitMeta) TimestampMillis() uint64 { return uint64(cm.Committer.Date.Time().UnixMilli()) } // UserTimestampMillis returns the author date as milliseconds since Unix epoch. func (cm *CommitMeta) UserTimestampMillis() int64 { return cm.Author.Date.Time().UnixMilli() } // NewCommitMeta returns a [CommitMeta] using |name|, |email|, and |desc| as both the author and committer // identity. Both dates are left unset and are captured when the commit is written to storage. func NewCommitMeta(name, email, desc string) (*CommitMeta, error) { identity := CommitIdent{Name: name, Email: email} return NewCommitMetaWithAuthorCommitter(identity, identity, desc) } // NewCommitMetaWithAuthor returns a [CommitMeta] using |name| and |email| as both the author and committer // identity, with |authorDate| as the explicit author date. The committer date is left unset and is // captured when the commit is written to storage. func NewCommitMetaWithAuthor(name, email, desc string, authorDate time.Time) (*CommitMeta, error) { author := CommitIdent{Name: name, Email: email, Date: CommitDateAt(authorDate)} committer := CommitIdent{Name: name, Email: email} return NewCommitMetaWithAuthorCommitter(author, committer, desc) } // NewCommitMetaWithAuthorCommitter returns a [CommitMeta] with distinct |author| and |committer| identities. // When either date is unset, both are resolved to the same timestamp when the commit is written // to storage. func NewCommitMetaWithAuthorCommitter(author CommitIdent, committer CommitIdent, description string) (*CommitMeta, error) { if author.Name == "" { return nil, ErrNameNotConfigured.New("author") } if author.Email == "" { return nil, ErrEmailNotConfigured.New("author") } if description == "" { return nil, ErrEmptyCommitMessage } if committer.Name == "" { return nil, ErrNameNotConfigured.New("committer") } if committer.Email == "" { return nil, ErrEmailNotConfigured.New("committer") } return &CommitMeta{ Author: author, Committer: committer, Description: description, }, nil } // FormatTS returns the author date as a [time.RubyDate] string. The timestamp is rounded to the nearest // second to match MySQL timestamp precision, producing output consistent with the standard Git log format. func (cm *CommitMeta) FormatTS() string { return cm.Author.Date.Time().In(CommitLoc).Round(time.Second).Format(time.RubyDate) } // String returns a human-readable summary of the commit metadata. func (cm *CommitMeta) String() string { return fmt.Sprintf("name: %s, email: %s, timestamp: %s, description: %s", cm.Author.Name, cm.Author.Email, cm.FormatTS(), cm.Description) } // CommitMetaGenerator is an interface that generates a sequence of CommitMeta structs, and implements a predicate to check whether // a proposed commit is acceptable. type CommitMetaGenerator interface { Next() (*CommitMeta, error) IsGoodCommit(*Commit) bool } // The default implementation of CommitMetaGenerator, which generates a single commit which is always acceptable. type simpleCommitMetaGenerator struct { name, email string timestamp time.Time message string alreadyGenerated bool } func (g *simpleCommitMetaGenerator) Next() (*CommitMeta, error) { if g.alreadyGenerated { return nil, fmt.Errorf("Called simpleCommitMetaGenerator.Next twice. This should never happen.") } g.alreadyGenerated = true author := CommitIdent{Name: g.name, Email: g.email, Date: CommitDateAt(g.timestamp)} committer := CommitIdent{Name: g.name, Email: g.email} return NewCommitMetaWithAuthorCommitter(author, committer, g.message) } func (*simpleCommitMetaGenerator) IsGoodCommit(*Commit) bool { return true } func MakeCommitMetaGenerator(name, email string, timestamp time.Time) CommitMetaGenerator { return &simpleCommitMetaGenerator{name: name, email: email, timestamp: timestamp, message: defaultInitialCommitMessage, alreadyGenerated: false} } // signaturePayloadV1 generates the legacy signature payload format that includes only author information. func signaturePayloadV1(dbName string, meta *CommitMeta, headHash, stagedHash string) string { return fmt.Sprintf("db: %s\nMessage: %s\nName: %s\nEmail: %s\nDate: %s\nHead: %s\nStaged: %s", dbName, meta.Description, meta.Author.Name, meta.Author.Email, meta.Author.Date.Time().String(), headHash, stagedHash, ) } // SignaturePayloadV2 generates the signature payload including both author and committer information. // Committer fields are appended after the author fields so V1 is a strict prefix, enabling forward compatibility. func SignaturePayloadV2(dbName string, meta *CommitMeta, headHash, stagedHash string) string { return fmt.Sprintf("%s\nCommitterName: %s\nCommitterEmail: %s\nCommitterDate: %s", signaturePayloadV1(dbName, meta, headHash, stagedHash), meta.Committer.Name, meta.Committer.Email, meta.Committer.Date.Time().String()) }