diff --git a/cmd/rm.go b/cmd/rm.go index a421483..6c6fd84 100644 --- a/cmd/rm.go +++ b/cmd/rm.go @@ -1,16 +1,12 @@ package cmd import ( - "bufio" - "errors" "fmt" - "github.com/barrettj12/jit/common/env" + "github.com/barrettj12/jit/common" + "github.com/barrettj12/jit/common/git" "github.com/barrettj12/jit/common/path" + "github.com/barrettj12/jit/common/types" "github.com/spf13/cobra" - "os" - "strings" - - "github.com/barrettj12/jit/common" ) var removeCmd = &cobra.Command{ @@ -21,142 +17,41 @@ var removeCmd = &cobra.Command{ } func Remove(cmd *cobra.Command, args []string) error { - // TODO: add --force option - branch, err := common.ReqArg(args, 0, "Which branch would you like to remove?") - if err != nil { - return err - } - - split := strings.SplitN(branch, ":", 2) - if len(split) >= 2 { - branch = split[1] - } - - // Delete remote tracking branch - // git push -d - // TODO: not working when upstream is in origin/source repo - - // TODO: this should work if the remote branch doesn't exist (no-op) - //Delete remote tracking branch barrettj12/rm-webster? [y/n]: y - //error: unable to delete 'rm-webster': remote ref does not exist - //error: failed to push some refs to 'https://github.com/barrettj12/interview-questions' - //ERROR: exit status 1 + var remoteBranch types.GitHubBranch + var worktree path.Worktree + var localBranch types.LocalBranch - // TODO: need to be able to handle branches with "/" in the name - // $ jit rm imerge/3.3 - // Delete remote tracking branch barrettj12/imerge? [y/n] - remote, remoteBranch, err := common.PushLoc(branch) - switch err { - case common.ErrUpstreamNotFound: - // no-op - fmt.Printf("no remote tracking branch found for branch %q\n", branch) - - case nil: - ok, err := confirm(fmt.Sprintf("Delete remote tracking branch %s/%s", remote, remoteBranch)) - if err != nil { - return err - } - if ok { - err = common.Git("push", "-d", remote, remoteBranch) - if err != nil { - return err - } - } - - default: // non-nil error - return err - } + deleteRemoteBranch(remoteBranch) + deleteWorktree(worktree) + deleteLocalBranch(localBranch) +} - // Delete worktree - wktreePath, err := common.WorktreePath(branch) +func deleteRemoteBranch(branch types.GitHubBranch) error { + remote, err := common.GitRemoteFromURL(branch.RepoURL) if err != nil { - return err - } - // TODO: worktrees can be removed but the worktree still exists in Git. - // Check if it exists in `git worktrees list`, rather than doing a stat. - _, err = os.Stat(wktreePath) - if errors.Is(err, os.ErrNotExist) { - fmt.Printf("no worktree found at %s\n", wktreePath) - } else if err != nil { - return err - } else { - ok, err := confirm(fmt.Sprintf("Delete worktree at %s", wktreePath)) - if err != nil { - return err - } - if ok { - err = common.Git("worktree", "remove", wktreePath) - if err != nil { - // Usually the error is "worktree contains modified or untracked files" - // so print these files for the user to see. - - // TODO: the err could be - // fatal: is not a working tree - // in which case we need to just skip to the branch removal step - - untrackedFiles, _ := common.ExecGit(path.Worktree(wktreePath), "status", "--porcelain", "--ignore-submodules=none") - fmt.Println(untrackedFiles) - - force, err := confirm("Worktree deletion failed, try again with --force") - if err != nil { - return err - } - if force { - err = common.Git("worktree", "remove", wktreePath, "--force") - if err != nil { - return err - } - } - } - err = os.RemoveAll(wktreePath) - if err != nil { - return err - } - } + fmt.Printf("WARNING: couldn't find remote matching %q: %v\n", branch.RepoURL.Owner(), err) + fmt.Printf("assuming remote name is %q\n", branch.RepoURL.Owner()) + remote = types.RemoteName(branch.RepoURL.Owner()) } - // Fetch merged branch, so that we don't get the error message - // error: The branch ____ is not fully merged. - // TODO: get the remote/branch from gh pr view - _ = common.Fetch("", "") + // TODO: lookup local branch that's tracking the given remote + localBranch := types.LocalBranch(branch.Branch) - // Delete local branch - // git branch -d - ok, err := confirm(fmt.Sprintf("Delete local branch %q", branch)) + err = git.Push(git.PushArgs{ + Branch: localBranch, + Remote: remote, + Delete: true, + }) if err != nil { - return err - } - if ok { - err = common.Git("branch", "-d", branch) - if err != nil { - fmt.Printf("ERROR: %v\n", err) - force, err := confirm("Branch deletion failed, try again with force") - if err != nil { - return err - } - if force { - err = common.Git("branch", "-D", branch) - if err != nil { - return err - } - } - } + return fmt.Errorf("deleting remote branch %q: %w", branch, err) } - return nil } -// TODO: move this to common -func confirm(prompt string) (bool, error) { - if env.NonInteractive() { - panic("internal error: common.Prompt called with JIT_NONINTERACTIVE enabled") - } +func deleteWorktree(worktree path.Worktree) { + +} + +func deleteLocalBranch(branch types.LocalBranch) { - sc := bufio.NewScanner(os.Stdin) - fmt.Printf("%s? [y/n]: ", prompt) - sc.Scan() - if err := sc.Err(); err != nil { - return false, fmt.Errorf("error reading input: %w", err) - } - return sc.Text() == "y", nil } diff --git a/cmd/rm_v1.go b/cmd/rm_v1.go new file mode 100644 index 0000000..b5364d9 --- /dev/null +++ b/cmd/rm_v1.go @@ -0,0 +1,155 @@ +package cmd + +import ( + "bufio" + "errors" + "fmt" + "github.com/barrettj12/jit/common/env" + "github.com/barrettj12/jit/common/path" + "github.com/spf13/cobra" + "os" + "strings" + + "github.com/barrettj12/jit/common" +) + +func RemoveV1(cmd *cobra.Command, args []string) error { + // TODO: add --force option + branch, err := common.ReqArg(args, 0, "Which branch would you like to remove?") + if err != nil { + return err + } + + split := strings.SplitN(branch, ":", 2) + if len(split) >= 2 { + branch = split[1] + } + + // Delete remote tracking branch + // git push -d + // TODO: not working when upstream is in origin/source repo + + // TODO: this should work if the remote branch doesn't exist (no-op) + //Delete remote tracking branch barrettj12/rm-webster? [y/n]: y + //error: unable to delete 'rm-webster': remote ref does not exist + //error: failed to push some refs to 'https://github.com/barrettj12/interview-questions' + //ERROR: exit status 1 + + // TODO: need to be able to handle branches with "/" in the name + // $ jit rm imerge/3.3 + // Delete remote tracking branch barrettj12/imerge? [y/n] + remote, remoteBranch, err := common.PushLoc(branch) + switch err { + case common.ErrUpstreamNotFound: + // no-op + fmt.Printf("no remote tracking branch found for branch %q\n", branch) + + case nil: + ok, err := confirm(fmt.Sprintf("Delete remote tracking branch %s/%s", remote, remoteBranch)) + if err != nil { + return err + } + if ok { + err = common.Git("push", "-d", remote, remoteBranch) + if err != nil { + return err + } + } + + default: // non-nil error + return err + } + + // Delete worktree + wktreePath, err := common.WorktreePath(branch) + if err != nil { + return err + } + // TODO: worktrees can be removed but the worktree still exists in Git. + // Check if it exists in `git worktrees list`, rather than doing a stat. + _, err = os.Stat(wktreePath) + if errors.Is(err, os.ErrNotExist) { + fmt.Printf("no worktree found at %s\n", wktreePath) + } else if err != nil { + return err + } else { + ok, err := confirm(fmt.Sprintf("Delete worktree at %s", wktreePath)) + if err != nil { + return err + } + if ok { + err = common.Git("worktree", "remove", wktreePath) + if err != nil { + // Usually the error is "worktree contains modified or untracked files" + // so print these files for the user to see. + + // TODO: the err could be + // fatal: is not a working tree + // in which case we need to just skip to the branch removal step + + untrackedFiles, _ := common.ExecGit(path.Worktree(wktreePath), "status", "--porcelain", "--ignore-submodules=none") + fmt.Println(untrackedFiles) + + force, err := confirm("Worktree deletion failed, try again with --force") + if err != nil { + return err + } + if force { + err = common.Git("worktree", "remove", wktreePath, "--force") + if err != nil { + return err + } + } + } + err = os.RemoveAll(wktreePath) + if err != nil { + return err + } + } + } + + // Fetch merged branch, so that we don't get the error message + // error: The branch ____ is not fully merged. + // TODO: get the remote/branch from gh pr view + _ = common.Fetch("", "") + + // Delete local branch + // git branch -d + ok, err := confirm(fmt.Sprintf("Delete local branch %q", branch)) + if err != nil { + return err + } + if ok { + err = common.Git("branch", "-d", branch) + if err != nil { + fmt.Printf("ERROR: %v\n", err) + force, err := confirm("Branch deletion failed, try again with force") + if err != nil { + return err + } + if force { + err = common.Git("branch", "-D", branch) + if err != nil { + return err + } + } + } + } + + return nil +} + +// TODO: move this to common +func confirm(prompt string) (bool, error) { + if env.NonInteractive() { + panic("internal error: common.Prompt called with JIT_NONINTERACTIVE enabled") + } + + sc := bufio.NewScanner(os.Stdin) + fmt.Printf("%s? [y/n]: ", prompt) + sc.Scan() + if err := sc.Err(); err != nil { + return false, fmt.Errorf("error reading input: %w", err) + } + return sc.Text() == "y", nil +} diff --git a/cmd/rm_v2/rm.go b/cmd/rm_v2/rm.go deleted file mode 100644 index 6239da1..0000000 --- a/cmd/rm_v2/rm.go +++ /dev/null @@ -1,66 +0,0 @@ -package rm_v2 - -import ( - "fmt" - "github.com/barrettj12/jit/common/path" - "strings" - - "github.com/barrettj12/jit/common" -) - -func RemoveV2(args []string) error { - branch, err := common.ReqArg(args, 0, "Which branch would you like to remove?") - if err != nil { - return err - } - - localBranch, err := resolveBranch(branch) - if err != nil { - return err - } - - deleteUpstreamBranch(localBranch) - deleteWorktree(localBranch) - deleteLocalBranch(localBranch) - return nil -} - -// This is based on cmd/gitProvider.ResolveBranch, but with subtle differences -// (it only considers local branches and doesn't try to fetch). -func resolveBranch(branch string) (string, error) { - // Try to resolve as a local branch - _, err := common.ExecGit(path.CurrentDir, "rev-parse", branch) - if err == nil { - return branch, nil - } - - // Try to resolve remote:branch or remote/branch - split := strings.SplitN(branch, ":", 2) - if len(split) < 2 { - split = strings.SplitN(branch, "/", 2) - if len(split) < 2 { - // Can't parse this branch ref - return "", fmt.Errorf("branch not found") - } - } - - remoteBranch := split[1] - _, err = common.ExecGit(path.CurrentDir, "rev-parse", remoteBranch) - if err == nil { - return remoteBranch, nil - } - - return "", fmt.Errorf("branch not found") -} - -func deleteUpstreamBranch(branch string) { - -} - -func deleteWorktree(branch string) { - -} - -func deleteLocalBranch(branch string) { - -} diff --git a/common/git/branch.go b/common/git/branch.go index 7b54618..a41d8e1 100644 --- a/common/git/branch.go +++ b/common/git/branch.go @@ -64,6 +64,7 @@ type PushArgs struct { Branch types.LocalBranch // local branch to push Remote types.RemoteName // remote to push to SetUpstream bool // should the upstream be set on a successful push + Delete bool // } func Push(opts PushArgs) error { @@ -71,6 +72,9 @@ func Push(opts PushArgs) error { if opts.SetUpstream { args = append(args, "-u") } + if opts.Delete { + args = append(args, "-d") + } if opts.Remote != "" { args = append(args, string(opts.Remote)) } diff --git a/common/git/branch_test.go b/common/git/branch_test.go index 3d24424..c673ad6 100644 --- a/common/git/branch_test.go +++ b/common/git/branch_test.go @@ -8,17 +8,12 @@ import ( ) func TestPull(t *testing.T) { - // Patch internal exec function var expected internalExecArgs - realInternalExec := internalExec - internalExec = func(opts internalExecArgs) (string, error) { + patchInternalExec(t, func(opts internalExecArgs) (string, error) { if !reflect.DeepEqual(opts, expected) { t.Fatalf("incorrect args %#v\n", opts) } return "", nil - } - t.Cleanup(func() { - internalExec = realInternalExec }) tests := []struct { @@ -56,3 +51,11 @@ func TestPull(t *testing.T) { _ = Pull(test.pullArgs) } } + +func patchInternalExec(t *testing.T, f func(opts internalExecArgs) (string, error)) { + realInternalExec := internalExec + internalExec = f + t.Cleanup(func() { + internalExec = realInternalExec + }) +} diff --git a/common/git/remote.go b/common/git/remote.go index 275f40f..d467797 100644 --- a/common/git/remote.go +++ b/common/git/remote.go @@ -4,6 +4,7 @@ import ( "github.com/barrettj12/jit/common/path" "github.com/barrettj12/jit/common/types" "github.com/barrettj12/jit/common/url" + "strings" ) func RemoteExists(dir path.Dir, remote types.RemoteName) (bool, error) { @@ -34,3 +35,39 @@ func AddRemote(name types.RemoteName, url url.RemoteRepo) error { }) return err } + +type RemoteInfo struct { + Name types.RemoteName + FetchURL url.GitHubRepo + PushURL url.GitHubRepo +} + +func ListRemotes() (map[string]*RemoteInfo, error) { + out, err := internalExec(internalExecArgs{ + args: []string{"remote", "-v"}, + }) + if err != nil { + return nil, err + } + + lines := strings.Split(strings.TrimSpace(out), "\n") + remotes := map[string]*RemoteInfo{} + for _, line := range lines { + split := strings.Split(line, "\t") + name := split[0] + if _, ok := remotes[name]; !ok { + remotes[name] = &RemoteInfo{ + Name: types.RemoteName(name), + } + } + + urlInfo := split[1] + if fetchURL, ok := strings.CutSuffix(urlInfo, " (fetch)"); ok { + remotes[name].FetchURL = url.GitHubRepo(fetchURL) + } + if pushURL, ok := strings.CutSuffix(urlInfo, " (push)"); ok { + remotes[name].PushURL = url.GitHubRepo(pushURL) + } + } + return remotes, nil +} diff --git a/common/git/remote_test.go b/common/git/remote_test.go new file mode 100644 index 0000000..54d2636 --- /dev/null +++ b/common/git/remote_test.go @@ -0,0 +1,70 @@ +package git + +import ( + "github.com/barrettj12/jit/common/testutil" + "testing" +) + +func TestListRemotes(t *testing.T) { + patchInternalExec(t, func(opts internalExecArgs) (string, error) { + return remotes, nil + }) + + remoteInfo, err := ListRemotes() + testutil.CheckErr(t, err) + testutil.AssertDeepEqual(t, remoteInfo, map[string]*RemoteInfo{ + "SimonRichardson": { + Name: "SimonRichardson", + FetchURL: "https://github.com/SimonRichardson/juju", + PushURL: "https://github.com/SimonRichardson/juju", + }, + "barrettj12": { + Name: "barrettj12", + FetchURL: "https://github.com/barrettj12/juju", + PushURL: "https://github.com/barrettj12/juju", + }, + "cderici": { + Name: "cderici", + FetchURL: "https://github.com/cderici/juju", + PushURL: "https://github.com/cderici/juju", + }, + "juju": { + Name: "juju", + FetchURL: "https://github.com/juju/juju", + PushURL: "https://github.com/juju/juju", + }, + "tlm": { + Name: "tlm", + FetchURL: "https://github.com/tlm/juju", + PushURL: "https://github.com/tlm/juju", + }, + "upstream": { + Name: "upstream", + FetchURL: "https://github.com/juju/juju", + PushURL: "https://github.com/juju/juju", + }, + "wallyworld": { + Name: "wallyworld", + FetchURL: "https://github.com/wallyworld/juju", + PushURL: "https://github.com/wallyworld/juju", + }, + }) + +} + +var remotes = ` +SimonRichardson https://github.com/SimonRichardson/juju (fetch) +SimonRichardson https://github.com/SimonRichardson/juju (push) +barrettj12 https://github.com/barrettj12/juju (fetch) +barrettj12 https://github.com/barrettj12/juju (push) +cderici https://github.com/cderici/juju (fetch) +cderici https://github.com/cderici/juju (push) +juju https://github.com/juju/juju (fetch) +juju https://github.com/juju/juju (push) +tlm https://github.com/tlm/juju (fetch) +tlm https://github.com/tlm/juju (push) +upstream https://github.com/juju/juju (fetch) +upstream https://github.com/juju/juju (push) +wallyworld https://github.com/wallyworld/juju (fetch) +wallyworld https://github.com/wallyworld/juju (push) +`[1:] diff --git a/common/remote.go b/common/remote.go new file mode 100644 index 0000000..0fd0de2 --- /dev/null +++ b/common/remote.go @@ -0,0 +1,23 @@ +package common + +import ( + "fmt" + "github.com/barrettj12/jit/common/git" + "github.com/barrettj12/jit/common/types" + "github.com/barrettj12/jit/common/url" +) + +// GitRemoteFromURL finds the name of the Git remote which has the given URL. +func GitRemoteFromURL(repo url.GitHubRepo) (types.RemoteName, error) { + remotes, err := git.ListRemotes() + if err != nil { + return "", fmt.Errorf("getting remotes: %v", err) + } + + for _, remote := range remotes { + if remote.PushURL.Owner() == repo.Owner() { + return remote.Name, nil + } + } + return "", fmt.Errorf("no remote found matching %q", repo) +} diff --git a/common/testutil/util.go b/common/testutil/util.go index b730f9a..69c47dd 100644 --- a/common/testutil/util.go +++ b/common/testutil/util.go @@ -2,6 +2,7 @@ package testutil import ( "os/exec" + "reflect" "strings" "testing" ) @@ -36,3 +37,9 @@ func AssertNotEqual[T comparable](t *testing.T, obtained, expected T) { t.Fatalf("obtained and expected are equal = %#v", expected) } } + +func AssertDeepEqual[T any](t *testing.T, obtained, expected T) { + if !reflect.DeepEqual(obtained, expected) { + t.Fatalf("value not as expected: %#v\n", obtained) + } +}