| 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 |