1// Copyright 2024 CUE Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15package vcs
16
17import (
18 "context"
19 "fmt"
20 "path/filepath"
21 "slices"
22 "strconv"
23 "strings"
24 "time"
25)
26
27type gitVCS struct {
28 root string
29}
30
31func newGitVCS(dir string) (VCS, error) {
32 root := findRoot(dir, ".git")
33 if root == "" {
34 return nil, &vcsNotFoundError{
35 kind: "git",
36 dir: dir,
37 }
38 }
39 return gitVCS{
40 root: root,
41 }, nil
42}
43
44// Root implements [VCS.Root].
45func (v gitVCS) Root() string {
46 return v.root
47}
48
49// fixDir adjusts dir according to the semantics described in [VCS.ListFiles].
50func fixDir(v VCS, dir string) string {
51 if dir == "" {
52 return v.Root()
53 }
54 if !filepath.IsAbs(dir) {
55 return filepath.Join(v.Root(), dir)
56 }
57 return dir
58}
59
60// ListFiles implements [VCS.ListFiles].
61func (v gitVCS) ListFiles(ctx context.Context, dir string, paths ...string) ([]string, error) {
62 dir = fixDir(v, dir)
63
64 // TODO should we use --recurse-submodules?
65 gitargs := append([]string{"ls-files", "-z", "--"}, paths...)
66 out, err := runCmd(ctx, dir, "git", gitargs...)
67 if err != nil {
68 return nil, err
69 }
70 out = strings.TrimSuffix(out, "\x00")
71 if out == "" {
72 return nil, nil
73 }
74 files := strings.Split(out, "\x00")
75 slices.Sort(files)
76 return files, nil
77}
78
79// Status implements [VCS.Status].
80func (v gitVCS) Status(ctx context.Context, paths ...string) (Status, error) {
81 gitargs := append([]string{"status", "--porcelain", "--"}, paths...)
82 out, err := runCmd(ctx, v.root, "git", gitargs...)
83 if err != nil {
84 return Status{}, err
85 }
86 uncommitted := len(out) > 0
87
88 // "git status" works for empty repositories, but "git log" does not.
89 // Assume there are no commits in the repo when "git log" fails with
90 // uncommitted files and skip tagging revision / committime.
91 var rev string
92 var commitTime time.Time
93 out, err = runCmd(ctx, v.root, "git",
94 "-c", "log.showsignature=false",
95 "log", "-1", "--format=%H:%ct",
96 )
97 if err != nil && !uncommitted {
98 return Status{}, err
99 }
100 if err == nil {
101 rev, commitTime, err = parseRevTime(out)
102 if err != nil {
103 return Status{}, err
104 }
105 }
106 return Status{
107 Revision: rev,
108 CommitTime: commitTime,
109 Uncommitted: uncommitted,
110 }, nil
111}
112
113// parseRevTime parses commit details in "revision:seconds" format.
114func parseRevTime(out string) (string, time.Time, error) {
115 buf := strings.TrimSpace(out)
116
117 rev, t, _ := strings.Cut(buf, ":")
118 if rev == "" {
119 return "", time.Time{}, fmt.Errorf("unrecognized VCS tool output %q", out)
120 }
121
122 secs, err := strconv.ParseInt(t, 10, 64)
123 if err != nil {
124 return "", time.Time{}, fmt.Errorf("unrecognized VCS tool output: %v", err)
125 }
126
127 return rev, time.Unix(secs, 0), nil
128}