From ba6079a27b8cca1c1cf7446c501627664f6def2c Mon Sep 17 00:00:00 2001 From: Miguel Torres Date: Fri, 29 May 2026 15:43:00 +0000 Subject: [PATCH 1/6] Add --from flag to base worktrees on a non-default branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tsk add` and `tsk create -a ...` now accept `--from ` to base the new worktree's branch on `origin/` instead of the hardcoded `origin/main`. Default is unchanged, so existing usage is untouched. This is the multi-repo equivalent of `git checkout -b B` while sitting on branch A — useful when a task stacks on top of a feature branch that has not been merged to main yet. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 14 +++++++++++--- main.go | 29 +++++++++++++++++------------ main_test.go | 39 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index cf901a5..9aa7bad 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,13 @@ Run `tsk create` wherever you want the task to live. The task directory's name i source repo by `tsk add` defaults to `` — the ref is **not** part of the branch name. -The base branch is hardcoded to `origin/main`. +The base branch defaults to `origin/main`. Override it with `--from ` +on `tsk add` (or `tsk create` when using `-a`): + +```sh +# Base the new worktrees off origin/develop instead of origin/main. +tsk add ../../gobl.html --from develop +``` ## `tsk close` is paranoid by default @@ -71,8 +77,10 @@ want to discard. ## Commands ``` -tsk create [] Create a task directory in cwd -tsk add [...] [-b] Add worktrees to the current task +tsk create [] [--from ] [-a ...] + Create a task directory in cwd +tsk add [...] [-b ] [--from ] + Add worktrees to the current task tsk status git status summary across all worktrees tsk rm [-f] Remove one worktree from the current task tsk close [-f] Decommission a task: clean worktrees + delete dir diff --git a/main.go b/main.go index 517f86a..f427572 100644 --- a/main.go +++ b/main.go @@ -26,7 +26,7 @@ import ( const version = "0.1.0" const remote = "origin" -const baseBranch = "main" +const defaultBase = "main" const metaFile = ".tsk.yaml" @@ -75,9 +75,9 @@ func usage(w *os.File) { fmt.Fprint(w, `tsk — multi-repo task workspaces usage: - tsk create [] [-a ...] + tsk create [] [--from ] [-a ...] Create a task directory in cwd - tsk add [ ...] [-b ] + tsk add [ ...] [-b ] [--from ] Add worktrees to the current task tsk status git status summary across all worktrees tsk rm [-f] Remove one worktree from the current task @@ -104,6 +104,7 @@ func cmdCreate(args []string) error { flags := flag.NewFlagSet("create", flag.ContinueOnError) flags.SetOutput(os.Stderr) + from := flags.String("from", defaultBase, "remote branch to base new branches on (used with -a)") if err := flags.Parse(mainArgs); err != nil { return err } @@ -116,7 +117,7 @@ func cmdCreate(args []string) error { case 2: ref, slug = rest[0], rest[1] default: - return errors.New("usage: tsk create [] [-a ...]") + return errors.New("usage: tsk create [] [--from ] [-a ...]") } if ref != "" && !validSlug(ref) { @@ -157,7 +158,7 @@ func cmdCreate(args []string) error { fmt.Println(taskDir) for _, p := range addPaths { - if err := addOne(taskDir, p, slug); err != nil { + if err := addOne(taskDir, p, slug, *from); err != nil { return fmt.Errorf("%s: %w", p, err) } } @@ -171,12 +172,13 @@ 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)") + from := flags.String("from", defaultBase, "remote branch to base the new branch on") if err := flags.Parse(args); err != nil { return err } repos := flags.Args() if len(repos) == 0 { - return errors.New("usage: tsk add [ ...] [-b ]") + return errors.New("usage: tsk add [ ...] [-b ] [--from ]") } cwd, err := os.Getwd() @@ -198,14 +200,17 @@ func cmdAdd(args []string) error { } for _, p := range repos { - if err := addOne(taskRoot, p, chosenBranch); err != nil { + if err := addOne(taskRoot, p, chosenBranch, *from); err != nil { return fmt.Errorf("%s: %w", p, err) } } return nil } -func addOne(taskRoot, repoPath, branch string) error { +func addOne(taskRoot, repoPath, branch, base string) error { + if base == "" { + base = defaultBase + } src, err := filepath.Abs(repoPath) if err != nil { return err @@ -222,8 +227,8 @@ func addOne(taskRoot, repoPath, branch string) error { return err } - fmt.Printf("fetching %s/%s for %s...\n", remote, baseBranch, name) - if _, err := runGit(src, "fetch", remote, baseBranch); err != nil { + fmt.Printf("fetching %s/%s for %s...\n", remote, base, name) + if _, err := runGit(src, "fetch", remote, base); err != nil { return fmt.Errorf("fetch: %w", err) } @@ -237,11 +242,11 @@ func addOne(taskRoot, repoPath, branch string) error { fmt.Printf("creating worktree %s [%s]...\n", name, branch) // `-c branch.autoSetupMerge=false` keeps the new branch from inheriting - // `origin/main` as its upstream — we want "never pushed" to remain + // 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, remote+"/"+baseBranch, + "worktree", "add", "-b", branch, dest, remote+"/"+base, ); err != nil { return err } diff --git a/main_test.go b/main_test.go index 7ef549c..60f17f9 100644 --- a/main_test.go +++ b/main_test.go @@ -340,6 +340,45 @@ func TestCmdAdd_CustomBranch(t *testing.T) { } } +func TestCmdAdd_FromBranch(t *testing.T) { + _, src := makeRepoPair(t) + + // Create a `develop` branch with an extra commit, push it, then + // delete the local copy so we can prove the worktree was built from + // `origin/develop` (not a local ref). + mustRunGit(t, src, "checkout", "-b", "develop") + if err := os.WriteFile(filepath.Join(src, "DEV"), []byte("dev\n"), 0o644); err != nil { + t.Fatal(err) + } + mustRunGit(t, src, "add", ".") + mustRunGit(t, src, "commit", "-m", "dev commit") + mustRunGit(t, src, "push", "-u", "origin", "develop") + mustRunGit(t, src, "checkout", "main") + mustRunGit(t, src, "branch", "-D", "develop") + + 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{"--from", "develop", src}); err != nil { + t.Fatal(err) + } + }) + + wt := filepath.Join(taskDir, filepath.Base(src)) + if _, err := os.Stat(filepath.Join(wt, "DEV")); err != nil { + t.Errorf("expected DEV file (from develop) in worktree: %v", err) + } + br, _ := runGit(wt, "branch", "--show-current") + if br != "feat" { + t.Errorf("branch = %q, want feat", br) + } +} + func TestCmdRm(t *testing.T) { _, src := makeRepoPair(t) From 87993af74acd6ad61997550423d5415621e9b942 Mon Sep 17 00:00:00 2001 From: Miguel Torres Date: Mon, 1 Jun 2026 07:47:53 +0000 Subject: [PATCH 2/6] README: document use cases for --from Lists three concrete scenarios where basing worktrees on a non-default branch is the right call: stacking on an unreleased feature branch, hotfixes against a release branch, and long-lived integration branches. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index 9aa7bad..ad4dac6 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,19 @@ on `tsk add` (or `tsk create` when using `-a`): tsk add ../../gobl.html --from develop ``` +When `--from` helps: + +- **Stacking on an unreleased feature branch.** Your task depends on a + colleague's change that is approved but not yet merged to `main`. Branching + 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. +- **Working against a release/maintenance branch.** Hotfixes or backports + belong on top of `release/x.y`, not `main`. +- **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. + ## `tsk close` is paranoid by default Closing a task removes each worktree and deletes the task directory. Before doing From 6e0fb9464408cfe22d12003a5808620945aed70c Mon Sep 17 00:00:00 2001 From: Miguel Torres Date: Mon, 1 Jun 2026 09:03:47 +0000 Subject: [PATCH 3/6] README use cases --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index ad4dac6..21de3cb 100644 --- a/README.md +++ b/README.md @@ -69,8 +69,6 @@ When `--from` 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. -- **Working against a release/maintenance branch.** Hotfixes or backports - belong on top of `release/x.y`, not `main`. - **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. From 264c82f8b2db8ad279a9e68323e1d499f819b885 Mon Sep 17 00:00:00 2001 From: Miguel Torres Date: Mon, 1 Jun 2026 13:40:12 +0000 Subject: [PATCH 4/6] Rename --from to --base; auto-detect default base branch Addresses PR #1 review feedback: - Flag renamed from --from to --base. - --base now takes a full remote-tracking ref (e.g. origin/main); bare branch names are rejected so it's unambiguous whether a local ref or a remote-tracking one is meant. - The default base (when --base is not given) is no longer hardcoded to origin/main. It is resolved per repo as the first remote's default HEAD: cheap path via refs/remotes//HEAD, with an ls-remote --symref fallback for repos whose cache isn't set. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 23 ++++++----- main.go | 105 +++++++++++++++++++++++++++++++++++++++++---------- main_test.go | 98 ++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 195 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 21de3cb..7d55749 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Multi-repo task workspaces backed by git worktrees. If your work routinely touches a handful of repos at once — services and the libraries they share — `tsk` automates the bootstrap: one task directory, one -worktree per repo, all on a fresh branch off `origin/main`. +worktree per repo, all on a fresh branch off each repo's default upstream. ## Install @@ -54,15 +54,20 @@ Run `tsk create` wherever you want the task to live. The task directory's name i source repo by `tsk add` defaults to `` — the ref is **not** part of the branch name. -The base branch defaults to `origin/main`. Override it with `--from ` -on `tsk add` (or `tsk create` when using `-a`): +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`): ```sh -# Base the new worktrees off origin/develop instead of origin/main. -tsk add ../../gobl.html --from develop +# Base the new worktrees off origin/develop instead of the default. +tsk add ../../gobl.html --base origin/develop ``` -When `--from` helps: +The full `/` form is required so it's never ambiguous whether +you mean a local branch or a remote-tracking one. + +When `--base` helps: - **Stacking on an unreleased feature branch.** Your task depends on a colleague's change that is approved but not yet merged to `main`. Branching @@ -79,7 +84,7 @@ Closing a task removes each worktree and deletes the task directory. Before doin that, `close` refuses to touch a worktree if either: - the working tree is dirty, or -- the branch was never pushed, or has unpushed commits ahead of `origin/`. +- the branch was never pushed, or has unpushed commits ahead of its upstream. This is the whole point: it is easy to forget that a worktree had local-only work. `--force` is the explicit escape hatch for the cases where you really do @@ -88,9 +93,9 @@ want to discard. ## Commands ``` -tsk create [] [--from ] [-a ...] +tsk create [] [--base /] [-a ...] Create a task directory in cwd -tsk add [...] [-b ] [--from ] +tsk add [...] [-b ] [--base /] 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 f427572..95975db 100644 --- a/main.go +++ b/main.go @@ -25,9 +25,6 @@ import ( const version = "0.1.0" -const remote = "origin" -const defaultBase = "main" - const metaFile = ".tsk.yaml" // task is the on-disk schema for .tsk.yaml. @@ -75,9 +72,9 @@ func usage(w *os.File) { fmt.Fprint(w, `tsk — multi-repo task workspaces usage: - tsk create [] [--from ] [-a ...] + tsk create [] [--base /] [-a ...] Create a task directory in cwd - tsk add [ ...] [-b ] [--from ] + tsk add [ ...] [-b ] [--base /] Add worktrees to the current task tsk status git status summary across all worktrees tsk rm [-f] Remove one worktree from the current task @@ -104,7 +101,7 @@ func cmdCreate(args []string) error { flags := flag.NewFlagSet("create", flag.ContinueOnError) flags.SetOutput(os.Stderr) - from := flags.String("from", defaultBase, "remote branch to base new branches on (used with -a)") + base := flags.String("base", "", "remote-tracking branch to base new branches on, e.g. origin/main (defaults to the first remote's default branch)") if err := flags.Parse(mainArgs); err != nil { return err } @@ -117,7 +114,7 @@ func cmdCreate(args []string) error { case 2: ref, slug = rest[0], rest[1] default: - return errors.New("usage: tsk create [] [--from ] [-a ...]") + return errors.New("usage: tsk create [] [--base /] [-a ...]") } if ref != "" && !validSlug(ref) { @@ -158,7 +155,7 @@ func cmdCreate(args []string) error { fmt.Println(taskDir) for _, p := range addPaths { - if err := addOne(taskDir, p, slug, *from); err != nil { + if err := addOne(taskDir, p, slug, *base); err != nil { return fmt.Errorf("%s: %w", p, err) } } @@ -172,13 +169,13 @@ 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)") - from := flags.String("from", defaultBase, "remote branch to base the new branch on") + 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)") if err := flags.Parse(args); err != nil { return err } repos := flags.Args() if len(repos) == 0 { - return errors.New("usage: tsk add [ ...] [-b ] [--from ]") + return errors.New("usage: tsk add [ ...] [-b ] [--base /]") } cwd, err := os.Getwd() @@ -200,7 +197,7 @@ func cmdAdd(args []string) error { } for _, p := range repos { - if err := addOne(taskRoot, p, chosenBranch, *from); err != nil { + if err := addOne(taskRoot, p, chosenBranch, *base); err != nil { return fmt.Errorf("%s: %w", p, err) } } @@ -208,9 +205,6 @@ func cmdAdd(args []string) error { } func addOne(taskRoot, repoPath, branch, base string) error { - if base == "" { - base = defaultBase - } src, err := filepath.Abs(repoPath) if err != nil { return err @@ -219,6 +213,20 @@ func addOne(taskRoot, repoPath, branch, base 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) dest := filepath.Join(taskRoot, name) if _, err := os.Stat(dest); err == nil { @@ -227,8 +235,8 @@ func addOne(taskRoot, repoPath, branch, base string) error { return err } - fmt.Printf("fetching %s/%s for %s...\n", remote, base, name) - if _, err := runGit(src, "fetch", remote, base); err != nil { + 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) } @@ -246,7 +254,7 @@ func addOne(taskRoot, repoPath, branch, base string) error { // detectable until the user actually pushes it. if _, err := runGit(src, "-c", "branch.autoSetupMerge=false", - "worktree", "add", "-b", branch, dest, remote+"/"+base, + "worktree", "add", "-b", branch, dest, baseRemote+"/"+baseBranch, ); err != nil { return err } @@ -431,14 +439,16 @@ func cmdClose(args []string) error { problems = append(problems, fmt.Sprintf("%s: dirty working tree", w.path)) } - // Refresh remote tracking before checking upstream / ahead count. - _, _ = runGit(w.src, "fetch", remote, w.branch) - up, _ := runGit(w.path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}") if up == "" { problems = append(problems, fmt.Sprintf("%s: branch %q has no upstream (never pushed)", w.path, w.branch)) continue } + // Refresh the remote-tracking ref before counting ahead, otherwise + // a stale local view would let a not-yet-pushed branch look caught up. + if r, b, ok := parseRemoteBranch(up); ok { + _, _ = runGit(w.src, "fetch", r, b) + } aheadStr, err := runGit(w.path, "rev-list", "--count", "@{u}..HEAD") if err != nil { problems = append(problems, fmt.Sprintf("%s: %v", w.path, err)) @@ -534,6 +544,61 @@ func runGit(dir string, args ...string) (string, error) { return strings.TrimRight(stdout.String(), "\n"), nil } +// parseRemoteBranch splits a string like "origin/dev" into its remote and +// branch parts on the first slash. Returns ok=false if the input is missing a +// slash, starts with one, or ends with one. The branch part may contain +// slashes (e.g. "origin/feature/foo" → "origin", "feature/foo"). +func parseRemoteBranch(s string) (remote, branch string, ok bool) { + i := strings.IndexByte(s, '/') + if i <= 0 || i == len(s)-1 { + return "", "", false + } + return s[:i], s[i+1:], true +} + +// defaultBase picks the first remote configured in repo (alphabetical, which is +// git's default `git remote` order) and resolves that remote's default HEAD +// branch. Tries the locally-cached `refs/remotes//HEAD` first, falling +// back to `git ls-remote --symref` if it isn't set. +func defaultBase(repo string) (remote, branch string, err error) { + out, err := runGit(repo, "remote") + if err != nil { + return "", "", err + } + out = strings.TrimSpace(out) + if out == "" { + return "", "", fmt.Errorf("no remotes configured in %s", repo) + } + remote = strings.SplitN(out, "\n", 2)[0] + + // Cheap path: the cached remote HEAD set by `git clone` / `git remote set-head`. + cached, err := runGit(repo, "symbolic-ref", "--short", "refs/remotes/"+remote+"/HEAD") + if err == nil { + cached = strings.TrimSpace(cached) + if r, b, ok := parseRemoteBranch(cached); ok && r == remote { + return r, b, nil + } + } + + // Fallback: query the remote. + ls, err := runGit(repo, "ls-remote", "--symref", remote, "HEAD") + if err != nil { + return "", "", err + } + for _, line := range strings.Split(ls, "\n") { + if !strings.HasPrefix(line, "ref: ") { + continue + } + // "ref: refs/heads/main\tHEAD" + ref := strings.TrimPrefix(line, "ref: ") + if tab := strings.IndexByte(ref, '\t'); tab > 0 { + ref = ref[:tab] + } + return remote, strings.TrimPrefix(ref, "refs/heads/"), nil + } + return "", "", fmt.Errorf("could not determine default branch of remote %q in %s", remote, repo) +} + 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 60f17f9..77d3c7d 100644 --- a/main_test.go +++ b/main_test.go @@ -340,7 +340,7 @@ func TestCmdAdd_CustomBranch(t *testing.T) { } } -func TestCmdAdd_FromBranch(t *testing.T) { +func TestCmdAdd_BaseBranch(t *testing.T) { _, src := makeRepoPair(t) // Create a `develop` branch with an extra commit, push it, then @@ -364,7 +364,7 @@ func TestCmdAdd_FromBranch(t *testing.T) { }) taskDir := filepath.Join(tasks, "feat") runIn(t, taskDir, func() { - if err := cmdAdd([]string{"--from", "develop", src}); err != nil { + if err := cmdAdd([]string{"--base", "origin/develop", src}); err != nil { t.Fatal(err) } }) @@ -379,6 +379,100 @@ func TestCmdAdd_FromBranch(t *testing.T) { } } +func TestCmdAdd_BaseRejectsMissingSlash(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", "main", src}) + if err == nil { + t.Fatal("expected error: --base main is missing a remote prefix") + } + if !strings.Contains(err.Error(), "/") { + t.Errorf("error should explain expected format, got: %v", err) + } + }) +} + +// 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. +func TestCmdAdd_DefaultBaseUsesFirstRemote(t *testing.T) { + bare := filepath.Join(t.TempDir(), "remote.git") + if err := exec.Command("git", "init", "--bare", "-b", "master", bare).Run(); err != nil { + t.Fatal(err) + } + src := filepath.Join(t.TempDir(), "src") + if err := exec.Command("git", "clone", "-o", "upstream", bare, src).Run(); err != nil { + t.Fatalf("clone failed: %v", err) + } + mustRunGit(t, src, "config", "user.email", "test@example.com") + mustRunGit(t, src, "config", "user.name", "Test") + mustRunGit(t, src, "checkout", "-B", "master") + if err := os.WriteFile(filepath.Join(src, "README"), []byte("hi\n"), 0o644); err != nil { + t.Fatal(err) + } + mustRunGit(t, src, "add", ".") + mustRunGit(t, src, "commit", "-m", "init") + mustRunGit(t, src, "push", "-u", "upstream", "master") + // `git push -u` doesn't set the remote HEAD on a clone of a previously-empty + // bare repo, so seed it explicitly — defaultBase falls back to ls-remote if + // this is missing, but we want to exercise the cheap symbolic-ref path too. + mustRunGit(t, src, "remote", "set-head", "upstream", "master") + + 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{src}); err != nil { + t.Fatalf("default base detection failed: %v", err) + } + }) + + wt := filepath.Join(taskDir, filepath.Base(src)) + if !isWorktree(wt) { + t.Errorf("expected worktree at %s", wt) + } + br, _ := runGit(wt, "branch", "--show-current") + if br != "feat" { + t.Errorf("branch = %q, want feat", br) + } +} + +func TestParseRemoteBranch(t *testing.T) { + cases := []struct { + in string + wantRemote string + wantBranch string + wantOK bool + }{ + {"origin/main", "origin", "main", true}, + {"upstream/master", "upstream", "master", true}, + {"origin/feature/foo", "origin", "feature/foo", true}, + {"main", "", "", false}, + {"/main", "", "", false}, + {"origin/", "", "", false}, + {"", "", "", false}, + } + for _, c := range cases { + r, b, ok := parseRemoteBranch(c.in) + if r != c.wantRemote || b != c.wantBranch || ok != c.wantOK { + t.Errorf("parseRemoteBranch(%q) = (%q,%q,%v); want (%q,%q,%v)", + c.in, r, b, ok, c.wantRemote, c.wantBranch, c.wantOK) + } + } +} + func TestCmdRm(t *testing.T) { _, src := makeRepoPair(t) From cee287ff7fb70a6d3b7ade3d0c1ed5904a390866 Mon Sep 17 00:00:00 2001 From: Miguel Torres Valls <97603106+migueltorresvalls@users.noreply.github.com> Date: Mon, 1 Jun 2026 20:22:25 +0200 Subject: [PATCH 5/6] Update command arguments positions non-flag arguments must always appear after flag arguments Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7d55749..63714f4 100644 --- a/README.md +++ b/README.md @@ -93,9 +93,9 @@ want to discard. ## Commands ``` -tsk create [] [--base /] [-a ...] +tsk create [--base /] [] [-a ...] Create a task directory in cwd -tsk add [...] [-b ] [--base /] +tsk add [--base /] [-b ] [...] Add worktrees to the current task tsk status git status summary across all worktrees tsk rm [-f] Remove one worktree from the current task From bc6e7473bd59e99d61326949d9ee489293879ad3 Mon Sep 17 00:00:00 2001 From: Miguel Torres Valls <97603106+migueltorresvalls@users.noreply.github.com> Date: Mon, 1 Jun 2026 20:24:12 +0200 Subject: [PATCH 6/6] Update args position non-flag arguments must come after flagged arguments Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- README.md | 3 +-- main.go | 8 ++++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 63714f4..b80e6e2 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,7 @@ configured with (`upstream/master` works just the same). Override it with ```sh # Base the new worktrees off origin/develop instead of the default. -tsk add ../../gobl.html --base origin/develop -``` +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. diff --git a/main.go b/main.go index 95975db..fdeec0c 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 [ ...] [-b ] [--base /] + tsk add [--base /] [-b ] [ ...] Add worktrees to the current task tsk status git status summary across all worktrees tsk rm [-f] Remove one worktree from the current task @@ -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) { @@ -175,7 +175,7 @@ func cmdAdd(args []string) error { } repos := flags.Args() if len(repos) == 0 { - return errors.New("usage: tsk add [ ...] [-b ] [--base /]") + return errors.New("usage: tsk add [--base /] [-b ] [ ...]") } cwd, err := os.Getwd()