pf 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +33 -0
- package/build.mjs +24 -0
- package/commands/completion.ts +197 -0
- package/commands/ls.ts +49 -0
- package/commands/new.ts +82 -0
- package/commands/open.ts +61 -0
- package/commands/rm.ts +61 -0
- package/dist/index.js +2316 -0
- package/go/Makefile +37 -0
- package/go/cmd/completion.go +183 -0
- package/go/cmd/list.go +126 -0
- package/go/cmd/new.go +146 -0
- package/go/cmd/open.go +128 -0
- package/go/cmd/rm.go +88 -0
- package/go/cmd/root.go +74 -0
- package/go/cmd/styles.go +9 -0
- package/go/cmd/version.go +27 -0
- package/go/cmd/where.go +57 -0
- package/go/go.mod +37 -0
- package/go/go.sum +73 -0
- package/go/internal/store/store.go +124 -0
- package/go/main.go +7 -0
- package/index.ts +107 -0
- package/package.json +20 -22
- package/store.ts +67 -0
- package/tsconfig.json +16 -0
- package/utils.ts +108 -0
- package/.npmignore +0 -1
- package/index.js +0 -90
- package/test.js +0 -14
package/go/Makefile
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
BINARY_NAME=pf
|
|
2
|
+
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
|
3
|
+
GIT_COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "none")
|
|
4
|
+
BUILD_DATE ?= $(shell date -u '+%Y-%m-%d_%H:%M:%S')
|
|
5
|
+
LDFLAGS=-ldflags "-X github.com/pullfrog/cli/cmd.Version=$(VERSION) -X github.com/pullfrog/cli/cmd.GitCommit=$(GIT_COMMIT) -X github.com/pullfrog/cli/cmd.BuildDate=$(BUILD_DATE)"
|
|
6
|
+
|
|
7
|
+
.PHONY: build install clean test build-all
|
|
8
|
+
|
|
9
|
+
# Build for current platform
|
|
10
|
+
build:
|
|
11
|
+
go build $(LDFLAGS) -o bin/$(BINARY_NAME) .
|
|
12
|
+
|
|
13
|
+
# Install to GOPATH/bin (should be in PATH)
|
|
14
|
+
install:
|
|
15
|
+
go install $(LDFLAGS) .
|
|
16
|
+
|
|
17
|
+
# Run tests
|
|
18
|
+
test:
|
|
19
|
+
go test -v ./...
|
|
20
|
+
|
|
21
|
+
# Clean build artifacts
|
|
22
|
+
clean:
|
|
23
|
+
rm -rf bin/
|
|
24
|
+
go clean
|
|
25
|
+
|
|
26
|
+
# Build for all platforms
|
|
27
|
+
build-all: clean
|
|
28
|
+
mkdir -p bin
|
|
29
|
+
GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o bin/$(BINARY_NAME)-darwin-amd64 .
|
|
30
|
+
GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o bin/$(BINARY_NAME)-darwin-arm64 .
|
|
31
|
+
GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o bin/$(BINARY_NAME)-linux-amd64 .
|
|
32
|
+
GOOS=linux GOARCH=arm64 go build $(LDFLAGS) -o bin/$(BINARY_NAME)-linux-arm64 .
|
|
33
|
+
GOOS=windows GOARCH=amd64 go build $(LDFLAGS) -o bin/$(BINARY_NAME)-windows-amd64.exe .
|
|
34
|
+
|
|
35
|
+
# Development build and run
|
|
36
|
+
run: build
|
|
37
|
+
./bin/$(BINARY_NAME)
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
package cmd
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bufio"
|
|
5
|
+
"fmt"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"strings"
|
|
9
|
+
|
|
10
|
+
"github.com/spf13/cobra"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
var completionCmd = &cobra.Command{
|
|
14
|
+
Use: "completion [bash|zsh|fish]",
|
|
15
|
+
Short: "Install shell completions",
|
|
16
|
+
Long: `Install shell completions for pf.
|
|
17
|
+
|
|
18
|
+
Run without arguments to interactively install completions.
|
|
19
|
+
Run with a shell name to output the raw completion script.
|
|
20
|
+
`,
|
|
21
|
+
DisableFlagsInUseLine: true,
|
|
22
|
+
ValidArgs: []string{"bash", "zsh", "fish"},
|
|
23
|
+
Args: cobra.MaximumNArgs(1),
|
|
24
|
+
RunE: runCompletion,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func init() {
|
|
28
|
+
rootCmd.AddCommand(completionCmd)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func runCompletion(cmd *cobra.Command, args []string) error {
|
|
32
|
+
// If shell specified, just output the script
|
|
33
|
+
if len(args) == 1 {
|
|
34
|
+
switch args[0] {
|
|
35
|
+
case "bash":
|
|
36
|
+
return rootCmd.GenBashCompletion(os.Stdout)
|
|
37
|
+
case "zsh":
|
|
38
|
+
return rootCmd.GenZshCompletion(os.Stdout)
|
|
39
|
+
case "fish":
|
|
40
|
+
return rootCmd.GenFishCompletion(os.Stdout, true)
|
|
41
|
+
}
|
|
42
|
+
return nil
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Interactive mode
|
|
46
|
+
shell := detectShell()
|
|
47
|
+
if shell == "" {
|
|
48
|
+
return fmt.Errorf("could not detect shell, please run: pf completion [bash|zsh|fish]")
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
completionPath := getCompletionPath(shell)
|
|
52
|
+
rcPath, rcLines := getRcConfig(shell)
|
|
53
|
+
|
|
54
|
+
// Build description of changes
|
|
55
|
+
var desc strings.Builder
|
|
56
|
+
desc.WriteString(fmt.Sprintf("1. Write completion script to %s\n", completionPath))
|
|
57
|
+
if rcPath != "" && !fileContains(rcPath, rcLines[0]) {
|
|
58
|
+
desc.WriteString(fmt.Sprintf("2. Add to %s:\n", rcPath))
|
|
59
|
+
for _, line := range rcLines {
|
|
60
|
+
desc.WriteString(fmt.Sprintf(" %s\n", line))
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
fmt.Printf("Install %s completions? This will:\n", shell)
|
|
65
|
+
fmt.Print(desc.String())
|
|
66
|
+
fmt.Print("\nProceed? [y/N] ")
|
|
67
|
+
|
|
68
|
+
reader := bufio.NewReader(os.Stdin)
|
|
69
|
+
response, _ := reader.ReadString('\n')
|
|
70
|
+
response = strings.TrimSpace(strings.ToLower(response))
|
|
71
|
+
|
|
72
|
+
if response != "y" && response != "yes" {
|
|
73
|
+
fmt.Println("Canceled.")
|
|
74
|
+
return nil
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return installCompletion(shell, completionPath, rcPath, rcLines)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
func detectShell() string {
|
|
81
|
+
shell := os.Getenv("SHELL")
|
|
82
|
+
if strings.Contains(shell, "zsh") {
|
|
83
|
+
return "zsh"
|
|
84
|
+
} else if strings.Contains(shell, "bash") {
|
|
85
|
+
return "bash"
|
|
86
|
+
} else if strings.Contains(shell, "fish") {
|
|
87
|
+
return "fish"
|
|
88
|
+
}
|
|
89
|
+
return ""
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
func getCompletionPath(shell string) string {
|
|
93
|
+
home, _ := os.UserHomeDir()
|
|
94
|
+
|
|
95
|
+
switch shell {
|
|
96
|
+
case "zsh":
|
|
97
|
+
return filepath.Join(home, ".zsh/completions/_pf")
|
|
98
|
+
case "bash":
|
|
99
|
+
return filepath.Join(home, ".bash_completion.d/pf")
|
|
100
|
+
case "fish":
|
|
101
|
+
return filepath.Join(home, ".config/fish/completions/pf.fish")
|
|
102
|
+
}
|
|
103
|
+
return ""
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
func getRcConfig(shell string) (string, []string) {
|
|
107
|
+
home, _ := os.UserHomeDir()
|
|
108
|
+
|
|
109
|
+
switch shell {
|
|
110
|
+
case "zsh":
|
|
111
|
+
return filepath.Join(home, ".zshrc"), []string{
|
|
112
|
+
`fpath=(~/.zsh/completions $fpath)`,
|
|
113
|
+
`autoload -Uz compinit && compinit`,
|
|
114
|
+
}
|
|
115
|
+
case "bash":
|
|
116
|
+
return filepath.Join(home, ".bashrc"), []string{
|
|
117
|
+
`source ~/.bash_completion.d/pf`,
|
|
118
|
+
}
|
|
119
|
+
case "fish":
|
|
120
|
+
// Fish auto-loads from completions dir
|
|
121
|
+
return "", nil
|
|
122
|
+
}
|
|
123
|
+
return "", nil
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
func installCompletion(shell, completionPath, rcPath string, rcLines []string) error {
|
|
127
|
+
// Ensure completion directory exists
|
|
128
|
+
dir := filepath.Dir(completionPath)
|
|
129
|
+
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
130
|
+
return fmt.Errorf("failed to create directory: %w", err)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Write completion script
|
|
134
|
+
file, err := os.Create(completionPath)
|
|
135
|
+
if err != nil {
|
|
136
|
+
return fmt.Errorf("failed to create file: %w", err)
|
|
137
|
+
}
|
|
138
|
+
defer file.Close()
|
|
139
|
+
|
|
140
|
+
switch shell {
|
|
141
|
+
case "bash":
|
|
142
|
+
rootCmd.GenBashCompletion(file)
|
|
143
|
+
case "zsh":
|
|
144
|
+
rootCmd.GenZshCompletion(file)
|
|
145
|
+
case "fish":
|
|
146
|
+
rootCmd.GenFishCompletion(file, true)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
fmt.Println(success.Render("✓"), "Wrote", completionPath)
|
|
150
|
+
|
|
151
|
+
// Update rc file if needed
|
|
152
|
+
if rcPath != "" && len(rcLines) > 0 && !fileContains(rcPath, rcLines[0]) {
|
|
153
|
+
f, err := os.OpenFile(rcPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
|
154
|
+
if err != nil {
|
|
155
|
+
return fmt.Errorf("failed to open %s: %w", rcPath, err)
|
|
156
|
+
}
|
|
157
|
+
defer f.Close()
|
|
158
|
+
|
|
159
|
+
f.WriteString("\n# pf completions\n")
|
|
160
|
+
for _, line := range rcLines {
|
|
161
|
+
f.WriteString(line + "\n")
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
fmt.Println(success.Render("✓"), "Updated", rcPath)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
fmt.Println()
|
|
168
|
+
if rcPath != "" {
|
|
169
|
+
fmt.Println("Restart your shell or run:", dim.Render("source "+rcPath))
|
|
170
|
+
} else {
|
|
171
|
+
fmt.Println("Restart your shell to enable completions.")
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return nil
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
func fileContains(path, substr string) bool {
|
|
178
|
+
data, err := os.ReadFile(path)
|
|
179
|
+
if err != nil {
|
|
180
|
+
return false
|
|
181
|
+
}
|
|
182
|
+
return strings.Contains(string(data), substr)
|
|
183
|
+
}
|
package/go/cmd/list.go
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
package cmd
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"os"
|
|
6
|
+
"os/exec"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"strings"
|
|
9
|
+
|
|
10
|
+
"github.com/charmbracelet/lipgloss"
|
|
11
|
+
"github.com/charmbracelet/lipgloss/table"
|
|
12
|
+
"github.com/pullfrog/cli/internal/store"
|
|
13
|
+
"github.com/spf13/cobra"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
var listCmd = &cobra.Command{
|
|
17
|
+
Use: "ls",
|
|
18
|
+
Short: "List all tracked worktrees",
|
|
19
|
+
RunE: runList,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
var plainOutput bool
|
|
23
|
+
|
|
24
|
+
var listAliasCmd = &cobra.Command{
|
|
25
|
+
Use: "list",
|
|
26
|
+
Hidden: true,
|
|
27
|
+
RunE: runList,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func init() {
|
|
31
|
+
listCmd.Flags().BoolVarP(&plainOutput, "plain", "p", false, "Plain output (grep-friendly)")
|
|
32
|
+
rootCmd.AddCommand(listCmd)
|
|
33
|
+
rootCmd.AddCommand(listAliasCmd)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func runList(cmd *cobra.Command, args []string) error {
|
|
37
|
+
s, err := store.Load()
|
|
38
|
+
if err != nil {
|
|
39
|
+
return fmt.Errorf("failed to load store: %w", err)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if len(s.Worktrees) == 0 {
|
|
43
|
+
fmt.Println("No worktrees. Use 'pf new <name>' to create one.")
|
|
44
|
+
return nil
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Detect current worktree
|
|
48
|
+
cwd, _ := os.Getwd()
|
|
49
|
+
currentWorktree := ""
|
|
50
|
+
for _, wt := range s.Worktrees {
|
|
51
|
+
absPath, _ := filepath.Abs(wt.Path)
|
|
52
|
+
if strings.HasPrefix(cwd, absPath) {
|
|
53
|
+
currentWorktree = wt.Name
|
|
54
|
+
break
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if plainOutput {
|
|
59
|
+
for _, wt := range s.Worktrees {
|
|
60
|
+
branch := getWorktreeBranch(wt.Path)
|
|
61
|
+
status := getWorktreeStatus(wt.Path)
|
|
62
|
+
fmt.Printf("%s\t%s\t%s\n", wt.Name, branch, status)
|
|
63
|
+
}
|
|
64
|
+
return nil
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
rows := [][]string{}
|
|
68
|
+
for _, wt := range s.Worktrees {
|
|
69
|
+
branch := getWorktreeBranch(wt.Path)
|
|
70
|
+
status := getWorktreeStatus(wt.Path)
|
|
71
|
+
name := wt.Name
|
|
72
|
+
if wt.Name == currentWorktree {
|
|
73
|
+
name = "*" + name
|
|
74
|
+
}
|
|
75
|
+
rows = append(rows, []string{name, branch, status})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
t := table.New().
|
|
79
|
+
Border(lipgloss.NormalBorder()).
|
|
80
|
+
BorderHeader(true).
|
|
81
|
+
Headers("name", "branch", "git status").
|
|
82
|
+
Rows(rows...)
|
|
83
|
+
|
|
84
|
+
fmt.Println(t)
|
|
85
|
+
|
|
86
|
+
return nil
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
func getWorktreeBranch(path string) string {
|
|
90
|
+
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
91
|
+
return "[missing]"
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
cmd := exec.Command("git", "-C", path, "branch", "--show-current")
|
|
95
|
+
output, err := cmd.Output()
|
|
96
|
+
if err != nil {
|
|
97
|
+
return "[detached]"
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
branch := strings.TrimSpace(string(output))
|
|
101
|
+
if branch == "" {
|
|
102
|
+
return "[detached]"
|
|
103
|
+
}
|
|
104
|
+
return branch
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
func getWorktreeStatus(path string) string {
|
|
108
|
+
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
109
|
+
return ""
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
cmd := exec.Command("git", "-C", path, "status", "--porcelain")
|
|
113
|
+
output, err := cmd.Output()
|
|
114
|
+
if err != nil {
|
|
115
|
+
return "[error]"
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
status := strings.TrimSpace(string(output))
|
|
119
|
+
if status == "" {
|
|
120
|
+
return "clean"
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
lines := strings.Split(status, "\n")
|
|
124
|
+
return fmt.Sprintf("%d changed", len(lines))
|
|
125
|
+
}
|
|
126
|
+
|
package/go/cmd/new.go
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
package cmd
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"crypto/rand"
|
|
5
|
+
"encoding/hex"
|
|
6
|
+
"fmt"
|
|
7
|
+
"os"
|
|
8
|
+
"os/exec"
|
|
9
|
+
"path/filepath"
|
|
10
|
+
"strings"
|
|
11
|
+
|
|
12
|
+
"github.com/pullfrog/cli/internal/store"
|
|
13
|
+
"github.com/spf13/cobra"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
var newCmd = &cobra.Command{
|
|
17
|
+
Use: "new [name]",
|
|
18
|
+
Short: "Create a new worktree with a branch",
|
|
19
|
+
Long: `Creates a new git worktree and branch with the given name. If no name is provided, generates a random name like pf-a1b2c3d4.`,
|
|
20
|
+
Args: cobra.MaximumNArgs(1),
|
|
21
|
+
RunE: runNew,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
func init() {
|
|
25
|
+
rootCmd.AddCommand(newCmd)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func runNew(cmd *cobra.Command, args []string) error {
|
|
29
|
+
var name string
|
|
30
|
+
if len(args) > 0 {
|
|
31
|
+
name = args[0]
|
|
32
|
+
} else {
|
|
33
|
+
name = generateName()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Get the git root directory
|
|
37
|
+
gitRoot, err := store.GetGitRoot()
|
|
38
|
+
if err != nil {
|
|
39
|
+
return fmt.Errorf("not in a git repository: %w", err)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Don't allow creating worktrees from within a worktree
|
|
43
|
+
gitDir, err := store.GetGitCommonDir()
|
|
44
|
+
if err != nil {
|
|
45
|
+
return fmt.Errorf("failed to get git directory: %w", err)
|
|
46
|
+
}
|
|
47
|
+
if gitRoot != filepath.Dir(gitDir) {
|
|
48
|
+
fmt.Fprintln(os.Stderr, "Error: You are inside a pf worktree.")
|
|
49
|
+
fmt.Fprintln(os.Stderr, "")
|
|
50
|
+
fmt.Fprintln(os.Stderr, "Each time you run 'pf new' or 'pf open', pf starts a subshell in the")
|
|
51
|
+
fmt.Fprintln(os.Stderr, "worktree directory. To keep subshells shallow and avoid nesting, pf")
|
|
52
|
+
fmt.Fprintln(os.Stderr, "requires you to return to the main repo before opening another worktree.")
|
|
53
|
+
fmt.Fprintln(os.Stderr, "")
|
|
54
|
+
fmt.Fprintln(os.Stderr, "To exit the current worktree:")
|
|
55
|
+
fmt.Fprintln(os.Stderr, " exit")
|
|
56
|
+
fmt.Fprintln(os.Stderr, "")
|
|
57
|
+
fmt.Fprintln(os.Stderr, "Then re-run:")
|
|
58
|
+
fmt.Fprintf(os.Stderr, " pf new %s\n", name)
|
|
59
|
+
os.Exit(1)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Check if branch already exists - error if so
|
|
63
|
+
if checkBranchExists(name) {
|
|
64
|
+
return fmt.Errorf("branch '%s' already exists", name)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Check if worktree already exists in store
|
|
68
|
+
s, err := store.Load()
|
|
69
|
+
if err != nil {
|
|
70
|
+
return fmt.Errorf("failed to load store: %w", err)
|
|
71
|
+
}
|
|
72
|
+
if s.FindByName(name) != nil {
|
|
73
|
+
return fmt.Errorf("worktree '%s' already exists - use 'pf open %s'", name, name)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Get current branch to show what we're branching from
|
|
77
|
+
baseBranch := getCurrentBranch()
|
|
78
|
+
|
|
79
|
+
// Determine worktree path inside .git/pf/worktrees
|
|
80
|
+
repoName := filepath.Base(gitRoot)
|
|
81
|
+
worktreePath := filepath.Join(gitDir, "pf", "worktrees", name, repoName)
|
|
82
|
+
|
|
83
|
+
// Ensure directory exists
|
|
84
|
+
if err := os.MkdirAll(filepath.Dir(worktreePath), 0755); err != nil {
|
|
85
|
+
return fmt.Errorf("failed to create worktree directory: %w", err)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Create new branch with worktree (suppress git noise)
|
|
89
|
+
gitCmd := exec.Command("git", "worktree", "add", "-b", name, worktreePath)
|
|
90
|
+
if err := gitCmd.Run(); err != nil {
|
|
91
|
+
return fmt.Errorf("failed to create worktree: %w", err)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Track in store
|
|
95
|
+
s.Add(name, worktreePath)
|
|
96
|
+
if err := s.Save(); err != nil {
|
|
97
|
+
return fmt.Errorf("failed to save store: %w", err)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
fmt.Println()
|
|
101
|
+
fmt.Println(success.Render("✓"), bold.Render(name), dim.Render("(from "+baseBranch+")"))
|
|
102
|
+
fmt.Println(dim.Render(" Subshell started. Type 'exit' to return."))
|
|
103
|
+
fmt.Println()
|
|
104
|
+
|
|
105
|
+
// Get user's shell
|
|
106
|
+
shell := os.Getenv("SHELL")
|
|
107
|
+
if shell == "" {
|
|
108
|
+
shell = "/bin/sh"
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Spawn new shell in worktree directory
|
|
112
|
+
shellCmd := exec.Command(shell)
|
|
113
|
+
shellCmd.Dir = worktreePath
|
|
114
|
+
shellCmd.Stdin = os.Stdin
|
|
115
|
+
shellCmd.Stdout = os.Stdout
|
|
116
|
+
shellCmd.Stderr = os.Stderr
|
|
117
|
+
|
|
118
|
+
if err := shellCmd.Run(); err != nil {
|
|
119
|
+
return fmt.Errorf("shell exited with error: %w", err)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
fmt.Println()
|
|
123
|
+
fmt.Println(dim.Render(" Back in main worktree"))
|
|
124
|
+
fmt.Println()
|
|
125
|
+
return nil
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
func checkBranchExists(branchName string) bool {
|
|
129
|
+
cmd := exec.Command("git", "show-ref", "--verify", "--quiet", "refs/heads/"+branchName)
|
|
130
|
+
return cmd.Run() == nil
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
func getCurrentBranch() string {
|
|
134
|
+
cmd := exec.Command("git", "branch", "--show-current")
|
|
135
|
+
output, err := cmd.Output()
|
|
136
|
+
if err != nil {
|
|
137
|
+
return "HEAD"
|
|
138
|
+
}
|
|
139
|
+
return strings.TrimSpace(string(output))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
func generateName() string {
|
|
143
|
+
bytes := make([]byte, 4)
|
|
144
|
+
rand.Read(bytes)
|
|
145
|
+
return "pf-" + hex.EncodeToString(bytes)
|
|
146
|
+
}
|
package/go/cmd/open.go
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
package cmd
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"os"
|
|
6
|
+
"os/exec"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"strings"
|
|
9
|
+
|
|
10
|
+
"github.com/pullfrog/cli/internal/store"
|
|
11
|
+
"github.com/spf13/cobra"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
var openCmd = &cobra.Command{
|
|
15
|
+
Use: "open <name>",
|
|
16
|
+
Short: "Open a worktree",
|
|
17
|
+
Long: `Opens a worktree in a subshell. Type 'exit' to return.`,
|
|
18
|
+
Args: cobra.ExactArgs(1),
|
|
19
|
+
RunE: runOpen,
|
|
20
|
+
ValidArgsFunction: completeWorktreeNames,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func init() {
|
|
24
|
+
rootCmd.AddCommand(openCmd)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func completeWorktreeNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
|
28
|
+
if len(args) != 0 {
|
|
29
|
+
return nil, cobra.ShellCompDirectiveNoFileComp
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
s, err := store.Load()
|
|
33
|
+
if err != nil {
|
|
34
|
+
return nil, cobra.ShellCompDirectiveNoFileComp
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
var names []string
|
|
38
|
+
for _, wt := range s.Worktrees {
|
|
39
|
+
names = append(names, wt.Name)
|
|
40
|
+
}
|
|
41
|
+
return names, cobra.ShellCompDirectiveNoFileComp
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func runOpen(cmd *cobra.Command, args []string) error {
|
|
45
|
+
name := args[0]
|
|
46
|
+
|
|
47
|
+
// Get git dirs
|
|
48
|
+
gitRoot, err := store.GetGitRoot()
|
|
49
|
+
if err != nil {
|
|
50
|
+
return fmt.Errorf("not in a git repository: %w", err)
|
|
51
|
+
}
|
|
52
|
+
gitDir, err := store.GetGitCommonDir()
|
|
53
|
+
if err != nil {
|
|
54
|
+
return fmt.Errorf("failed to get git directory: %w", err)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Don't allow opening from within a worktree
|
|
58
|
+
if gitRoot != filepath.Dir(gitDir) {
|
|
59
|
+
fmt.Fprintln(os.Stderr, "Error: You are inside a pf worktree.")
|
|
60
|
+
fmt.Fprintln(os.Stderr, "")
|
|
61
|
+
fmt.Fprintln(os.Stderr, "Each time you run 'pf new' or 'pf open', pf starts a subshell in the")
|
|
62
|
+
fmt.Fprintln(os.Stderr, "worktree directory. To keep subshells shallow and avoid nesting, pf")
|
|
63
|
+
fmt.Fprintln(os.Stderr, "requires you to return to the main repo before opening another worktree.")
|
|
64
|
+
fmt.Fprintln(os.Stderr, "")
|
|
65
|
+
fmt.Fprintln(os.Stderr, "To exit the current worktree:")
|
|
66
|
+
fmt.Fprintln(os.Stderr, " exit")
|
|
67
|
+
fmt.Fprintln(os.Stderr, "")
|
|
68
|
+
fmt.Fprintln(os.Stderr, "Then re-run:")
|
|
69
|
+
fmt.Fprintf(os.Stderr, " pf open %s\n", name)
|
|
70
|
+
os.Exit(1)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Check for uncommitted changes
|
|
74
|
+
statusCmd := exec.Command("git", "status", "--porcelain")
|
|
75
|
+
output, err := statusCmd.Output()
|
|
76
|
+
if err != nil {
|
|
77
|
+
return fmt.Errorf("failed to check git status: %w", err)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if strings.TrimSpace(string(output)) != "" {
|
|
81
|
+
return fmt.Errorf("uncommitted changes in current worktree - commit or stash first")
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
s, err := store.Load()
|
|
85
|
+
if err != nil {
|
|
86
|
+
return fmt.Errorf("failed to load store: %w", err)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
wt := s.FindByName(name)
|
|
90
|
+
if wt == nil {
|
|
91
|
+
return fmt.Errorf("worktree '%s' not found", name)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Check if directory exists
|
|
95
|
+
if _, err := os.Stat(wt.Path); os.IsNotExist(err) {
|
|
96
|
+
return fmt.Errorf("worktree directory does not exist: %s", wt.Path)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Get user's shell
|
|
100
|
+
shell := os.Getenv("SHELL")
|
|
101
|
+
if shell == "" {
|
|
102
|
+
shell = "/bin/sh"
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Spawn new shell in worktree directory
|
|
106
|
+
shellCmd := exec.Command(shell)
|
|
107
|
+
shellCmd.Dir = wt.Path
|
|
108
|
+
shellCmd.Stdin = os.Stdin
|
|
109
|
+
shellCmd.Stdout = os.Stdout
|
|
110
|
+
shellCmd.Stderr = os.Stderr
|
|
111
|
+
|
|
112
|
+
// Get current branch of the worktree
|
|
113
|
+
branch := getWorktreeBranch(wt.Path)
|
|
114
|
+
|
|
115
|
+
fmt.Println()
|
|
116
|
+
fmt.Println(success.Render("✓"), bold.Render(name), dim.Render("("+branch+")"))
|
|
117
|
+
fmt.Println(dim.Render(" Subshell started. Type 'exit' to return."))
|
|
118
|
+
fmt.Println()
|
|
119
|
+
|
|
120
|
+
if err := shellCmd.Run(); err != nil {
|
|
121
|
+
return fmt.Errorf("shell exited with error: %w", err)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
fmt.Println()
|
|
125
|
+
fmt.Println(dim.Render(" Back in main worktree"))
|
|
126
|
+
fmt.Println()
|
|
127
|
+
return nil
|
|
128
|
+
}
|
package/go/cmd/rm.go
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
package cmd
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"os"
|
|
6
|
+
"os/exec"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"strings"
|
|
9
|
+
|
|
10
|
+
"github.com/pullfrog/cli/internal/store"
|
|
11
|
+
"github.com/spf13/cobra"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
var rmCmd = &cobra.Command{
|
|
15
|
+
Use: "rm <name>",
|
|
16
|
+
Short: "Remove a worktree",
|
|
17
|
+
Long: `Removes a worktree and optionally deletes the branch.`,
|
|
18
|
+
Args: cobra.ExactArgs(1),
|
|
19
|
+
RunE: runRm,
|
|
20
|
+
ValidArgsFunction: completeWorktreeNames,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
var deleteBranch bool
|
|
24
|
+
|
|
25
|
+
func init() {
|
|
26
|
+
rmCmd.Flags().BoolVarP(&deleteBranch, "delete-branch", "d", false, "Also delete the branch")
|
|
27
|
+
rootCmd.AddCommand(rmCmd)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func runRm(cmd *cobra.Command, args []string) error {
|
|
31
|
+
name := args[0]
|
|
32
|
+
|
|
33
|
+
s, err := store.Load()
|
|
34
|
+
if err != nil {
|
|
35
|
+
return fmt.Errorf("failed to load store: %w", err)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
wt := s.FindByName(name)
|
|
39
|
+
if wt == nil {
|
|
40
|
+
return fmt.Errorf("worktree '%s' not found", name)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Check if we're currently in this worktree
|
|
44
|
+
cwd, err := os.Getwd()
|
|
45
|
+
if err == nil {
|
|
46
|
+
absWtPath, _ := filepath.Abs(wt.Path)
|
|
47
|
+
absCwd, _ := filepath.Abs(cwd)
|
|
48
|
+
if strings.HasPrefix(absCwd, absWtPath) {
|
|
49
|
+
return fmt.Errorf("cannot remove current worktree - exit first")
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Get branch before removing (for -d flag)
|
|
54
|
+
branch := getWorktreeBranch(wt.Path)
|
|
55
|
+
|
|
56
|
+
// Check if directory exists
|
|
57
|
+
if _, err := os.Stat(wt.Path); err == nil {
|
|
58
|
+
// Remove the worktree using git (suppress noise)
|
|
59
|
+
gitCmd := exec.Command("git", "worktree", "remove", wt.Path)
|
|
60
|
+
if err := gitCmd.Run(); err != nil {
|
|
61
|
+
return fmt.Errorf("failed to remove worktree: %w", err)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Prune worktrees
|
|
66
|
+
exec.Command("git", "worktree", "prune").Run()
|
|
67
|
+
|
|
68
|
+
// Delete branch if requested
|
|
69
|
+
if deleteBranch && branch != "[missing]" && branch != "[detached]" {
|
|
70
|
+
gitCmd := exec.Command("git", "branch", "-D", branch)
|
|
71
|
+
if err := gitCmd.Run(); err != nil {
|
|
72
|
+
fmt.Fprintf(os.Stderr, "Warning: failed to delete branch %s: %v\n", branch, err)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Remove from store
|
|
77
|
+
s.RemoveByName(name)
|
|
78
|
+
if err := s.Save(); err != nil {
|
|
79
|
+
return fmt.Errorf("failed to save store: %w", err)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if deleteBranch && branch != "[missing]" && branch != "[detached]" {
|
|
83
|
+
fmt.Println(success.Render("✓"), "Removed", bold.Render(name), dim.Render("(branch deleted)"))
|
|
84
|
+
} else {
|
|
85
|
+
fmt.Println(success.Render("✓"), "Removed", bold.Render(name))
|
|
86
|
+
}
|
|
87
|
+
return nil
|
|
88
|
+
}
|