返回 DeepSeek-Reasonix
repo.go
根目录 / internal / gitcmd / repo.go
1 package gitcmd
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strings"
11 )
12
13 // ErrNotRepository reports that a directory is not inside a git work tree, or
14 // that a Repo was used before one was resolved.
15 var ErrNotRepository = errors.New("gitcmd: not inside a git work tree")
16
17 // Repo is a repository identity resolved once, when a workspace is opened.
18 // Commands run through it name the git dir, common dir and work tree
19 // explicitly, so files the workspace gains later cannot change which
20 // repository, or whose configuration, git reads.
21 type Repo struct {
22 Dir string `json:"dir"` // where commands run; relative pathspecs start here
23 GitDir string `json:"gitDir"`
24 CommonDir string `json:"commonDir"`
25 WorkTree string `json:"workTree"`
26 }
27
28 // Open resolves the repository whose work tree contains dir. A directory
29 // outside any work tree, bare repositories included, is ErrNotRepository.
30 func Open(ctx context.Context, dir string) (Repo, error) {
31 abs, err := filepath.Abs(dir)
32 if err != nil {
33 return Repo{}, err
34 }
35 out, err := Command(ctx, abs, "rev-parse", "--absolute-git-dir", "--git-common-dir", "--show-toplevel").Output()
36 var exit *exec.ExitError
37 if errors.As(err, &exit) {
38 return Repo{}, fmt.Errorf("%w: %s", ErrNotRepository, abs)
39 }
40 if err != nil {
41 return Repo{}, err
42 }
43 lines := strings.Split(strings.TrimRight(string(out), "\r\n"), "\n")
44 if len(lines) != 3 {
45 return Repo{}, fmt.Errorf("%w: %s: unexpected rev-parse output %q", ErrNotRepository, abs, out)
46 }
47 resolve := func(p string) string {
48 p = filepath.FromSlash(strings.TrimRight(p, "\r"))
49 if !filepath.IsAbs(p) {
50 p = filepath.Join(abs, p)
51 }
52 return filepath.Clean(p)
53 }
54 repo := Repo{Dir: abs, GitDir: resolve(lines[0]), CommonDir: resolve(lines[1]), WorkTree: resolve(lines[2])}
55 if err := repo.selfConsistent(); err != nil {
56 return Repo{}, fmt.Errorf("%w: %s: %w", ErrNotRepository, abs, err)
57 }
58 return repo, nil
59 }
60
61 // Verify checks a Repo recorded earlier against the files as they stand: the
62 // same test Open applies, so a stored identity names only its own checkout.
63 func (r Repo) Verify() error {
64 if !r.Valid() {
65 return ErrNotRepository
66 }
67 if err := r.selfConsistent(); err != nil {
68 return fmt.Errorf("%w: %s: %w", ErrNotRepository, r.Dir, err)
69 }
70 return nil
71 }
72
73 // selfConsistent accepts only a git dir that belongs to the work tree holding
74 // Dir: the work tree's own .git directory, or a linked worktree's admin dir
75 // under the common dir whose gitdir file names that work tree's .git. A .git
76 // file or core.worktree pointing elsewhere names another checkout's content.
77 func (r Repo) selfConsistent() error {
78 if !within(r.WorkTree, r.Dir) {
79 return errors.New("work tree does not contain the directory")
80 }
81 dotGit := filepath.Join(r.WorkTree, ".git")
82 if sameFile(r.GitDir, dotGit) {
83 if !sameFile(r.CommonDir, r.GitDir) {
84 return errors.New("git dir shares another repository's common dir")
85 }
86 return nil
87 }
88 if !sameFile(filepath.Dir(r.GitDir), filepath.Join(r.CommonDir, "worktrees")) {
89 return errors.New("git dir belongs to another checkout")
90 }
91 raw, err := os.ReadFile(filepath.Join(r.GitDir, "gitdir"))
92 if err != nil {
93 return err
94 }
95 back := filepath.FromSlash(strings.TrimSpace(string(raw)))
96 if !filepath.IsAbs(back) {
97 back = filepath.Join(r.GitDir, back)
98 }
99 if !sameFile(back, dotGit) {
100 return errors.New("linked worktree admin dir names another checkout")
101 }
102 return nil
103 }
104
105 func sameFile(a, b string) bool {
106 left, err := os.Stat(a)
107 if err != nil {
108 return false
109 }
110 right, err := os.Stat(b)
111 return err == nil && os.SameFile(left, right)
112 }
113
114 // within reports whether dir is root or lies under it, symlinks resolved.
115 func within(root, dir string) bool {
116 realRoot, err := filepath.EvalSymlinks(root)
117 if err != nil {
118 return false
119 }
120 realDir, err := filepath.EvalSymlinks(dir)
121 if err != nil {
122 return false
123 }
124 rel, err := filepath.Rel(realRoot, realDir)
125 return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel)
126 }
127
128 // Valid reports whether r names a resolved repository.
129 func (r Repo) Valid() bool { return r.GitDir != "" && r.CommonDir != "" && r.WorkTree != "" }
130
131 // Top is r with commands running from the work tree's root.
132 func (r Repo) Top() Repo {
133 r.Dir = r.WorkTree
134 return r
135 }
136
137 // Command is gitcmd.Command against r's resolved identity. An unresolved Repo
138 // yields a command that fails with ErrNotRepository.
139 func (r Repo) Command(ctx context.Context, args ...string) *exec.Cmd {
140 return r.CommandWithConfig(ctx, nil, args...)
141 }
142
143 // CommandWithConfig is gitcmd.CommandWithConfig against r's resolved identity.
144 func (r Repo) CommandWithConfig(ctx context.Context, extraConfig []string, args ...string) *exec.Cmd {
145 if !r.Valid() {
146 if ctx == nil {
147 ctx = context.Background()
148 }
149 cmd := newCommand(ctx, Args(r.Dir, extraConfig, args...), nil)
150 cmd.Err = ErrNotRepository
151 return cmd
152 }
153 return build(ctx, r.Dir, r.env(), extraConfig, args)
154 }
155
156 // env pins the repository: GIT_COMMON_DIR outranks a commondir file, and an
157 // explicit GIT_DIR is never swapped for a nested or implicit-bare one.
158 func (r Repo) env() []string {
159 return []string{"GIT_DIR=" + r.GitDir, "GIT_COMMON_DIR=" + r.CommonDir, "GIT_WORK_TREE=" + r.WorkTree}
160 }
161
161 lines GO