vcs.go

 1// Package vcs finds the repository a local run happens in and what commit
 2// its working copy is, for jj (preferred when present) and git.
 3package vcs
 4
 5import (
 6	"fmt"
 7	"os"
 8	"os/exec"
 9	"path/filepath"
10	"strings"
11)
12
13// Root returns the nearest directory at or above start that contains .jj or
14// .git.
15func Root(start string) (string, error) {
16	dir, err := filepath.Abs(start)
17	if err != nil {
18		return "", err
19	}
20	for {
21		for _, marker := range []string{".jj", ".git"} {
22			if _, err := os.Stat(filepath.Join(dir, marker)); err == nil {
23				return dir, nil
24			}
25		}
26		parent := filepath.Dir(dir)
27		if parent == dir {
28			return "", fmt.Errorf("%s is not inside a jj or git repository", start)
29		}
30		dir = parent
31	}
32}
33
34// Head returns the working copy's commit and whether it has changes. With
35// jj that is @ (jj snapshots the working copy first, as every jj command
36// does); with git it is HEAD plus `git status`.
37func Head(root string) (sha string, dirty bool, err error) {
38	if _, err := os.Stat(filepath.Join(root, ".jj")); err == nil {
39		out, err := output(root, "jj", "log", "--no-graph", "-r", "@", "-T", `commit_id ++ " " ++ empty`)
40		if err != nil {
41			return "", false, err
42		}
43		id, empty, _ := strings.Cut(strings.TrimSpace(out), " ")
44		return id, empty != "true", nil
45	}
46	sha, err = output(root, "git", "rev-parse", "HEAD")
47	if err != nil {
48		return "", false, err
49	}
50	st, err := output(root, "git", "status", "--porcelain")
51	if err != nil {
52		return "", false, err
53	}
54	return strings.TrimSpace(sha), strings.TrimSpace(st) != "", nil
55}
56
57func output(dir, name string, args ...string) (string, error) {
58	cmd := exec.Command(name, args...)
59	cmd.Dir = dir
60	out, err := cmd.Output()
61	if err != nil {
62		if ee, ok := err.(*exec.ExitError); ok {
63			return "", fmt.Errorf("%s %s: %v: %s", name, strings.Join(args, " "), err, strings.TrimSpace(string(ee.Stderr)))
64		}
65		return "", fmt.Errorf("%s: %w", name, err)
66	}
67	return string(out), nil
68}
69
70// Resolve returns the commit a revision names: a jj revset in jj repos
71// (default @, the working-copy commit), a git revision otherwise (default
72// HEAD). It must name exactly one commit.
73func Resolve(root, rev string) (string, error) {
74	if _, err := os.Stat(filepath.Join(root, ".jj")); err == nil {
75		if rev == "" {
76			rev = "@"
77		}
78		out, err := output(root, "jj", "log", "--no-graph", "-r", rev, "-T", `commit_id ++ "\n"`)
79		if err != nil {
80			return "", err
81		}
82		ids := strings.Fields(out)
83		if len(ids) != 1 {
84			return "", fmt.Errorf("revision %q names %d commits, want 1", rev, len(ids))
85		}
86		return ids[0], nil
87	}
88	if rev == "" {
89		rev = "HEAD"
90	}
91	out, err := output(root, "git", "rev-parse", "--verify", rev+"^{commit}")
92	if err != nil {
93		return "", err
94	}
95	return strings.TrimSpace(out), nil
96}