diff --git a/cmd/gogit/worktree.go b/cmd/gogit/worktree.go new file mode 100644 index 0000000..0e8f127 --- /dev/null +++ b/cmd/gogit/worktree.go @@ -0,0 +1,190 @@ +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/go-git/go-billy/v6/memfs" + "github.com/go-git/go-billy/v6/osfs" + "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing" + "github.com/go-git/go-git/v6/x/plumbing/worktree" + xstorage "github.com/go-git/go-git/v6/x/storage" + "github.com/spf13/cobra" +) + +var ( + worktreeAddCommit string + worktreeAddDetach bool +) + +func init() { + worktreeAddCmd.Flags().StringVarP(&worktreeAddCommit, "commit", "c", "", "Commit hash to checkout in the new worktree") + worktreeAddCmd.Flags().BoolVarP(&worktreeAddDetach, "detach", "d", false, "Create a detached HEAD worktree") + worktreeCmd.AddCommand(worktreeAddCmd) + worktreeCmd.AddCommand(worktreeListCmd) + worktreeCmd.AddCommand(worktreeRemoveCmd) + rootCmd.AddCommand(worktreeCmd) +} + +var worktreeCmd = &cobra.Command{ + Use: "worktree ", + Short: "Manage repository worktrees", + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Usage() + }, + DisableFlagsInUseLine: true, +} + +var worktreeAddCmd = &cobra.Command{ + Use: "add ", + Short: "Add a new linked worktree", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + path := args[0] + name := filepath.Base(path) + + r, err := git.PlainOpen(".") + if err != nil { + return fmt.Errorf("failed to open repository: %w", err) + } + + w, err := worktree.New(r.Storer) + if err != nil { + return fmt.Errorf("failed to create worktree manager: %w", err) + } + + wt := osfs.New(path) + + var opts []worktree.Option + if worktreeAddDetach { + opts = append(opts, worktree.WithDetachedHead()) + } + if worktreeAddCommit != "" { + hash := plumbing.NewHash(worktreeAddCommit) + if !hash.IsZero() { + opts = append(opts, worktree.WithCommit(hash)) + } + } + + err = w.Add(wt, name, opts...) + if err != nil { + return fmt.Errorf("failed to add worktree: %w", err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Worktree '%s' created at '%s'\n", name, path) + + return nil + }, + DisableFlagsInUseLine: true, +} + +var worktreeListCmd = &cobra.Command{ + Use: "list", + Short: "List all linked worktrees", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + r, err := git.PlainOpen(".") + if err != nil { + return fmt.Errorf("failed to open repository: %w", err) + } + + w, err := worktree.New(r.Storer) + if err != nil { + return fmt.Errorf("failed to create worktree manager: %w", err) + } + + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current directory: %w", err) + } + + ref, err := r.Head() + if err != nil { + return fmt.Errorf("failed to get HEAD: %w", err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "%-30s %s %s\n", cwd, ref.Hash().String()[:7], refName(ref)) + + worktrees, err := w.List() + if err != nil { + return fmt.Errorf("failed to list worktrees: %w", err) + } + + wts, ok := r.Storer.(xstorage.WorktreeStorer) + if !ok { + return errors.New("storer does not implement WorktreeStorer") + } + + commonDir := wts.Filesystem() + for _, name := range worktrees { + gitdirPath := filepath.Join(commonDir.Root(), "worktrees", name, "gitdir") + gitdirData, err := os.ReadFile(gitdirPath) + if err != nil { + continue + } + + wtPath := filepath.Dir(string(gitdirData[:len(gitdirData)-1])) + wt := memfs.New() + err = w.Init(wt, name) + if err != nil { + continue + } + + r, err := w.Open(wt) + if err != nil { + continue + } + + ref, err := r.Head() + if err != nil { + return fmt.Errorf("failed to get HEAD: %w", err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "%-30s %s %s\n", wtPath, ref.Hash().String()[:7], refName(ref)) + } + + return nil + }, + DisableFlagsInUseLine: true, +} + +func refName(ref *plumbing.Reference) string { + name := ref.Name() + if name.IsBranch() { + return fmt.Sprint("[", name.Short(), "]") + } + + return fmt.Sprint("(detached ", string(ref.Name()), ")") +} + +var worktreeRemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove a linked worktree", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + + r, err := git.PlainOpen(".") + if err != nil { + return fmt.Errorf("failed to open repository: %w", err) + } + + wt, err := worktree.New(r.Storer) + if err != nil { + return fmt.Errorf("failed to create worktree manager: %w", err) + } + + err = wt.Remove(name) + if err != nil { + return fmt.Errorf("failed to remove worktree: %w", err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Worktree '%s' removed\n", name) + + return nil + }, + DisableFlagsInUseLine: true, +} diff --git a/go.mod b/go.mod index 8ab5fcb..b80323f 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,9 @@ go 1.24.0 toolchain go1.25.4 require ( - github.com/go-git/go-billy/v6 v6.0.0-20251209065551-8afc3eb64e4d - github.com/go-git/go-git-fixtures/v5 v5.1.1 - github.com/go-git/go-git/v6 v6.0.0-20251123162143-36fa81975a20 + github.com/go-git/go-billy/v6 v6.0.0-20251215223226-9a435375010d + github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20251217024734-e267099c9ed5 + github.com/go-git/go-git/v6 v6.0.0-20251218224324-ede584db67a4 github.com/spf13/cobra v1.10.2 golang.org/x/crypto v0.46.0 golang.org/x/term v0.38.0 @@ -27,6 +27,6 @@ require ( github.com/pjbgf/sha1cd v0.5.0 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/spf13/pflag v1.0.9 // indirect - golang.org/x/net v0.47.0 // indirect + golang.org/x/net v0.48.0 // indirect golang.org/x/sys v0.39.0 // indirect ) diff --git a/go.sum b/go.sum index 6cbd75d..dc68bac 100644 --- a/go.sum +++ b/go.sum @@ -22,12 +22,12 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo= github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs= -github.com/go-git/go-billy/v6 v6.0.0-20251209065551-8afc3eb64e4d h1:nfZPVEha54DwXl8twSNxi9J8edIiqfpSvnq/mGPfgc4= -github.com/go-git/go-billy/v6 v6.0.0-20251209065551-8afc3eb64e4d/go.mod h1:d3XQcsHu1idnquxt48kAv+h+1MUiYKLH/e7LAzjP+pI= -github.com/go-git/go-git-fixtures/v5 v5.1.1 h1:OH8i1ojV9bWfr0ZfasfpgtUXQHQyVS8HXik/V1C099w= -github.com/go-git/go-git-fixtures/v5 v5.1.1/go.mod h1:Altk43lx3b1ks+dVoAG2300o5WWUnktvfY3VI6bcaXU= -github.com/go-git/go-git/v6 v6.0.0-20251123162143-36fa81975a20 h1:T1iYBFBFcB5Kjpq5BjCET47+Xc+blVhFDJ4cM7SJImw= -github.com/go-git/go-git/v6 v6.0.0-20251123162143-36fa81975a20/go.mod h1:82JGB4xCU6W8toVHjEcv4KH4GSiB+MhjFTCGQxPOLdM= +github.com/go-git/go-billy/v6 v6.0.0-20251215223226-9a435375010d h1:/18mhRKvhulzdxqNuyhaEVtJgfl+hrYBJ/yl2v4lCKg= +github.com/go-git/go-billy/v6 v6.0.0-20251215223226-9a435375010d/go.mod h1:d3XQcsHu1idnquxt48kAv+h+1MUiYKLH/e7LAzjP+pI= +github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20251217024734-e267099c9ed5 h1:3PN91izCLX3c2mMqXDHpF9ift/yVticGUTPu/eGfX0M= +github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20251217024734-e267099c9ed5/go.mod h1:0mL/38Gupl98cI/OAAF+inDRhyKM9ic5FLuIY+zCTCE= +github.com/go-git/go-git/v6 v6.0.0-20251218224324-ede584db67a4 h1:M8aO2/N4F0dLkFRhEK+SN/kdxEQr0tD5UlCMiXxlFqc= +github.com/go-git/go-git/v6 v6.0.0-20251218224324-ede584db67a4/go.mod h1:JpR+9QlEMKtBgshm5dybpsz1cjmXU8u7ZnMnI3glMIo= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -57,8 +57,8 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=