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}