feat/002_init-remote
1package cmd
2
3import (
4 "fmt"
5 "os"
6 "os/exec"
7 "strings"
8 "time"
9
10 "github.com/go-git/go-billy/v5/osfs"
11 "github.com/go-git/go-git/v5"
12 "github.com/go-git/go-git/v5/config"
13 "github.com/go-git/go-git/v5/plumbing"
14 "github.com/go-git/go-git/v5/plumbing/object"
15 "github.com/spf13/cobra"
16)
17
18const (
19 _issuesBranchDefault = "issues"
20 _issuesDirDefault = "issues"
21)
22
23func NewInitCmd() *cobra.Command {
24 var issuesDir string
25 var issuesBranch string
26 var remoteName string
27 var forceLocal bool
28
29 var cmd = &cobra.Command{
30 Use: "init",
31 Short: "Initialize tissue issue tracking in the current repo",
32 Long: `Initialize tissue issue tracking by creating an orphan 'issues' branch
33with proper structure and setting up a git worktree for easy access.`,
34 RunE: func(cmd *cobra.Command, args []string) error {
35 return initRunE(cmd, args, issuesDir, issuesBranch, remoteName, forceLocal)
36 },
37 }
38
39 // flags
40 cmd.Flags().StringVar(&issuesDir, "issues-dir", _issuesDirDefault, "Directory name for the issues worktree (git worktree)")
41 cmd.Flags().StringVar(&issuesBranch, "issues-branch", _issuesBranchDefault, "Branch name for issues tracking")
42 cmd.Flags().StringVar(&remoteName, "remote", "", "Remote to use for pushing the issues branch")
43 cmd.Flags().BoolVar(&forceLocal, "force-local", false, "Create local branch even if remote checks fail")
44
45 return cmd
46}
47
48// checkRemoteBranches checks all remotes for the existence of a branch
49// Returns: remotesWithBranch (map of remotes that have the branch),
50// failedRemotes (list of remotes that couldn't be checked),
51// error (if we couldn't get remotes at all)
52func checkRemoteBranches(repo *git.Repository, branchName string) (map[string]bool, []string, error) {
53 remoteBranches := make(map[string]bool)
54 var failedRemotes []string
55
56 remotes, err := repo.Remotes()
57 if err != nil {
58 return nil, nil, err
59 }
60
61 for _, remote := range remotes {
62 // git ls-remote <remote>
63 refs, err := remote.List(&git.ListOptions{})
64 if err != nil {
65 failedRemotes = append(failedRemotes, remote.Config().Name)
66 continue
67 }
68 for _, ref := range refs {
69 if ref.Name() == plumbing.ReferenceName("refs/heads/"+branchName) {
70 remoteBranches[remote.Config().Name] = true
71 break
72 }
73 }
74 }
75
76 return remoteBranches, failedRemotes, nil
77}
78
79// determineTargetRemote determines which remote to use for push operations
80func determineTargetRemote(repo *git.Repository, requestedRemote string, numRemotes int) string {
81 if requestedRemote != "" {
82 remotes, _ := repo.Remotes()
83 for _, remote := range remotes {
84 if remote.Config().Name == requestedRemote {
85 return requestedRemote
86 }
87 }
88 return ""
89 }
90
91 if numRemotes == 0 {
92 return ""
93 }
94
95 if numRemotes == 1 {
96 remotes, _ := repo.Remotes()
97 return remotes[0].Config().Name
98 }
99
100 // Multiple remotes - require explicit --remote flag
101 return ""
102}
103
104// checkoutAndTrackRemoteBranch fetches and tracks an existing remote branch
105func checkoutAndTrackRemoteBranch(repo *git.Repository, remoteName, branchName, issuesDir string) error {
106 remote, err := repo.Remote(remoteName)
107 if err != nil {
108 return fmt.Errorf("getting remote %s: %w", remoteName, err)
109 }
110
111 // git fetch origin refs/heads/issues:refs/remotes/origin/issues
112 err = remote.Fetch(&git.FetchOptions{
113 RefSpecs: []config.RefSpec{
114 config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/remotes/%s/%s", branchName, remoteName, branchName)),
115 },
116 })
117 if err != nil && err != git.NoErrAlreadyUpToDate {
118 return fmt.Errorf("fetching remote branch: %w", err)
119 }
120
121 refName := plumbing.ReferenceName(fmt.Sprintf("refs/heads/%s", branchName))
122 remoteRef := plumbing.ReferenceName(fmt.Sprintf("refs/remotes/%s/%s", remoteName, branchName))
123
124 ref, err := repo.Reference(remoteRef, true)
125 if err != nil {
126 return fmt.Errorf("getting remote branch reference: %w", err)
127 }
128
129 // git branch issues origin/issues
130 err = repo.Storer.SetReference(plumbing.NewHashReference(refName, ref.Hash()))
131 if err != nil {
132 return fmt.Errorf("creating local branch: %w", err)
133 }
134
135 // git branch --set-upstream-to=origin/issues issues
136 cfg, err := repo.Config()
137 if err != nil {
138 return fmt.Errorf("getting repo config: %w", err)
139 }
140
141 cfg.Branches[branchName] = &config.Branch{
142 Name: branchName,
143 Remote: remoteName,
144 Merge: refName,
145 }
146
147 err = repo.Storer.SetConfig(cfg)
148 if err != nil {
149 return fmt.Errorf("setting branch tracking: %w", err)
150 }
151
152 // git worktree add issues issues
153 worktreeCmd := exec.Command("git", "worktree", "add", issuesDir, branchName)
154 worktreeCmd.Stdout = os.Stdout
155 worktreeCmd.Stderr = os.Stderr
156 err = worktreeCmd.Run()
157 if err != nil {
158 return fmt.Errorf("creating worktree: %w", err)
159 }
160
161 // Add issuesDir/ to .gitignore in main branch
162 worktree, err := repo.Worktree()
163 if err != nil {
164 return fmt.Errorf("getting worktree: %w", err)
165 }
166
167 // Get current branch to return to it
168 head, err := repo.Head()
169 if err != nil {
170 return fmt.Errorf("getting HEAD: %w", err)
171 }
172
173 gitignoreMainPath := ".gitignore"
174 var gitignoreMainContent []byte
175
176 // Check if .gitignore exists
177 if file, err := os.Open(gitignoreMainPath); err == nil {
178 defer file.Close()
179 info, _ := file.Stat()
180 gitignoreMainContent = make([]byte, info.Size())
181 file.Read(gitignoreMainContent)
182 }
183
184 // Append issuesDir/ if not already present
185 gitignoreStr := string(gitignoreMainContent)
186 gitignoreEntry := issuesDir + "/"
187 if !contains(gitignoreStr, gitignoreEntry) {
188 if len(gitignoreStr) > 0 && gitignoreStr[len(gitignoreStr)-1] != '\n' {
189 gitignoreStr += "\n"
190 }
191 gitignoreStr += gitignoreEntry + "\n"
192
193 err = os.WriteFile(gitignoreMainPath, []byte(gitignoreStr), 0644)
194 if err != nil {
195 return fmt.Errorf("updating .gitignore: %w", err)
196 }
197
198 // Make sure we're on the main branch to commit .gitignore
199 err = worktree.Checkout(&git.CheckoutOptions{
200 Branch: head.Name(),
201 })
202 if err != nil {
203 // Could not checkout main branch for .gitignore update
204 }
205
206 // Stage and commit .gitignore update
207 _, err = worktree.Add(gitignoreMainPath)
208 if err != nil {
209 // Could not stage .gitignore
210 } else {
211 _, err = worktree.Commit(fmt.Sprintf("Add %s worktree to gitignore", issuesDir), &git.CommitOptions{
212 Author: &object.Signature{
213 Name: "Tissue CLI",
214 Email: "tissue@example.com",
215 When: time.Now(),
216 },
217 })
218 if err != nil {
219 // Could not commit .gitignore update
220 }
221 }
222 }
223
224 fmt.Printf("Initialized tissue in %s/\n", issuesDir)
225
226 return nil
227}
228
229func initRunE(cmd *cobra.Command, args []string, issuesDir string, issuesBranch string, remoteName string, forceLocal bool) error {
230 // Step 1: Open and validate repository
231 repo, err := git.PlainOpen(".")
232 if err != nil {
233 err = fmt.Errorf("opening repository: %w", err)
234 return err
235 }
236
237 // Store current branch to return to later
238 head, err := repo.Head()
239 if err != nil {
240 err = fmt.Errorf("getting current branch: %w", err)
241 return err
242 }
243 originalBranch := head.Name()
244
245 // Check if issues branch already exists locally
246 branchRefName := plumbing.ReferenceName("refs/heads/" + issuesBranch)
247 _, err = repo.Reference(branchRefName, false)
248 localBranchExists := err == nil
249
250 // Check if worktree already exists
251 worktreeExists := false
252 if _, err := os.Stat(issuesDir); err == nil {
253 // Check if it's actually a git worktree
254 worktreeCheckCmd := exec.Command("git", "worktree", "list")
255 output, err := worktreeCheckCmd.Output()
256 if err == nil && contains(string(output), issuesDir) {
257 worktreeExists = true
258 }
259 }
260
261 // If both branch and worktree exist, error out
262 if localBranchExists && worktreeExists {
263 err = fmt.Errorf("%s branch and worktree already exist - use 'tissue status' to check current setup", issuesBranch)
264 return err
265 }
266
267 // If branch exists but worktree doesn't, we'll set up the worktree later
268 var skipBranchCreation bool
269 if localBranchExists && !worktreeExists {
270 skipBranchCreation = true
271 }
272
273 if !skipBranchCreation {
274 // Check remote branches
275 remotesWithBranch, failedRemotes, err := checkRemoteBranches(repo, issuesBranch)
276 if err != nil {
277 // Could not check remote branches - continue anyway
278 }
279
280 remotes, _ := repo.Remotes()
281 numRemotes := len(remotes)
282
283 // If we have failed remotes and user didn't use --force-local, fail safe
284 if len(failedRemotes) > 0 && !forceLocal {
285 return fmt.Errorf("could not verify remote branches for: %v\n\nPossible causes:\n- SSH connectivity issues\n- Network problems\n- Authentication failures\n\nTo proceed anyway, use: tissue init --force-local\nOr fix the connectivity issue and try again", failedRemotes)
286 }
287
288 // Validate --remote flag if provided
289 if remoteName != "" {
290 found := false
291 for _, remote := range remotes {
292 if remote.Config().Name == remoteName {
293 found = true
294 break
295 }
296 }
297 if !found {
298 return fmt.Errorf("remote '%s' does not exist", remoteName)
299 }
300 }
301
302 // Case 4: Multiple remotes, one has issues branch
303 if numRemotes > 1 && len(remotesWithBranch) == 1 {
304 for remote := range remotesWithBranch {
305 // Track the existing remote branch
306 return checkoutAndTrackRemoteBranch(repo, remote, issuesBranch, issuesDir)
307 }
308 }
309
310 // Case 5: Multiple remotes, multiple have issues branch
311 if len(remotesWithBranch) > 1 && remoteName == "" {
312 remoteList := []string{}
313 for r := range remotesWithBranch {
314 remoteList = append(remoteList, r)
315 }
316 return fmt.Errorf("branch '%s' exists on multiple remotes: %v. Use --remote flag to specify which remote to use",
317 issuesBranch, remoteList)
318 }
319
320 // If --remote was specified and that remote has the branch, track it
321 if remoteName != "" && remotesWithBranch[remoteName] {
322 return checkoutAndTrackRemoteBranch(repo, remoteName, issuesBranch, issuesDir)
323 }
324
325 // Single remote with issues branch
326 if numRemotes == 1 && len(remotesWithBranch) == 1 {
327 for remote := range remotesWithBranch {
328 return checkoutAndTrackRemoteBranch(repo, remote, issuesBranch, issuesDir)
329 }
330 }
331
332 // Step 2: Create orphan branch
333 orphanRef := plumbing.NewSymbolicReference(
334 plumbing.HEAD,
335 branchRefName,
336 )
337 err = repo.Storer.SetReference(orphanRef)
338 if err != nil {
339 err = fmt.Errorf("creating orphan branch: %w", err)
340 return err
341 }
342
343 // Step 3: Get worktree and initialize files
344 worktree, err := repo.Worktree()
345 if err != nil {
346 err = fmt.Errorf("getting worktree: %w", err)
347 return err
348 }
349
350 // Clear the index and working directory for orphan branch
351 // git rm -rf . (remove all files from index and working directory)
352 status, err := worktree.Status()
353 if err != nil {
354 err = fmt.Errorf("getting worktree status: %w", err)
355 return err
356 }
357
358 for filepath := range status {
359 _, err := worktree.Remove(filepath)
360 if err != nil {
361 // Continue even if some files fail to be removed
362 }
363 os.Remove(filepath)
364 }
365
366 err = worktree.RemoveGlob("*")
367 if err != nil && err != git.ErrGlobNoMatches {
368 // RemoveGlob failed, continue anyway
369 }
370
371 fs := osfs.New(".")
372 readmePath := "README.md"
373 readmeContent := `# Issue Tracker
374
375This branch contains all issues for this repository.
376Each issue is a markdown file with YAML frontmatter.
377
378## Structure
379
380Issues are stored as markdown files with the following frontmatter:
381
382` + "```yaml" + `
383---
384id: 001
385title: Issue title
386status: open
387type: bug|feature|task
388created: 2025-09-24
389assignee: username
390---
391` + "```" + `
392
393## Usage
394
395Use the tissue CLI to manage issues:
396- ` + "`tissue new`" + ` - Create a new issue
397- ` + "`tissue list`" + ` - List all issues
398- ` + "`tissue view <id>`" + ` - View an issue
399- ` + "`tissue update <id>`" + ` - Update an issue
400`
401 readmeFile, err := fs.Create(readmePath)
402 if err != nil {
403 err = fmt.Errorf("creating README.md: %w", err)
404 return err
405 }
406 _, err = readmeFile.Write([]byte(readmeContent))
407 if err != nil {
408 readmeFile.Close()
409 err = fmt.Errorf("writing README.md: %w", err)
410 return err
411 }
412 readmeFile.Close()
413
414 // Create .gitignore
415 gitignorePath := ".gitignore"
416 gitignoreContent := `# Ignore everything except markdown files and directories
417/*
418!*.md
419!*/
420!.gitignore
421`
422 gitignoreFile, err := fs.Create(gitignorePath)
423 if err != nil {
424 err = fmt.Errorf("creating .gitignore: %w", err)
425 return err
426 }
427 _, err = gitignoreFile.Write([]byte(gitignoreContent))
428 if err != nil {
429 gitignoreFile.Close()
430 err = fmt.Errorf("writing .gitignore: %w", err)
431 return err
432 }
433 gitignoreFile.Close()
434
435 // git add README.md .gitignore
436 _, err = worktree.Add(readmePath)
437 if err != nil {
438 err = fmt.Errorf("staging README.md: %w", err)
439 return err
440 }
441 _, err = worktree.Add(gitignorePath)
442 if err != nil {
443 err = fmt.Errorf("staging .gitignore: %w", err)
444 return err
445 }
446
447 // Commit
448 _, err = worktree.Commit(fmt.Sprintf("Initialize %s branch for issue tracking", issuesBranch), &git.CommitOptions{
449 Author: &object.Signature{
450 Name: "Tissue CLI",
451 Email: "tissue@example.com",
452 When: time.Now(),
453 },
454 })
455 if err != nil {
456 err = fmt.Errorf("committing initial structure: %w", err)
457 return err
458 }
459
460 // git push <remote> <branch>
461 targetRemote := determineTargetRemote(repo, remoteName, numRemotes)
462
463 if targetRemote != "" {
464 refSpec := fmt.Sprintf("refs/heads/%s:refs/heads/%s", issuesBranch, issuesBranch)
465 err = repo.Push(&git.PushOptions{
466 RemoteName: targetRemote,
467 RefSpecs: []config.RefSpec{
468 config.RefSpec(refSpec),
469 },
470 })
471 if err != nil && err != git.NoErrAlreadyUpToDate {
472 // Push failed, user can push manually
473 }
474 }
475
476 // git checkout <original-branch>
477 err = worktree.Checkout(&git.CheckoutOptions{
478 Branch: originalBranch,
479 })
480 if err != nil {
481 err = fmt.Errorf("returning to original branch: %w", err)
482 return err
483 }
484 } // End of !skipBranchCreation
485
486 // git worktree add <dir> <branch>
487 // TODO: Remove dependency on git CLI when go-git supports worktrees
488 // Track progress: https://github.com/go-git/go-git/pull/396
489 worktreeCmd := exec.Command("git", "worktree", "add", issuesDir, issuesBranch)
490 worktreeCmd.Stdout = os.Stdout
491 worktreeCmd.Stderr = os.Stderr
492 err = worktreeCmd.Run()
493 if err != nil {
494 err = fmt.Errorf("creating worktree: %w", err)
495 return err
496 }
497
498 // Add issuesDir/ to .gitignore in main branch
499 if !skipBranchCreation {
500 worktree, _ := repo.Worktree()
501 gitignoreMainPath := ".gitignore"
502 var gitignoreMainContent []byte
503
504 if file, err := os.Open(gitignoreMainPath); err == nil {
505 defer file.Close()
506 info, _ := file.Stat()
507 gitignoreMainContent = make([]byte, info.Size())
508 file.Read(gitignoreMainContent)
509 }
510
511 gitignoreStr := string(gitignoreMainContent)
512 gitignoreEntry := issuesDir + "/"
513 if !contains(gitignoreStr, gitignoreEntry) {
514 if len(gitignoreStr) > 0 && gitignoreStr[len(gitignoreStr)-1] != '\n' {
515 gitignoreStr += "\n"
516 }
517 gitignoreStr += gitignoreEntry + "\n"
518
519 err = os.WriteFile(gitignoreMainPath, []byte(gitignoreStr), 0644)
520 if err != nil {
521 err = fmt.Errorf("updating .gitignore: %w", err)
522 return err
523 }
524
525 // git add .gitignore && git commit -m "..."
526 _, err = worktree.Add(gitignoreMainPath)
527 if err == nil {
528 _, err = worktree.Commit(fmt.Sprintf("Add %s worktree to gitignore", issuesDir), &git.CommitOptions{
529 Author: &object.Signature{
530 Name: "Tissue CLI",
531 Email: "tissue@example.com",
532 When: time.Now(),
533 },
534 })
535 // Commit may fail if no changes or other reasons
536 }
537 }
538 }
539
540 fmt.Printf("Initialized tissue in %s/\n", issuesDir)
541
542 return nil
543}
544
545func contains(s, substr string) bool {
546 return strings.Contains(s, substr)
547}