12// Repo represents a Git repository working tree.
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()
27 return nil, fmt.Errorf("not in a git repository: %w", err)
29 return &Repo{root: strings.TrimSpace(string(out))}, nil
32// Root returns the top-level path of the repository.
33func (r *Repo) Root() string {
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...)
41 out, err := cmd.CombinedOutput()
43 return "", fmt.Errorf("git %s failed: %w\n%s", strings.Join(args, " "), err, string(out))
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")
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")
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", "--")
78 untrackedOut, err := r.Run("ls-files", "--others", "--exclude-standard")
83 seen := make(map[string]bool)
85 for f := range strings.SplitSeq(diffOut+"\n"+untrackedOut, "\n") {
86 f = strings.TrimSpace(f)
87 if f == "" || seen[f] {
91 files = append(files, f)
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 {
103 _, _ = r.Run("worktree", "remove", "--force", dest)
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()
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) {
125 if err != nil || fi.IsDir() {
129 if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
132 if err := copyFile(src, dst); err != nil {
139func copyFile(src, dst string) error {
140 in, err := os.Open(src)
146 out, err := os.Create(dst)
152 if _, err := io.Copy(out, in); err != nil {