tangled
alpha
login
or
join now
fuwn.net
/
mugi
0
fork
atom
🍰 Personal Multi-Git Remote Manager
go
git
0
fork
atom
overview
issues
pulls
pipelines
feat: Add repository management commands
fuwn.net
1 month ago
a391fdec
0dbdee0e
verified
This commit was signed with the committer's
known signature
.
fuwn.net
SSH Key Fingerprint:
SHA256:VPdFPyPbd6JkoMyWUdZ/kkTcIAt3sxjXD2XSAZ7FYC4=
+365
-11
3 changed files
expand all
collapse all
unified
split
cmd
mugi
main.go
internal
cli
cli.go
manage
manage.go
+44
-1
cmd/mugi/main.go
···
6
6
7
7
"github.com/ebisu/mugi/internal/cli"
8
8
"github.com/ebisu/mugi/internal/config"
9
9
+
"github.com/ebisu/mugi/internal/manage"
9
10
"github.com/ebisu/mugi/internal/ui"
10
11
)
11
12
···
36
37
return nil
37
38
}
38
39
39
39
-
cfg, err := config.Load(cmd.ConfigPath)
40
40
+
configPath := cmd.ConfigPath
41
41
+
if configPath == "" {
42
42
+
configPath, _ = config.Path()
43
43
+
}
44
44
+
45
45
+
switch cmd.Type {
46
46
+
case cli.CommandAdd:
47
47
+
cfg, err := config.Load(configPath)
48
48
+
if err != nil {
49
49
+
return fmt.Errorf("config: %w", err)
50
50
+
}
51
51
+
52
52
+
if err := manage.Add(cmd.Path, configPath, cfg.Remotes); err != nil {
53
53
+
return err
54
54
+
}
55
55
+
56
56
+
fmt.Printf("Added repository: %s\n", cmd.Path)
57
57
+
58
58
+
return nil
59
59
+
60
60
+
case cli.CommandRemove:
61
61
+
if err := manage.Remove(cmd.Repo, configPath); err != nil {
62
62
+
return err
63
63
+
}
64
64
+
65
65
+
fmt.Printf("Removed repository: %s\n", cmd.Repo)
66
66
+
67
67
+
return nil
68
68
+
69
69
+
case cli.CommandList:
70
70
+
repos, err := manage.List(configPath)
71
71
+
if err != nil {
72
72
+
return err
73
73
+
}
74
74
+
75
75
+
for _, repo := range repos {
76
76
+
fmt.Printf("%s (%s)\n", repo.Name, repo.Path)
77
77
+
}
78
78
+
79
79
+
return nil
80
80
+
}
81
81
+
82
82
+
cfg, err := config.Load(configPath)
40
83
if err != nil {
41
84
return fmt.Errorf("config: %w", err)
42
85
}
+51
-10
internal/cli/cli.go
···
9
9
"github.com/ebisu/mugi/internal/remote"
10
10
)
11
11
12
12
+
type CommandType int
13
13
+
14
14
+
const (
15
15
+
CommandOperation CommandType = iota
16
16
+
CommandAdd
17
17
+
CommandRemove
18
18
+
CommandList
19
19
+
)
20
20
+
12
21
type Command struct {
22
22
+
Type CommandType
13
23
Operation remote.Operation
14
24
Repo string
15
25
Remotes []string
26
26
+
Path string
16
27
ConfigPath string
17
28
Verbose bool
18
29
Force bool
···
61
72
62
73
switch args[0] {
63
74
case "pull":
75
75
+
cmd.Type = CommandOperation
64
76
cmd.Operation = remote.Pull
65
77
case "push":
78
78
+
cmd.Type = CommandOperation
66
79
cmd.Operation = remote.Push
67
80
case "fetch":
81
81
+
cmd.Type = CommandOperation
68
82
cmd.Operation = remote.Fetch
83
83
+
case "add":
84
84
+
cmd.Type = CommandAdd
85
85
+
86
86
+
if len(args) < 2 {
87
87
+
cmd.Path = "."
88
88
+
} else {
89
89
+
cmd.Path = args[1]
90
90
+
}
91
91
+
92
92
+
return cmd, nil
93
93
+
case "rm", "remove":
94
94
+
cmd.Type = CommandRemove
95
95
+
96
96
+
if len(args) < 2 {
97
97
+
return cmd, fmt.Errorf("rm requires a repository name")
98
98
+
}
99
99
+
100
100
+
cmd.Repo = args[1]
101
101
+
102
102
+
return cmd, nil
103
103
+
case "list", "ls":
104
104
+
cmd.Type = CommandList
105
105
+
106
106
+
return cmd, nil
69
107
default:
70
108
return cmd, fmt.Errorf("%w: %s", ErrUnknownCommand, args[0])
71
109
}
···
95
133
mugi [flags] <command> [repo] [remotes...]
96
134
97
135
Commands:
98
98
-
pull Pull from remote(s)
99
99
-
push Push to remote(s)
100
100
-
fetch Fetch from remote(s)
101
101
-
help Show this help
102
102
-
version Show version
136
136
+
pull Pull from remote(s)
137
137
+
push Push to remote(s)
138
138
+
fetch Fetch from remote(s)
139
139
+
add <path> Add repository to config
140
140
+
rm <name> Remove repository from config
141
141
+
list List tracked repositories
142
142
+
help Show this help
143
143
+
version Show version
103
144
104
145
Flags:
105
146
-c, --config <path> Override config file path
···
109
150
110
151
Examples:
111
152
mugi pull Pull all repositories from all remotes
112
112
-
mugi pull windmark Pull Windmark from all remotes
113
113
-
mugi pull windmark github Pull Windmark from GitHub only
114
114
-
mugi push windmark gh cb Push Windmark to GitHub and Codeberg
115
115
-
mugi fetch gemrest/september Fetch specific repository
116
116
-
mugi -c ./test.yaml pull Use custom config
153
153
+
mugi push windmark gh cb Push to GitHub and Codeberg
154
154
+
mugi add . Add current directory to config
155
155
+
mugi add ~/Developer/mugi Add repository at path
156
156
+
mugi rm mugi Remove repository from config
157
157
+
mugi list List all tracked repositories
117
158
118
159
Config: ` + configPath()
119
160
}
+270
internal/manage/manage.go
···
1
1
+
package manage
2
2
+
3
3
+
import (
4
4
+
"fmt"
5
5
+
"os"
6
6
+
"os/exec"
7
7
+
"path/filepath"
8
8
+
"strings"
9
9
+
10
10
+
"github.com/ebisu/mugi/internal/config"
11
11
+
"gopkg.in/yaml.v3"
12
12
+
)
13
13
+
14
14
+
type RepoInfo struct {
15
15
+
Name string
16
16
+
Path string
17
17
+
Remotes map[string]string
18
18
+
}
19
19
+
20
20
+
func Add(path, configPath string, remoteDefs map[string]config.RemoteDefinition) error {
21
21
+
absPath, err := filepath.Abs(path)
22
22
+
if err != nil {
23
23
+
return fmt.Errorf("invalid path: %w", err)
24
24
+
}
25
25
+
26
26
+
if !isGitRepo(absPath) {
27
27
+
return fmt.Errorf("not a git repository: %s", absPath)
28
28
+
}
29
29
+
30
30
+
info, err := extractRepoInfo(absPath, remoteDefs)
31
31
+
if err != nil {
32
32
+
return err
33
33
+
}
34
34
+
35
35
+
return appendToConfig(configPath, info)
36
36
+
}
37
37
+
38
38
+
func Remove(name, configPath string) error {
39
39
+
cfg, err := config.Load(configPath)
40
40
+
if err != nil {
41
41
+
return err
42
42
+
}
43
43
+
44
44
+
fullName, _, found := cfg.FindRepo(name)
45
45
+
if !found {
46
46
+
return fmt.Errorf("repository not found: %s", name)
47
47
+
}
48
48
+
49
49
+
return removeFromConfig(configPath, fullName)
50
50
+
}
51
51
+
52
52
+
func List(configPath string) ([]RepoInfo, error) {
53
53
+
cfg, err := config.Load(configPath)
54
54
+
if err != nil {
55
55
+
return nil, err
56
56
+
}
57
57
+
58
58
+
var repos []RepoInfo
59
59
+
60
60
+
for name, repo := range cfg.Repos {
61
61
+
repos = append(repos, RepoInfo{
62
62
+
Name: name,
63
63
+
Path: repo.ExpandPath(),
64
64
+
Remotes: repo.Remotes,
65
65
+
})
66
66
+
}
67
67
+
68
68
+
return repos, nil
69
69
+
}
70
70
+
71
71
+
func isGitRepo(path string) bool {
72
72
+
cmd := exec.Command("git", "rev-parse", "--git-dir")
73
73
+
cmd.Dir = path
74
74
+
75
75
+
return cmd.Run() == nil
76
76
+
}
77
77
+
78
78
+
func extractRepoInfo(path string, remoteDefs map[string]config.RemoteDefinition) (RepoInfo, error) {
79
79
+
info := RepoInfo{
80
80
+
Path: path,
81
81
+
Remotes: make(map[string]string),
82
82
+
}
83
83
+
84
84
+
cmd := exec.Command("git", "remote", "-v")
85
85
+
cmd.Dir = path
86
86
+
87
87
+
out, err := cmd.Output()
88
88
+
if err != nil {
89
89
+
return info, fmt.Errorf("failed to get remotes: %w", err)
90
90
+
}
91
91
+
92
92
+
remoteURLs := parseRemotes(string(out))
93
93
+
94
94
+
for remoteName, url := range remoteURLs {
95
95
+
knownRemote := matchRemoteURL(url, remoteDefs)
96
96
+
97
97
+
if knownRemote != "" {
98
98
+
info.Remotes[knownRemote] = url
99
99
+
} else {
100
100
+
info.Remotes[remoteName] = url
101
101
+
}
102
102
+
}
103
103
+
104
104
+
info.Name = inferRepoName(path, remoteURLs)
105
105
+
106
106
+
return info, nil
107
107
+
}
108
108
+
109
109
+
func parseRemotes(output string) map[string]string {
110
110
+
remotes := make(map[string]string)
111
111
+
112
112
+
for line := range strings.SplitSeq(output, "\n") {
113
113
+
if !strings.Contains(line, "(fetch)") {
114
114
+
continue
115
115
+
}
116
116
+
117
117
+
parts := strings.Fields(line)
118
118
+
119
119
+
if len(parts) >= 2 {
120
120
+
remotes[parts[0]] = parts[1]
121
121
+
}
122
122
+
}
123
123
+
124
124
+
return remotes
125
125
+
}
126
126
+
127
127
+
func matchRemoteURL(url string, remoteDefs map[string]config.RemoteDefinition) string {
128
128
+
for name, def := range remoteDefs {
129
129
+
template := def.URL
130
130
+
131
131
+
if template == "" {
132
132
+
continue
133
133
+
}
134
134
+
135
135
+
pattern := strings.ReplaceAll(template, "${user}", "")
136
136
+
pattern = strings.ReplaceAll(pattern, "${repo}", "")
137
137
+
138
138
+
base := strings.Split(pattern, ":")[0]
139
139
+
140
140
+
if strings.Contains(url, base) || strings.Contains(url, name) {
141
141
+
return name
142
142
+
}
143
143
+
}
144
144
+
145
145
+
return ""
146
146
+
}
147
147
+
148
148
+
func inferRepoName(path string, remotes map[string]string) string {
149
149
+
for _, url := range remotes {
150
150
+
name := extractRepoNameFromURL(url)
151
151
+
152
152
+
if name != "" {
153
153
+
return name
154
154
+
}
155
155
+
}
156
156
+
157
157
+
return filepath.Base(path)
158
158
+
}
159
159
+
160
160
+
func extractRepoNameFromURL(url string) string {
161
161
+
url = strings.TrimSuffix(url, ".git")
162
162
+
163
163
+
if strings.Contains(url, ":") {
164
164
+
parts := strings.Split(url, ":")
165
165
+
166
166
+
if len(parts) == 2 {
167
167
+
return strings.TrimPrefix(parts[1], "~")
168
168
+
}
169
169
+
}
170
170
+
171
171
+
if strings.Contains(url, "/") {
172
172
+
parts := strings.Split(url, "/")
173
173
+
174
174
+
if len(parts) >= 2 {
175
175
+
return parts[len(parts)-2] + "/" + parts[len(parts)-1]
176
176
+
}
177
177
+
}
178
178
+
179
179
+
return ""
180
180
+
}
181
181
+
182
182
+
func appendToConfig(configPath string, info RepoInfo) error {
183
183
+
data, err := os.ReadFile(configPath)
184
184
+
if err != nil {
185
185
+
return err
186
186
+
}
187
187
+
188
188
+
var raw map[string]yaml.Node
189
189
+
190
190
+
if err := yaml.Unmarshal(data, &raw); err != nil {
191
191
+
return err
192
192
+
}
193
193
+
194
194
+
reposNode, ok := raw["repos"]
195
195
+
if !ok {
196
196
+
return fmt.Errorf("repos section not found in config")
197
197
+
}
198
198
+
199
199
+
repoEntry := map[string]any{
200
200
+
"path": info.Path,
201
201
+
"remotes": info.Remotes,
202
202
+
}
203
203
+
204
204
+
entryBytes, err := yaml.Marshal(map[string]any{info.Name: repoEntry})
205
205
+
if err != nil {
206
206
+
return err
207
207
+
}
208
208
+
209
209
+
var entryNode yaml.Node
210
210
+
211
211
+
if err := yaml.Unmarshal(entryBytes, &entryNode); err != nil {
212
212
+
return err
213
213
+
}
214
214
+
215
215
+
if reposNode.Kind == yaml.MappingNode && len(entryNode.Content) > 0 && len(entryNode.Content[0].Content) >= 2 {
216
216
+
reposNode.Content = append(reposNode.Content, entryNode.Content[0].Content...)
217
217
+
raw["repos"] = reposNode
218
218
+
}
219
219
+
220
220
+
output, err := yaml.Marshal(raw)
221
221
+
if err != nil {
222
222
+
return err
223
223
+
}
224
224
+
225
225
+
return os.WriteFile(configPath, output, 0o644)
226
226
+
}
227
227
+
228
228
+
func removeFromConfig(configPath, name string) error {
229
229
+
data, err := os.ReadFile(configPath)
230
230
+
if err != nil {
231
231
+
return err
232
232
+
}
233
233
+
234
234
+
var raw map[string]yaml.Node
235
235
+
236
236
+
if err := yaml.Unmarshal(data, &raw); err != nil {
237
237
+
return err
238
238
+
}
239
239
+
240
240
+
reposNode, ok := raw["repos"]
241
241
+
if !ok {
242
242
+
return fmt.Errorf("repos section not found in config")
243
243
+
}
244
244
+
245
245
+
if reposNode.Kind != yaml.MappingNode {
246
246
+
return fmt.Errorf("repos section is not a mapping")
247
247
+
}
248
248
+
249
249
+
var newContent []*yaml.Node
250
250
+
251
251
+
for i := 0; i < len(reposNode.Content); i += 2 {
252
252
+
if i+1 >= len(reposNode.Content) {
253
253
+
break
254
254
+
}
255
255
+
256
256
+
if reposNode.Content[i].Value != name {
257
257
+
newContent = append(newContent, reposNode.Content[i], reposNode.Content[i+1])
258
258
+
}
259
259
+
}
260
260
+
261
261
+
reposNode.Content = newContent
262
262
+
raw["repos"] = reposNode
263
263
+
264
264
+
output, err := yaml.Marshal(raw)
265
265
+
if err != nil {
266
266
+
return err
267
267
+
}
268
268
+
269
269
+
return os.WriteFile(configPath, output, 0o644)
270
270
+
}