Commit 3038f7a
cmd/init.go
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"os/exec"
+ "strings"
"time"
"github.com/go-git/go-billy/v5/osfs"
@@ -23,6 +24,7 @@ func NewInitCmd() *cobra.Command {
var issuesDir string
var issuesBranch string
var remoteName string
+ var forceLocal bool
var cmd = &cobra.Command{
Use: "init",
@@ -30,7 +32,7 @@ func NewInitCmd() *cobra.Command {
Long: `Initialize tissue issue tracking by creating an orphan 'issues' branch
with proper structure and setting up a git worktree for easy access.`,
RunE: func(cmd *cobra.Command, args []string) error {
- return initRunE(cmd, args, issuesDir, issuesBranch, remoteName)
+ return initRunE(cmd, args, issuesDir, issuesBranch, remoteName, forceLocal)
},
}
@@ -38,29 +40,31 @@ with proper structure and setting up a git worktree for easy access.`,
cmd.Flags().StringVar(&issuesDir, "issues-dir", _issuesDirDefault, "Directory name for the issues worktree (git worktree)")
cmd.Flags().StringVar(&issuesBranch, "issues-branch", _issuesBranchDefault, "Branch name for issues tracking")
cmd.Flags().StringVar(&remoteName, "remote", "", "Remote to use for pushing the issues branch")
+ cmd.Flags().BoolVar(&forceLocal, "force-local", false, "Create local branch even if remote checks fail")
return cmd
}
// checkRemoteBranches checks all remotes for the existence of a branch
-func checkRemoteBranches(repo *git.Repository, branchName string) (map[string]bool, error) {
+// Returns: remotesWithBranch (map of remotes that have the branch),
+// failedRemotes (list of remotes that couldn't be checked),
+// error (if we couldn't get remotes at all)
+func checkRemoteBranches(repo *git.Repository, branchName string) (map[string]bool, []string, error) {
remoteBranches := make(map[string]bool)
+ var failedRemotes []string
remotes, err := repo.Remotes()
if err != nil {
- return nil, err
+ return nil, nil, err
}
for _, remote := range remotes {
- // List remote references
+ // git ls-remote <remote>
refs, err := remote.List(&git.ListOptions{})
if err != nil {
- // Skip unreachable remotes
- fmt.Printf("Warning: Could not list refs for remote %s: %v\n", remote.Config().Name, err)
+ failedRemotes = append(failedRemotes, remote.Config().Name)
continue
}
-
- // Check if the branch exists on this remote
for _, ref := range refs {
if ref.Name() == plumbing.ReferenceName("refs/heads/"+branchName) {
remoteBranches[remote.Config().Name] = true
@@ -69,20 +73,18 @@ func checkRemoteBranches(repo *git.Repository, branchName string) (map[string]bo
}
}
- return remoteBranches, nil
+ return remoteBranches, failedRemotes, nil
}
// determineTargetRemote determines which remote to use for push operations
func determineTargetRemote(repo *git.Repository, requestedRemote string, numRemotes int) string {
if requestedRemote != "" {
- // Validate that requested remote exists
remotes, _ := repo.Remotes()
for _, remote := range remotes {
if remote.Config().Name == requestedRemote {
return requestedRemote
}
}
- // Remote doesn't exist - will error later
return ""
}
@@ -90,26 +92,23 @@ func determineTargetRemote(repo *git.Repository, requestedRemote string, numRemo
return ""
}
- // Case 2: Single remote (use it regardless of name)
if numRemotes == 1 {
remotes, _ := repo.Remotes()
return remotes[0].Config().Name
}
- // Case 3: Multiple remotes without --remote flag
- // Don't push automatically
+ // Multiple remotes - require explicit --remote flag
return ""
}
// checkoutAndTrackRemoteBranch fetches and tracks an existing remote branch
func checkoutAndTrackRemoteBranch(repo *git.Repository, remoteName, branchName, issuesDir string) error {
- // Fetch the remote branch
remote, err := repo.Remote(remoteName)
if err != nil {
return fmt.Errorf("getting remote %s: %w", remoteName, err)
}
- fmt.Printf("Fetching '%s' branch from remote '%s'...\n", branchName, remoteName)
+ // git fetch origin refs/heads/issues:refs/remotes/origin/issues
err = remote.Fetch(&git.FetchOptions{
RefSpecs: []config.RefSpec{
config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/remotes/%s/%s", branchName, remoteName, branchName)),
@@ -119,23 +118,21 @@ func checkoutAndTrackRemoteBranch(repo *git.Repository, remoteName, branchName,
return fmt.Errorf("fetching remote branch: %w", err)
}
- // Create local branch tracking the remote
refName := plumbing.ReferenceName(fmt.Sprintf("refs/heads/%s", branchName))
remoteRef := plumbing.ReferenceName(fmt.Sprintf("refs/remotes/%s/%s", remoteName, branchName))
- // Get the remote branch reference
ref, err := repo.Reference(remoteRef, true)
if err != nil {
return fmt.Errorf("getting remote branch reference: %w", err)
}
- // Create local branch pointing to same commit
+ // git branch issues origin/issues
err = repo.Storer.SetReference(plumbing.NewHashReference(refName, ref.Hash()))
if err != nil {
return fmt.Errorf("creating local branch: %w", err)
}
- // Set up tracking
+ // git branch --set-upstream-to=origin/issues issues
cfg, err := repo.Config()
if err != nil {
return fmt.Errorf("getting repo config: %w", err)
@@ -152,14 +149,12 @@ func checkoutAndTrackRemoteBranch(repo *git.Repository, remoteName, branchName,
return fmt.Errorf("setting branch tracking: %w", err)
}
- // Setup worktree
- fmt.Printf("Setting up git worktree at %s...\n", issuesDir)
+ // git worktree add issues issues
worktreeCmd := exec.Command("git", "worktree", "add", issuesDir, branchName)
worktreeCmd.Stdout = os.Stdout
worktreeCmd.Stderr = os.Stderr
err = worktreeCmd.Run()
if err != nil {
- fmt.Printf("Manual worktree setup: git worktree add %s %s\n", issuesDir, branchName)
return fmt.Errorf("creating worktree: %w", err)
}
@@ -205,13 +200,13 @@ func checkoutAndTrackRemoteBranch(repo *git.Repository, remoteName, branchName,
Branch: head.Name(),
})
if err != nil {
- fmt.Printf("Warning: Could not checkout main branch for .gitignore update: %v\n", err)
+ // Could not checkout main branch for .gitignore update
}
// Stage and commit .gitignore update
_, err = worktree.Add(gitignoreMainPath)
if err != nil {
- fmt.Printf("Warning: Could not stage .gitignore: %v\n", err)
+ // Could not stage .gitignore
} else {
_, err = worktree.Commit(fmt.Sprintf("Add %s worktree to gitignore", issuesDir), &git.CommitOptions{
Author: &object.Signature{
@@ -221,27 +216,18 @@ func checkoutAndTrackRemoteBranch(repo *git.Repository, remoteName, branchName,
},
})
if err != nil {
- fmt.Printf("Warning: Could not commit .gitignore update: %v\n", err)
+ // Could not commit .gitignore update
}
}
}
- fmt.Printf("Git worktree created at ./%s\n", issuesDir)
- fmt.Println("\n✓ Tissue initialized successfully!")
- fmt.Printf("\nTracking '%s' branch from remote '%s'\n", branchName, remoteName)
- fmt.Printf("Issues directory: ./%s\n", issuesDir)
- fmt.Println("\nNext steps:")
- fmt.Println(" - Create your first issue: tissue new")
- fmt.Println(" - List issues: tissue list")
- fmt.Println(" - Check status: tissue status")
+ fmt.Printf("Initialized tissue in %s/\n", issuesDir)
return nil
}
-func initRunE(cmd *cobra.Command, args []string, issuesDir string, issuesBranch string, remoteName string) error {
+func initRunE(cmd *cobra.Command, args []string, issuesDir string, issuesBranch string, remoteName string, forceLocal bool) error {
// Step 1: Open and validate repository
- fmt.Println("Initializing tissue issue tracking...")
-
repo, err := git.PlainOpen(".")
if err != nil {
err = fmt.Errorf("opening repository: %w", err)
@@ -281,21 +267,24 @@ func initRunE(cmd *cobra.Command, args []string, issuesDir string, issuesBranch
// If branch exists but worktree doesn't, we'll set up the worktree later
var skipBranchCreation bool
if localBranchExists && !worktreeExists {
- fmt.Printf("Found existing local '%s' branch, setting up worktree...\n", issuesBranch)
skipBranchCreation = true
}
if !skipBranchCreation {
// Check remote branches
- remotesWithBranch, err := checkRemoteBranches(repo, issuesBranch)
+ remotesWithBranch, failedRemotes, err := checkRemoteBranches(repo, issuesBranch)
if err != nil {
- // Log warning but continue - remotes might be temporarily unavailable
- fmt.Printf("Warning: Could not check remote branches: %v\n", err)
+ // Could not check remote branches - continue anyway
}
remotes, _ := repo.Remotes()
numRemotes := len(remotes)
+ // If we have failed remotes and user didn't use --force-local, fail safe
+ if len(failedRemotes) > 0 && !forceLocal {
+ 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)
+ }
+
// Validate --remote flag if provided
if remoteName != "" {
found := false
@@ -313,7 +302,6 @@ func initRunE(cmd *cobra.Command, args []string, issuesDir string, issuesBranch
// Case 4: Multiple remotes, one has issues branch
if numRemotes > 1 && len(remotesWithBranch) == 1 {
for remote := range remotesWithBranch {
- fmt.Printf("Found existing '%s' branch on remote '%s'\n", issuesBranch, remote)
// Track the existing remote branch
return checkoutAndTrackRemoteBranch(repo, remote, issuesBranch, issuesDir)
}
@@ -331,20 +319,17 @@ func initRunE(cmd *cobra.Command, args []string, issuesDir string, issuesBranch
// If --remote was specified and that remote has the branch, track it
if remoteName != "" && remotesWithBranch[remoteName] {
- fmt.Printf("Found existing '%s' branch on remote '%s'\n", issuesBranch, remoteName)
return checkoutAndTrackRemoteBranch(repo, remoteName, issuesBranch, issuesDir)
}
// Single remote with issues branch
if numRemotes == 1 && len(remotesWithBranch) == 1 {
for remote := range remotesWithBranch {
- fmt.Printf("Found existing '%s' branch on remote '%s'\n", issuesBranch, remote)
return checkoutAndTrackRemoteBranch(repo, remote, issuesBranch, issuesDir)
}
}
// Step 2: Create orphan branch
- fmt.Printf("Creating orphan '%s' branch...\n", issuesBranch)
orphanRef := plumbing.NewSymbolicReference(
plumbing.HEAD,
branchRefName,
@@ -363,37 +348,27 @@ func initRunE(cmd *cobra.Command, args []string, issuesDir string, issuesBranch
}
// Clear the index and working directory for orphan branch
- // This is essential to create a true orphan branch with no parent commits
- fmt.Println("Clearing working directory for orphan branch...")
-
- // Get the status to find all tracked files
+ // git rm -rf . (remove all files from index and working directory)
status, err := worktree.Status()
if err != nil {
err = fmt.Errorf("getting worktree status: %w", err)
return err
}
- // Remove all files from both the index and working directory
for filepath := range status {
_, err := worktree.Remove(filepath)
if err != nil {
// Continue even if some files fail to be removed
- fmt.Printf("Warning: could not remove %s: %v\n", filepath, err)
}
- // Also physically delete the file from disk
os.Remove(filepath)
}
- // Use RemoveGlob as a fallback to ensure everything is cleared
err = worktree.RemoveGlob("*")
if err != nil && err != git.ErrGlobNoMatches {
- fmt.Printf("Warning: RemoveGlob failed: %v\n", err)
+ // RemoveGlob failed, continue anyway
}
- // Now we have a clean slate for the orphan branch
fs := osfs.New(".")
-
- // Create README.md
readmePath := "README.md"
readmeContent := `# Issue Tracker
@@ -457,8 +432,7 @@ Use the tissue CLI to manage issues:
}
gitignoreFile.Close()
- // Stage files
- fmt.Println("Creating initial structure...")
+ // git add README.md .gitignore
_, err = worktree.Add(readmePath)
if err != nil {
err = fmt.Errorf("staging README.md: %w", err)
@@ -471,7 +445,7 @@ Use the tissue CLI to manage issues:
}
// Commit
- commit, err := worktree.Commit(fmt.Sprintf("Initialize %s branch for issue tracking", issuesBranch), &git.CommitOptions{
+ _, err = worktree.Commit(fmt.Sprintf("Initialize %s branch for issue tracking", issuesBranch), &git.CommitOptions{
Author: &object.Signature{
Name: "Tissue CLI",
Email: "tissue@example.com",
@@ -482,13 +456,11 @@ Use the tissue CLI to manage issues:
err = fmt.Errorf("committing initial structure: %w", err)
return err
}
- fmt.Printf("Created initial commit: %s\n", commit.String()[:7])
- // Step 4: Push to remote if appropriate
+ // git push <remote> <branch>
targetRemote := determineTargetRemote(repo, remoteName, numRemotes)
if targetRemote != "" {
- fmt.Printf("Pushing to remote '%s'...\n", targetRemote)
refSpec := fmt.Sprintf("refs/heads/%s:refs/heads/%s", issuesBranch, issuesBranch)
err = repo.Push(&git.PushOptions{
RemoteName: targetRemote,
@@ -497,21 +469,11 @@ Use the tissue CLI to manage issues:
},
})
if err != nil && err != git.NoErrAlreadyUpToDate {
- fmt.Printf("Warning: Could not push to remote: %v\n", err)
- fmt.Printf("You can push manually later with: git push -u %s %s\n", targetRemote, issuesBranch)
- } else if err == nil {
- fmt.Printf("Pushed '%s' branch to remote '%s'\n", issuesBranch, targetRemote)
+ // Push failed, user can push manually
}
- } else if numRemotes > 1 && remoteName == "" {
- // Case 3: Multiple remotes, no issues branch, no --remote flag
- fmt.Println("\nMultiple remotes detected. Not pushing to any remote.")
- fmt.Println("To push to a specific remote, run:")
- fmt.Println(" tissue init --remote <remote-name>")
- fmt.Println("\nOr push manually with git:")
- fmt.Printf(" git push -u <remote-name> %s\n", issuesBranch)
}
- // Step 5: Return to original branch
+ // git checkout <original-branch>
err = worktree.Checkout(&git.CheckoutOptions{
Branch: originalBranch,
})
@@ -519,12 +481,9 @@ Use the tissue CLI to manage issues:
err = fmt.Errorf("returning to original branch: %w", err)
return err
}
- fmt.Printf("Returned to branch: %s\n", originalBranch.Short())
} // End of !skipBranchCreation
- // Step 6: Setup worktree (always done)
- fmt.Printf("Setting up git worktree at %s...\n", issuesDir)
-
+ // git worktree add <dir> <branch>
// TODO: Remove dependency on git CLI when go-git supports worktrees
// Track progress: https://github.com/go-git/go-git/pull/396
worktreeCmd := exec.Command("git", "worktree", "add", issuesDir, issuesBranch)
@@ -532,18 +491,16 @@ Use the tissue CLI to manage issues:
worktreeCmd.Stderr = os.Stderr
err = worktreeCmd.Run()
if err != nil {
- fmt.Printf("Manual worktree setup: git worktree add %s %s\n", issuesDir, issuesBranch)
err = fmt.Errorf("creating worktree: %w", err)
return err
}
- // Add issuesDir/ to .gitignore in main branch (but only if we're not tracking existing branch)
+ // Add issuesDir/ to .gitignore in main branch
if !skipBranchCreation {
worktree, _ := repo.Worktree()
gitignoreMainPath := ".gitignore"
var gitignoreMainContent []byte
- // Check if .gitignore exists
if file, err := os.Open(gitignoreMainPath); err == nil {
defer file.Close()
info, _ := file.Stat()
@@ -551,7 +508,6 @@ Use the tissue CLI to manage issues:
file.Read(gitignoreMainContent)
}
- // Append issuesDir/ if not already present
gitignoreStr := string(gitignoreMainContent)
gitignoreEntry := issuesDir + "/"
if !contains(gitignoreStr, gitignoreEntry) {
@@ -566,11 +522,9 @@ Use the tissue CLI to manage issues:
return err
}
- // Stage and commit .gitignore update
+ // git add .gitignore && git commit -m "..."
_, err = worktree.Add(gitignoreMainPath)
- if err != nil {
- fmt.Printf("Warning: Could not stage .gitignore: %v\n", err)
- } else {
+ if err == nil {
_, err = worktree.Commit(fmt.Sprintf("Add %s worktree to gitignore", issuesDir), &git.CommitOptions{
Author: &object.Signature{
Name: "Tissue CLI",
@@ -578,40 +532,16 @@ Use the tissue CLI to manage issues:
When: time.Now(),
},
})
- if err != nil {
- fmt.Printf("Warning: Could not commit .gitignore update: %v\n", err)
- }
+ // Commit may fail if no changes or other reasons
}
}
}
- fmt.Printf("Git worktree created at ./%s\n", issuesDir)
-
- fmt.Println("\n✓ Tissue initialized successfully!")
- fmt.Printf("\nIssues directory: ./%s\n", issuesDir)
- fmt.Println("\nNext steps:")
- fmt.Println(" - Create your first issue: tissue new")
- fmt.Println(" - List issues: tissue list")
- fmt.Println(" - Check status: tissue status")
+ fmt.Printf("Initialized tissue in %s/\n", issuesDir)
return nil
}
func contains(s, substr string) bool {
- return len(s) > 0 && len(substr) > 0 &&
- (s == substr ||
- len(s) > len(substr) &&
- (s[:len(substr)] == substr ||
- s[len(s)-len(substr):] == substr ||
- len(s) > len(substr)+1 &&
- containsInMiddle(s, substr)))
-}
-
-func containsInMiddle(s, substr string) bool {
- for i := 1; i < len(s)-len(substr); i++ {
- if s[i:i+len(substr)] == substr {
- return true
- }
- }
- return false
+ return strings.Contains(s, substr)
}
go.mod
@@ -15,7 +15,7 @@ require (
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
- github.com/kevinburke/ssh_config v1.2.0 // indirect
+ github.com/kevinburke/ssh_config v1.4.0 // indirect
github.com/pjbgf/sha1cd v0.3.2 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
go.sum
@@ -28,6 +28,8 @@ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOl
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
+github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
+github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=