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
33 changes: 28 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -54,15 +54,36 @@ Run `tsk create` wherever you want the task to live. The task directory's name i
source repo by `tsk add` defaults to `<slug>` — the ref is **not** part of the
branch name.

The base branch is hardcoded to `origin/main`.
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`):

```sh
# Base the new worktrees off origin/develop instead of the default.
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.

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
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.
- **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
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/<branch>`.
- 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
Expand All @@ -71,8 +92,10 @@ want to discard.
## Commands

```
tsk create [<ref>] <slug> Create a task directory in cwd
tsk add <repo-path> [...] [-b] Add worktrees to the current task
tsk create [--base <remote>/<branch>] [<ref>] <slug> [-a <repo>...]
Create a task directory in cwd
tsk add [--base <remote>/<branch>] [-b <branch>] <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
tsk close [-f] <task-path> Decommission a task: clean worktrees + delete dir
Expand Down
104 changes: 87 additions & 17 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,6 @@ import (

const version = "0.1.0"

const remote = "origin"
const baseBranch = "main"

const metaFile = ".tsk.yaml"

// task is the on-disk schema for .tsk.yaml.
Expand Down Expand Up @@ -75,9 +72,9 @@ func usage(w *os.File) {
fmt.Fprint(w, `tsk — multi-repo task workspaces

usage:
tsk create [<ref>] <slug> [-a <repo-path> ...]
tsk create [--base <remote>/<branch>] [<ref>] <slug> [-a <repo-path> ...]
Create a task directory in cwd
tsk add <repo-path> [<repo-path> ...] [-b <branch>]
tsk add [--base <remote>/<branch>] [-b <branch>] <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 @@ -104,6 +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)")
if err := flags.Parse(mainArgs); err != nil {
Comment thread
migueltorresvalls marked this conversation as resolved.
return err
}
Expand All @@ -116,7 +114,7 @@ func cmdCreate(args []string) error {
case 2:
ref, slug = rest[0], rest[1]
default:
return errors.New("usage: tsk create [<ref>] <slug> [-a <repo-path> ...]")
return errors.New("usage: tsk create [--base <remote>/<branch>] [<ref>] <slug> [-a <repo-path> ...]")
}
Comment thread
migueltorresvalls marked this conversation as resolved.

if ref != "" && !validSlug(ref) {
Expand Down Expand Up @@ -157,7 +155,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, *base); err != nil {
return fmt.Errorf("%s: %w", p, err)
}
}
Expand All @@ -171,12 +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)")
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 <repo-path> [<repo-path> ...] [-b <branch>]")
return errors.New("usage: tsk add [--base <remote>/<branch>] [-b <branch>] <repo-path> [<repo-path> ...]")
}
Comment thread
migueltorresvalls marked this conversation as resolved.

cwd, err := os.Getwd()
Expand All @@ -198,14 +197,14 @@ func cmdAdd(args []string) error {
}

for _, p := range repos {
if err := addOne(taskRoot, p, chosenBranch); err != nil {
if err := addOne(taskRoot, p, chosenBranch, *base); 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 {
src, err := filepath.Abs(repoPath)
if err != nil {
return err
Expand All @@ -214,6 +213,20 @@ func addOne(taskRoot, repoPath, branch 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)
dest := filepath.Join(taskRoot, name)
if _, err := os.Stat(dest); err == nil {
Expand All @@ -222,8 +235,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", baseRemote, baseBranch, name)
if _, err := runGit(src, "fetch", baseRemote, baseBranch); err != nil {
return fmt.Errorf("fetch: %w", err)
}

Expand All @@ -237,11 +250,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, baseRemote+"/"+baseBranch,
); err != nil {
return err
}
Expand Down Expand Up @@ -426,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))
Expand Down Expand Up @@ -529,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/<remote>/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 {
Expand Down
Loading
Loading