Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 22 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <remote>/<branch>` on `tsk add` (or `tsk create` when using `-a`):
`--base <branch>` 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 `<remote>/<branch>` 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 `<remote>/<branch>` 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:

Expand All @@ -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 <task-A-branch>` 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.
Expand Down Expand Up @@ -107,9 +123,9 @@ want to discard.
## Commands

```
tsk create [--base <remote>/<branch>] [<ref>] <slug> [-a <repo>...]
tsk create [--base <branch>] [<ref>] <slug> [-a <repo>...]
Create a task directory in cwd
tsk add [--base <remote>/<branch>] [-b <branch>] [--as <name>] <repo-path> [...]
tsk add [--base <branch>] [-b <branch>] [--as <name>] <repo-path> [...]
Add worktrees to the current task
tsk status git status summary across all worktrees
tsk rm [-f] <repo-path> Remove one worktree from the current task
Expand Down
121 changes: 93 additions & 28 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,9 @@ func usage(w *os.File) {
fmt.Fprint(w, `tsk — multi-repo task workspaces

usage:
tsk create [--base <remote>/<branch>] [<ref>] <slug> [-a <repo-path> ...]
tsk create [--base <branch>] [<ref>] <slug> [-a <repo-path> ...]
Create a task directory in cwd
tsk add [--base <remote>/<branch>] [-b <branch>] [--as <name>] <repo-path> [<repo-path> ...]
tsk add [--base <branch>] [-b <branch>] [--as <name>] <repo-path> [<repo-path> ...]
Add worktrees to the current task
tsk status git status summary across all worktrees
tsk rm [-f] <repo-path> Remove one worktree from the current task
Expand All @@ -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: <remote>/<branch> or a local branch name (defaults to the first remote's default branch)")
if err := flags.Parse(mainArgs); err != nil {
return err
}
Expand All @@ -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 <remote>/<branch>] [<ref>] <slug> [-a <repo-path> ...]")
return errors.New("usage: tsk create [--base <branch>] [<ref>] <slug> [-a <repo-path> ...]")
}

if ref != "" && !validSlug(ref) {
Expand Down Expand Up @@ -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: <remote>/<branch> 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 <remote>/<branch>] [-b <branch>] [--as <name>] <repo-path> [<repo-path> ...]")
return errors.New("usage: tsk add [--base <branch>] [-b <branch>] [--as <name>] <repo-path> [<repo-path> ...]")
}
if *asName != "" {
if len(repos) > 1 {
Expand Down Expand Up @@ -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 <remote>/<branch>, e.g. origin/main)", base)
}
}

name := filepath.Base(src)
if dirName != "" {
name = dirName
Expand All @@ -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
}
Expand Down Expand Up @@ -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 <remote>/<branch>
// 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 <remote>/<branch> 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 {
Expand Down
Loading
Loading