Skip to main content

internal/git/git.go

1package git
3import (
4 "fmt"
5 "io"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "strings"
12// Repo represents a Git repository working tree.
13type Repo struct {
14 root string
17// Open creates a new Repo rooted at the given directory.
18func Open(root string) *Repo {
19 return &Repo{root: root}
22// Current discovers and returns the Repo containing the current working directory.
23func Current() (*Repo, error) {
24 cmd := exec.Command("git", "rev-parse", "--show-toplevel")
25 out, err := cmd.CombinedOutput()
26 if err != nil {
27 return nil, fmt.Errorf("not in a git repository: %w", err)
28 }
29 return &Repo{root: strings.TrimSpace(string(out))}, nil
32// Root returns the top-level path of the repository.
33func (r *Repo) Root() string {
34 return r.root
37// Run executes a Git command in the root of this Repo.
38func (r *Repo) Run(args ...string) (string, error) {
39 cmd := exec.Command("git", args...)
40 cmd.Dir = r.root
41 out, err := cmd.CombinedOutput()
42 if err != nil {
43 return "", fmt.Errorf("git %s failed: %w\n%s", strings.Join(args, " "), err, string(out))
44 }
45 return strings.TrimSpace(string(out)), nil
48// CurrentBranch returns the name of the checked-out branch, or the
49// short commit hash if detached.
50func (r *Repo) CurrentBranch() (string, error) {
51 branch, err := r.Run("symbolic-ref", "--short", "-q", "HEAD")
52 if err == nil {
53 return branch, nil
54 }
55 return r.RevParse("HEAD")
58// RevParse resolves a ref (for example, "HEAD", "main") to a 7-character short commit hash.
59func (r *Repo) RevParse(ref string) (string, error) {
60 return r.Run("rev-parse", "--short", ref)
63// IsDirty returns true if the working tree has uncommitted staged, unstaged, or untracked changes.
64func (r *Repo) IsDirty() (bool, error) {
65 out, err := r.Run("status", "--porcelain")
66 if err != nil {
67 return false, err
68 }
69 return len(out) > 0, nil
72// ChangedFiles returns a deduplicated list of relative paths for all modified, staged, and untracked files.
73func (r *Repo) ChangedFiles() ([]string, error) {
74 diffOut, err := r.Run("diff", "--name-only", "HEAD", "--")
75 if err != nil {
76 return nil, err
77 }
78 untrackedOut, err := r.Run("ls-files", "--others", "--exclude-standard")
79 if err != nil {
80 return nil, err
81 }
83 seen := make(map[string]bool)
84 var files []string
85 for f := range strings.SplitSeq(diffOut+"\n"+untrackedOut, "\n") {
86 f = strings.TrimSpace(f)
87 if f == "" || seen[f] {
88 continue
89 }
90 seen[f] = true
91 files = append(files, f)
92 }
93 return files, nil
96// AddWorktree creates a detached worktree at dest pointing to ref.
97// It returns a cleanup function that forcibly removes the worktree.
98func (r *Repo) AddWorktree(dest, ref string) (cleanup func(), err error) {
99 if _, err := r.Run("worktree", "add", "--detach", "-q", dest, ref); err != nil {
100 return nil, err
101 }
102 cleanup = func() {
103 _, _ = r.Run("worktree", "remove", "--force", dest)
104 }
105 return cleanup, nil
108// SyncChangesTo copies all uncommitted and untracked files to a target directory,
109// removing destination files that were deleted locally.
110func (r *Repo) SyncChangesTo(destDir string) error {
111 files, err := r.ChangedFiles()
112 if err != nil {
113 return err
114 }
116 for _, relPath := range files {
117 src := filepath.Join(r.root, relPath)
118 dst := filepath.Join(destDir, relPath)
120 fi, err := os.Stat(src)
121 if os.IsNotExist(err) {
122 _ = os.Remove(dst)
123 continue
124 }
125 if err != nil || fi.IsDir() {
126 continue
127 }
129 if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
130 return err
131 }
132 if err := copyFile(src, dst); err != nil {
133 return err
134 }
135 }
136 return nil
139func copyFile(src, dst string) error {
140 in, err := os.Open(src)
141 if err != nil {
142 return err
143 }
144 defer in.Close()
146 out, err := os.Create(dst)
147 if err != nil {
148 return err
149 }
150 defer out.Close()
152 if _, err := io.Copy(out, in); err != nil {
153 return err
154 }
155 return out.Sync()