diff --git a/README.md b/README.md index 102b7de..c566f17 100644 --- a/README.md +++ b/README.md @@ -57,14 +57,26 @@ branch name. The base branch defaults to the first remote's default branch — e.g. on a typical clone, `origin/main`, but `tsk` follows whatever the repo is actually configured with (`upstream/master` works just the same). Override it with -`--base /` on `tsk add` (or `tsk create` when using `-a`): +`--base ` on `tsk add` (or `tsk create` when using `-a`): ```sh -# Base the new worktrees off origin/develop instead of the default. +# Remote-tracking base: fetched first, then branched from. tsk add --base origin/develop ../../gobl.html -The full `/` form is required so it's never ambiguous whether -you mean a local branch or a remote-tracking one. +# Local base: a branch that lives only in your clone. +tsk add --base my-wip-branch ../../gobl.html +``` + +`--base` accepts either form. A value shaped like `/` whose +first component names a configured remote is treated as remote-tracking and +fetched before branching; anything else must be an existing local branch, which +needs no fetch. So on a normal clone `origin/main` is remote and `my-wip-branch` +is local, and in the rare case where you have a local branch literally named +`origin/main`, the remote still wins. + +Either way `tsk add` **creates a new branch** — `--base` only decides where that +branch starts. The base itself is never checked out, so it can safely be a +branch that is already checked out in another worktree. When `--base` helps: @@ -73,6 +85,10 @@ When `--base` helps: off their feature branch keeps your diff focused on your own work instead of dragging in theirs, and avoids the "merge their branch into mine, then rebase later" dance. +- **Splitting one piece of work across two tasks.** A migration in task A + unblocks a feature in task B, but you want them as separate PRs and separate + Linear issues. Give task B `--base ` and it starts from the + migration, sees that code, and lands as a PR stacked on A's. - **Long-lived integration branches.** When several tasks land into a shared `develop` (or similar) before promotion, base new worktrees there so each task starts from the state the integration branch is actually in. @@ -107,9 +123,9 @@ want to discard. ## Commands ``` -tsk create [--base /] [] [-a ...] +tsk create [--base ] [] [-a ...] Create a task directory in cwd -tsk add [--base /] [-b ] [--as ] [...] +tsk add [--base ] [-b ] [--as ] [...] Add worktrees to the current task tsk status git status summary across all worktrees tsk rm [-f] Remove one worktree from the current task diff --git a/main.go b/main.go index 4e849b7..f2febaa 100644 --- a/main.go +++ b/main.go @@ -72,9 +72,9 @@ func usage(w *os.File) { fmt.Fprint(w, `tsk — multi-repo task workspaces usage: - tsk create [--base /] [] [-a ...] + tsk create [--base ] [] [-a ...] Create a task directory in cwd - tsk add [--base /] [-b ] [--as ] [ ...] + tsk add [--base ] [-b ] [--as ] [ ...] Add worktrees to the current task tsk status git status summary across all worktrees tsk rm [-f] Remove one worktree from the current task @@ -101,7 +101,7 @@ func cmdCreate(args []string) error { flags := flag.NewFlagSet("create", flag.ContinueOnError) flags.SetOutput(os.Stderr) - base := flags.String("base", "", "remote-tracking branch to base new branches on, e.g. origin/main (defaults to the first remote's default branch)") + base := flags.String("base", "", "branch to base new branches on: / or a local branch name (defaults to the first remote's default branch)") if err := flags.Parse(mainArgs); err != nil { return err } @@ -114,7 +114,7 @@ func cmdCreate(args []string) error { case 2: ref, slug = rest[0], rest[1] default: - return errors.New("usage: tsk create [--base /] [] [-a ...]") + return errors.New("usage: tsk create [--base ] [] [-a ...]") } if ref != "" && !validSlug(ref) { @@ -169,14 +169,14 @@ func cmdAdd(args []string) error { flags := flag.NewFlagSet("add", flag.ContinueOnError) flags.SetOutput(os.Stderr) branch := flags.String("b", "", "branch name to create (defaults to task slug)") - base := flags.String("base", "", "remote-tracking branch to base the new branch on, e.g. origin/main (defaults to the first remote's default branch)") + base := flags.String("base", "", "branch to base the new branch on: / or a local branch name (defaults to the first remote's default branch)") asName := flags.String("as", "", "worktree directory name within the task (defaults to the repo's base name); lets the same repo be added more than once") if err := flags.Parse(args); err != nil { return err } repos := flags.Args() if len(repos) == 0 { - return errors.New("usage: tsk add [--base /] [-b ] [--as ] [ ...]") + return errors.New("usage: tsk add [--base ] [-b ] [--as ] [ ...]") } if *asName != "" { if len(repos) > 1 { @@ -222,20 +222,6 @@ func addOne(taskRoot, repoPath, branch, base, dirName string) error { return fmt.Errorf("not a git repo: %s", src) } - var baseRemote, baseBranch string - if base == "" { - baseRemote, baseBranch, err = defaultBase(src) - if err != nil { - return fmt.Errorf("determining default base branch: %w", err) - } - } else { - var ok bool - baseRemote, baseBranch, ok = parseRemoteBranch(base) - if !ok { - return fmt.Errorf("invalid --base %q (expected /, e.g. origin/main)", base) - } - } - name := filepath.Base(src) if dirName != "" { name = dirName @@ -247,26 +233,34 @@ func addOne(taskRoot, repoPath, branch, base, dirName string) error { return err } - fmt.Printf("fetching %s/%s for %s...\n", baseRemote, baseBranch, name) - if _, err := runGit(src, "fetch", baseRemote, baseBranch); err != nil { - return fmt.Errorf("fetch: %w", err) + branchExists, err := gitBranchExists(src, branch) + if err != nil { + return err + } + + if branchExists { + return fmt.Errorf("branch %q already exists in source repo (pass -b to pick another)", branch) } - exists, err := gitBranchExists(src, branch) + ref, err := resolveBase(src, base) if err != nil { return err } - if exists { - return fmt.Errorf("branch %q already exists in source repo (pass -b to pick another)", branch) + + if ref.remote != "" { + fmt.Printf("fetching %s for %s...\n", ref, name) + if _, err := runGit(src, "fetch", ref.remote, ref.branch); err != nil { + return fmt.Errorf("fetch: %w", err) + } } - fmt.Printf("creating worktree %s [%s]...\n", name, branch) + fmt.Printf("creating worktree %s [%s] from %s...\n", name, branch, ref) // `-c branch.autoSetupMerge=false` keeps the new branch from inheriting // the base branch as its upstream — we want "never pushed" to remain // detectable until the user actually pushes it. if _, err := runGit(src, "-c", "branch.autoSetupMerge=false", - "worktree", "add", "-b", branch, dest, baseRemote+"/"+baseBranch, + "worktree", "add", "-b", branch, dest, ref.startPoint(), ); err != nil { return err } @@ -622,6 +616,77 @@ func defaultBase(repo string) (remote, branch string, err error) { return "", "", fmt.Errorf("could not determine default branch of remote %q in %s", remote, repo) } +// baseRef is a resolved start point for a new branch: a remote-tracking branch +// when remote is non-empty (and so must be fetched first), or a local branch +// when it is empty. +type baseRef struct { + remote string + branch string +} + +// String renders the ref the way a user would write it on the command line. +func (b baseRef) String() string { + if b.remote == "" { + return b.branch + } + return b.remote + "/" + b.branch +} + +// startPoint renders the ref fully qualified for git. The short form is not +// enough: a local branch literally named "origin/x" makes "origin/x" ambiguous +// and git refuses to resolve it at all. +func (b baseRef) startPoint() string { + if b.remote == "" { + return "refs/heads/" + b.branch + } + return "refs/remotes/" + b.remote + "/" + b.branch +} + +// resolveBase turns a --base value into a start point for the new branch. An +// empty value falls back to the first remote's default HEAD. A / +// value whose first component names a configured remote resolves to that +// remote-tracking branch; anything else must be an existing local branch. +func resolveBase(repo, base string) (baseRef, error) { + if base == "" { + remote, branch, err := defaultBase(repo) + if err != nil { + return baseRef{}, fmt.Errorf("determining default base branch: %w", err) + } + return baseRef{remote: remote, branch: branch}, nil + } + if remote, branch, ok := parseRemoteBranch(base); ok { + known, err := remoteExists(repo, remote) + if err != nil { + return baseRef{}, err + } + if known { + return baseRef{remote: remote, branch: branch}, nil + } + } + exists, err := gitBranchExists(repo, base) + if err != nil { + return baseRef{}, err + } + if !exists { + return baseRef{}, fmt.Errorf("invalid --base %q (not a local branch, and not / for a configured remote)", base) + } + return baseRef{branch: base}, nil +} + +// remoteExists reports whether name is one of the remotes configured in repo. +func remoteExists(repo, name string) (bool, error) { + out, err := runGit(repo, "remote") + if err != nil { + return false, err + } + for _, r := range strings.Split(out, "\n") { + if strings.TrimSpace(r) == name { + return true, nil + } + } + return false, nil +} + func gitBranchExists(repo, branch string) (bool, error) { _, err := runGit(repo, "rev-parse", "--verify", "--quiet", "refs/heads/"+branch) if err == nil { diff --git a/main_test.go b/main_test.go index 48878ee..dcd2648 100644 --- a/main_test.go +++ b/main_test.go @@ -486,9 +486,22 @@ func TestCmdAdd_BaseBranch(t *testing.T) { } } -func TestCmdAdd_BaseRejectsMissingSlash(t *testing.T) { +// TestCmdAdd_LocalBase covers the stacked-task case: a branch that exists only +// locally (never pushed) and is already checked out in another worktree is +// still usable as a base, and no fetch is needed to reach it. +func TestCmdAdd_LocalBase(t *testing.T) { _, src := makeRepoPair(t) + // `wip` is local-only: committed but never pushed, so it is unreachable + // through any remote-tracking ref. + mustRunGit(t, src, "checkout", "-b", "wip") + if err := os.WriteFile(filepath.Join(src, "WIP"), []byte("wip\n"), 0o644); err != nil { + t.Fatal(err) + } + mustRunGit(t, src, "add", ".") + mustRunGit(t, src, "commit", "-m", "wip commit") + mustRunGit(t, src, "checkout", "main") + tasks := t.TempDir() runIn(t, tasks, func() { if err := cmdCreate([]string{"feat"}); err != nil { @@ -497,14 +510,190 @@ func TestCmdAdd_BaseRejectsMissingSlash(t *testing.T) { }) taskDir := filepath.Join(tasks, "feat") runIn(t, taskDir, func() { - err := cmdAdd([]string{"--base", "main", src}) + if err := cmdAdd([]string{"--base", "wip", src}); err != nil { + t.Fatal(err) + } + }) + + wt := filepath.Join(taskDir, filepath.Base(src)) + if _, err := os.Stat(filepath.Join(wt, "WIP")); err != nil { + t.Errorf("expected WIP file (from local wip branch) in worktree: %v", err) + } + br, _ := runGit(wt, "branch", "--show-current") + if br != "feat" { + t.Errorf("branch = %q, want feat", br) + } + // The base must not become the upstream, or close's "never pushed" check breaks. + if up, _ := runGit(wt, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"); up != "" { + t.Errorf("upstream = %q, want none", up) + } +} + +// TestCmdAdd_LocalBaseCheckedOutElsewhere proves a base branch can be shared by +// two tasks: git refuses to check the same branch out twice, but using it as a +// start point is fine. +func TestCmdAdd_LocalBaseCheckedOutElsewhere(t *testing.T) { + _, src := makeRepoPair(t) + + tasks := t.TempDir() + runIn(t, tasks, func() { + if err := cmdCreate([]string{"first"}); err != nil { + t.Fatal(err) + } + if err := cmdCreate([]string{"second"}); err != nil { + t.Fatal(err) + } + }) + + firstDir := filepath.Join(tasks, "first") + runIn(t, firstDir, func() { + if err := cmdAdd([]string{src}); err != nil { + t.Fatal(err) + } + }) + firstWT := filepath.Join(firstDir, filepath.Base(src)) + if err := os.WriteFile(filepath.Join(firstWT, "MIGRATION"), []byte("x\n"), 0o644); err != nil { + t.Fatal(err) + } + mustRunGit(t, firstWT, "add", ".") + mustRunGit(t, firstWT, "commit", "-m", "migration") + + secondDir := filepath.Join(tasks, "second") + runIn(t, secondDir, func() { + if err := cmdAdd([]string{"--base", "first", src}); err != nil { + t.Fatalf("basing on a branch checked out in another worktree: %v", err) + } + }) + + secondWT := filepath.Join(secondDir, filepath.Base(src)) + if _, err := os.Stat(filepath.Join(secondWT, "MIGRATION")); err != nil { + t.Errorf("expected MIGRATION (from the first task's branch) in second worktree: %v", err) + } + br, _ := runGit(secondWT, "branch", "--show-current") + if br != "second" { + t.Errorf("branch = %q, want second", br) + } +} + +// TestCmdAdd_BasePrefersRemote pins the precedence rule: when a local branch is +// literally named "origin/x" and remote "origin" also has an "x", the remote +// wins. +func TestCmdAdd_BasePrefersRemote(t *testing.T) { + _, src := makeRepoPair(t) + + mustRunGit(t, src, "checkout", "-b", "shared") + if err := os.WriteFile(filepath.Join(src, "REMOTE"), []byte("remote\n"), 0o644); err != nil { + t.Fatal(err) + } + mustRunGit(t, src, "add", ".") + mustRunGit(t, src, "commit", "-m", "remote side") + mustRunGit(t, src, "push", "-u", "origin", "shared") + mustRunGit(t, src, "checkout", "main") + mustRunGit(t, src, "branch", "-D", "shared") + + // A local branch whose name collides with the remote-tracking spelling. + mustRunGit(t, src, "checkout", "-b", "origin/shared", "main") + if err := os.WriteFile(filepath.Join(src, "LOCAL"), []byte("local\n"), 0o644); err != nil { + t.Fatal(err) + } + mustRunGit(t, src, "add", ".") + mustRunGit(t, src, "commit", "-m", "local side") + mustRunGit(t, src, "checkout", "main") + + tasks := t.TempDir() + runIn(t, tasks, func() { + if err := cmdCreate([]string{"feat"}); err != nil { + t.Fatal(err) + } + }) + taskDir := filepath.Join(tasks, "feat") + runIn(t, taskDir, func() { + if err := cmdAdd([]string{"--base", "origin/shared", src}); err != nil { + t.Fatal(err) + } + }) + + wt := filepath.Join(taskDir, filepath.Base(src)) + if _, err := os.Stat(filepath.Join(wt, "REMOTE")); err != nil { + t.Errorf("expected REMOTE file: remote-tracking base should win, got: %v", err) + } + if _, err := os.Stat(filepath.Join(wt, "LOCAL")); err == nil { + t.Error("got LOCAL file: local branch named origin/shared should not win") + } +} + +func TestCmdAdd_BaseRejectsUnknown(t *testing.T) { + _, src := makeRepoPair(t) + + tasks := t.TempDir() + runIn(t, tasks, func() { + if err := cmdCreate([]string{"feat"}); err != nil { + t.Fatal(err) + } + }) + taskDir := filepath.Join(tasks, "feat") + runIn(t, taskDir, func() { + err := cmdAdd([]string{"--base", "nope", src}) if err == nil { - t.Fatal("expected error: --base main is missing a remote prefix") + t.Fatal("expected error: --base nope is neither a local branch nor /") } if !strings.Contains(err.Error(), "/") { - t.Errorf("error should explain expected format, got: %v", err) + t.Errorf("error should explain the accepted forms, got: %v", err) } }) + + // An unknown remote prefix is rejected too, rather than silently treated + // as a local branch name. + runIn(t, taskDir, func() { + if err := cmdAdd([]string{"--base", "nosuchremote/main", src}); err == nil { + t.Fatal("expected error: nosuchremote is not a configured remote") + } + }) +} + +func TestResolveBase(t *testing.T) { + _, src := makeRepoPair(t) + mustRunGit(t, src, "branch", "local-only") + + cases := []struct { + in string + wantRemote string + wantBranch string + wantErr bool + }{ + {"", "origin", "main", false}, + {"origin/main", "origin", "main", false}, + {"local-only", "", "local-only", false}, + {"main", "", "main", false}, + {"nope", "", "", true}, + {"nosuchremote/main", "", "", true}, + } + for _, c := range cases { + got, err := resolveBase(src, c.in) + if c.wantErr { + if err == nil { + t.Errorf("resolveBase(%q) = %+v, want error", c.in, got) + } + continue + } + if err != nil { + t.Errorf("resolveBase(%q): %v", c.in, err) + continue + } + if got.remote != c.wantRemote || got.branch != c.wantBranch { + t.Errorf("resolveBase(%q) = {%q %q}, want {%q %q}", + c.in, got.remote, got.branch, c.wantRemote, c.wantBranch) + } + } +} + +func TestBaseRefString(t *testing.T) { + if got := (baseRef{remote: "origin", branch: "main"}).String(); got != "origin/main" { + t.Errorf("String() = %q, want origin/main", got) + } + if got := (baseRef{branch: "wip"}).String(); got != "wip" { + t.Errorf("String() = %q, want wip", got) + } } // TestCmdAdd_DefaultBaseUsesFirstRemote builds a repo whose only remote is