From 16ff85b0ba459e9281d32a62ad9c54e041ee8f7f Mon Sep 17 00:00:00 2001 From: Miguel Torres Date: Sat, 18 Jul 2026 16:24:10 +0000 Subject: [PATCH 1/2] Add --existing flag to check out an existing branch in tsk add Co-Authored-By: Claude Opus 4.8 --- README.md | 16 +++++++++++- main.go | 70 ++++++++++++++++++++++++++++++++++------------------ main_test.go | 57 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 102b7de..1a7de40 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,20 @@ tsk add ../../gov-fr # → gov-fr [] tsk add --as gov-fr-hotfix -b hotfix ../../gov-fr # → gov-fr-hotfix [hotfix] ``` +## Checking out an existing branch + +By default `tsk add` *creates* a new branch (from `-b` or the task slug) off the +base branch. To instead check out a branch that already exists in the repo — for +example to review a colleague's branch, or to give an in-progress branch its own +worktree — use `--existing `: + +```sh +tsk add --existing peppol-pint-addon --as gobl-pint ../../gobl +``` + +`--existing` skips the base fetch (no base is needed), errors if the branch is +missing, and is mutually exclusive with `-b`. + `--as` names a single directory, so it can't be combined with multiple repo paths in one `tsk add`. @@ -109,7 +123,7 @@ want to discard. ``` tsk create [--base /] [] [-a ...] Create a task directory in cwd -tsk add [--base /] [-b ] [--as ] [...] +tsk add [--base /] [-b | --existing ] [--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..7c1ef17 100644 --- a/main.go +++ b/main.go @@ -74,7 +74,7 @@ func usage(w *os.File) { usage: tsk create [--base /] [] [-a ...] Create a task directory in cwd - tsk add [--base /] [-b ] [--as ] [ ...] + tsk add [--base /] [-b | --existing ] [--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 @@ -155,7 +155,7 @@ func cmdCreate(args []string) error { fmt.Println(taskDir) for _, p := range addPaths { - if err := addOne(taskDir, p, slug, *base, ""); err != nil { + if err := addOne(taskDir, p, slug, *base, "", false); err != nil { return fmt.Errorf("%s: %w", p, err) } } @@ -169,6 +169,7 @@ 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)") + existing := flags.String("existing", "", "check out an existing branch instead of creating one (mutually exclusive with -b)") 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)") 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 { @@ -176,7 +177,10 @@ func cmdAdd(args []string) error { } repos := flags.Args() if len(repos) == 0 { - return errors.New("usage: tsk add [--base /] [-b ] [--as ] [ ...]") + return errors.New("usage: tsk add [--base /] [-b | --existing ] [--as ] [ ...]") + } + if *existing != "" && *branch != "" { + return errors.New("-b and --existing are mutually exclusive") } if *asName != "" { if len(repos) > 1 { @@ -204,16 +208,19 @@ func cmdAdd(args []string) error { if chosenBranch == "" { chosenBranch = t.Slug } + if *existing != "" { + chosenBranch = *existing + } for _, p := range repos { - if err := addOne(taskRoot, p, chosenBranch, *base, *asName); err != nil { + if err := addOne(taskRoot, p, chosenBranch, *base, *asName, *existing != ""); err != nil { return fmt.Errorf("%s: %w", p, err) } } return nil } -func addOne(taskRoot, repoPath, branch, base, dirName string) error { +func addOne(taskRoot, repoPath, branch, base, dirName string, existing bool) error { src, err := filepath.Abs(repoPath) if err != nil { return err @@ -222,6 +229,40 @@ func addOne(taskRoot, repoPath, branch, base, dirName string) error { return fmt.Errorf("not a git repo: %s", src) } + name := filepath.Base(src) + if dirName != "" { + name = dirName + } + dest := filepath.Join(taskRoot, name) + if _, err := os.Stat(dest); err == nil { + return fmt.Errorf("worktree dir %q already exists in task", name) + } else if !errors.Is(err, fs.ErrNotExist) { + return err + } + + branchExists, err := gitBranchExists(src, branch) + if err != nil { + return err + } + + // --existing: check out a branch that is already in the source repo, + // rather than creating a new one. No base is needed. + if existing { + if !branchExists { + return fmt.Errorf("branch %q does not exist in source repo (omit --existing to create it)", branch) + } + fmt.Printf("checking out worktree %s [%s]...\n", name, branch) + if _, err := runGit(src, "worktree", "add", dest, branch); err != nil { + return err + } + fmt.Printf("added %s → %s [%s]\n", src, dest, branch) + return nil + } + + if branchExists { + return fmt.Errorf("branch %q already exists in source repo (pass -b to pick another, or --existing to check it out)", branch) + } + var baseRemote, baseBranch string if base == "" { baseRemote, baseBranch, err = defaultBase(src) @@ -236,30 +277,11 @@ func addOne(taskRoot, repoPath, branch, base, dirName string) error { } } - name := filepath.Base(src) - if dirName != "" { - name = dirName - } - dest := filepath.Join(taskRoot, name) - if _, err := os.Stat(dest); err == nil { - return fmt.Errorf("worktree dir %q already exists in task", name) - } else if !errors.Is(err, fs.ErrNotExist) { - 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) } - exists, err := gitBranchExists(src, branch) - if err != nil { - return err - } - if exists { - return fmt.Errorf("branch %q already exists in source repo (pass -b to pick another)", branch) - } - fmt.Printf("creating worktree %s [%s]...\n", name, branch) // `-c branch.autoSetupMerge=false` keeps the new branch from inheriting // the base branch as its upstream — we want "never pushed" to remain diff --git a/main_test.go b/main_test.go index 48878ee..f1afde8 100644 --- a/main_test.go +++ b/main_test.go @@ -340,6 +340,63 @@ func TestCmdAdd_CustomBranch(t *testing.T) { } } +func TestCmdAdd_ExistingBranch(t *testing.T) { + _, src := makeRepoPair(t) + mustRunGit(t, src, "branch", "review") // branch created ahead of time + + 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{"--existing", "review", "--as", "src-review", src}); err != nil { + t.Fatal(err) + } + }) + wt := filepath.Join(taskDir, "src-review") + br, _ := runGit(wt, "branch", "--show-current") + if br != "review" { + t.Errorf("branch = %q, want review", br) + } +} + +func TestCmdAdd_ExistingMissingFails(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() { + if err := cmdAdd([]string{"--existing", "nope", src}); err == nil { + t.Error("expected error: branch does not exist") + } + }) +} + +func TestCmdAdd_ExistingWithBFails(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() { + if err := cmdAdd([]string{"-b", "new", "--existing", "old", src}); err == nil { + t.Error("expected error: -b and --existing are mutually exclusive") + } + }) +} + func TestCmdAdd_SameRepoTwice(t *testing.T) { _, src := makeRepoPair(t) From 2af2d2c32eff2c797bc09b48a33258451577df95 Mon Sep 17 00:00:00 2001 From: Miguel Torres Date: Mon, 27 Jul 2026 17:05:26 +0000 Subject: [PATCH 2/2] Accept a local branch as --base and drop --existing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --existing could never serve its use case: git refuses to check one branch out in two worktrees, so a branch already live in another task was never available to a second one. Branching from it is the only thing git permits, and it's what the stacked-PR workflow wants anyway. - --base takes / or a local branch name; remote wins on ambiguity, and a local base skips the fetch - Pass a fully-qualified start point to `git worktree add` — the short form is ambiguous to git when a local branch is named e.g. origin/main - Remove --existing from `tsk add`, usage, and README Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 42 +++++---- main.go | 139 ++++++++++++++++++---------- main_test.go | 254 ++++++++++++++++++++++++++++++++++++++------------- 3 files changed, 306 insertions(+), 129 deletions(-) diff --git a/README.md b/README.md index 1a7de40..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. @@ -89,20 +105,6 @@ tsk add ../../gov-fr # → gov-fr [] tsk add --as gov-fr-hotfix -b hotfix ../../gov-fr # → gov-fr-hotfix [hotfix] ``` -## Checking out an existing branch - -By default `tsk add` *creates* a new branch (from `-b` or the task slug) off the -base branch. To instead check out a branch that already exists in the repo — for -example to review a colleague's branch, or to give an in-progress branch its own -worktree — use `--existing `: - -```sh -tsk add --existing peppol-pint-addon --as gobl-pint ../../gobl -``` - -`--existing` skips the base fetch (no base is needed), errors if the branch is -missing, and is mutually exclusive with `-b`. - `--as` names a single directory, so it can't be combined with multiple repo paths in one `tsk add`. @@ -121,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 | --existing ] [--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 7c1ef17..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 | --existing ] [--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) { @@ -155,7 +155,7 @@ func cmdCreate(args []string) error { fmt.Println(taskDir) for _, p := range addPaths { - if err := addOne(taskDir, p, slug, *base, "", false); err != nil { + if err := addOne(taskDir, p, slug, *base, ""); err != nil { return fmt.Errorf("%s: %w", p, err) } } @@ -169,18 +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)") - existing := flags.String("existing", "", "check out an existing branch instead of creating one (mutually exclusive with -b)") - 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 | --existing ] [--as ] [ ...]") - } - if *existing != "" && *branch != "" { - return errors.New("-b and --existing are mutually exclusive") + return errors.New("usage: tsk add [--base ] [-b ] [--as ] [ ...]") } if *asName != "" { if len(repos) > 1 { @@ -208,19 +204,16 @@ func cmdAdd(args []string) error { if chosenBranch == "" { chosenBranch = t.Slug } - if *existing != "" { - chosenBranch = *existing - } for _, p := range repos { - if err := addOne(taskRoot, p, chosenBranch, *base, *asName, *existing != ""); err != nil { + if err := addOne(taskRoot, p, chosenBranch, *base, *asName); err != nil { return fmt.Errorf("%s: %w", p, err) } } return nil } -func addOne(taskRoot, repoPath, branch, base, dirName string, existing bool) error { +func addOne(taskRoot, repoPath, branch, base, dirName string) error { src, err := filepath.Abs(repoPath) if err != nil { return err @@ -245,50 +238,29 @@ func addOne(taskRoot, repoPath, branch, base, dirName string, existing bool) err return err } - // --existing: check out a branch that is already in the source repo, - // rather than creating a new one. No base is needed. - if existing { - if !branchExists { - return fmt.Errorf("branch %q does not exist in source repo (omit --existing to create it)", branch) - } - fmt.Printf("checking out worktree %s [%s]...\n", name, branch) - if _, err := runGit(src, "worktree", "add", dest, branch); err != nil { - return err - } - fmt.Printf("added %s → %s [%s]\n", src, dest, branch) - return nil - } - if branchExists { - return fmt.Errorf("branch %q already exists in source repo (pass -b to pick another, or --existing to check it out)", branch) + return fmt.Errorf("branch %q already exists in source repo (pass -b to pick another)", branch) } - 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) - } + ref, err := resolveBase(src, base) + if err != nil { + 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) + 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 } @@ -644,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 f1afde8..dcd2648 100644 --- a/main_test.go +++ b/main_test.go @@ -340,63 +340,6 @@ func TestCmdAdd_CustomBranch(t *testing.T) { } } -func TestCmdAdd_ExistingBranch(t *testing.T) { - _, src := makeRepoPair(t) - mustRunGit(t, src, "branch", "review") // branch created ahead of time - - 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{"--existing", "review", "--as", "src-review", src}); err != nil { - t.Fatal(err) - } - }) - wt := filepath.Join(taskDir, "src-review") - br, _ := runGit(wt, "branch", "--show-current") - if br != "review" { - t.Errorf("branch = %q, want review", br) - } -} - -func TestCmdAdd_ExistingMissingFails(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() { - if err := cmdAdd([]string{"--existing", "nope", src}); err == nil { - t.Error("expected error: branch does not exist") - } - }) -} - -func TestCmdAdd_ExistingWithBFails(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() { - if err := cmdAdd([]string{"-b", "new", "--existing", "old", src}); err == nil { - t.Error("expected error: -b and --existing are mutually exclusive") - } - }) -} - func TestCmdAdd_SameRepoTwice(t *testing.T) { _, src := makeRepoPair(t) @@ -543,9 +486,120 @@ 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 { + t.Fatal(err) + } + }) + taskDir := filepath.Join(tasks, "feat") + runIn(t, taskDir, func() { + 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 { @@ -554,16 +608,94 @@ 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", "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 // named "upstream" (not "origin") and whose default branch is "master" (not // "main"). Without --base, tsk should resolve the base via upstream/master.