main
Raw Download raw file
  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}