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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ For full flags and examples, see `timber --help` and `timber <command> --help`.
| `tui` | Interactively open an existing worktree or create a new one |
| `herdr install` | Install the bundled Herdr plugin |
| `herdr space` | Open a Herdr Agent + Shell space for a worktree |
| `list` | List worktrees with merge/dirtiness status (`--json`, `--pr`, `--sort`) |
| `list` | List worktrees with merge/dirtiness status and TODO.md progress (`--json`, `--pr`, `--sort`) |
| `prune` | Remove clean, merged worktrees (`--dry-run`, `--prompt`) |
| `remove` | Remove one worktree and delete its branch (`--force` overrides safety) |
| `todo` | Open the current worktree's `TODO.md` in `$EDITOR` |
Expand Down
14 changes: 10 additions & 4 deletions internal/timber/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func (x *listCommandOptions) Execute(command *cobra.Command, args []string) erro
return x.reportJSON(command, worktrees)
}

headers := []string{"Name", "Repo", "Status", "Commit", "Dirty"}
headers := []string{"Name", "Repo", "Status", "Todo", "Commit", "Dirty"}
if x.pullRequests {
headers = append(headers, "PR")
}
Expand All @@ -69,9 +69,10 @@ func (x *listCommandOptions) Execute(command *cobra.Command, args []string) erro

// listJSONWorktree is the JSON record for one worktree in `list --json`
// output. It carries the same information as a table row: identity,
// upstream divergence, full commit hash, dirtiness, merge state, and the
// pull request display string when --pr is used. Worktrees whose git status
// could not be read report statusError instead of divergence data.
// upstream divergence, TODO.md progress, full commit hash, dirtiness,
// merge state, and the pull request display string when --pr is used.
// Worktrees whose git status could not be read report statusError instead
// of divergence data.
type listJSONWorktree struct {
Name string `json:"name"`
Repo string `json:"repo"`
Expand All @@ -83,6 +84,8 @@ type listJSONWorktree struct {
Clean bool `json:"clean"`
Merged bool `json:"merged"`
StatusError bool `json:"statusError"`
TodoDone int `json:"todoDone"`
TodoTotal int `json:"todoTotal"`
PullRequest string `json:"pullRequest"`
}

Expand All @@ -100,6 +103,8 @@ func worktreesToListJSON(worktrees []managedWorktree) []listJSONWorktree {
Clean: worktree.Clean,
Merged: worktree.Merged,
StatusError: worktree.ListError,
TodoDone: worktree.Todo.Done,
TodoTotal: worktree.Todo.Total,
PullRequest: worktree.PullRequest,
})
}
Expand Down Expand Up @@ -142,6 +147,7 @@ func groupListTableRows(worktrees []managedWorktree, statusFormatter listStatusF
worktree.Name,
worktree.Repo,
status,
worktree.Todo.String(),
worktree.shortCommitHash(),
formatDirtyStatus(worktree.Clean),
}
Expand Down
24 changes: 21 additions & 3 deletions internal/timber/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ func TestGroupListTableRowsAddsRuleAfterEverySecondWorktree(t *testing.T) {
assert.Equal(t, names, rows[index][0])
}

tableView := newOutputTable("Name", "Repo", "Status", "Commit", "Dirty").BorderRow(true)
tableView := newOutputTable("Name", "Repo", "Status", "Todo", "Commit", "Dirty").BorderRow(true)
tableView.Rows(rows...)
tableOutput := dottedListRowRules(tableView.String())
assert.Equal(t, testCase.horizontalRuleRows, strings.Count(tableOutput, "├"))
Expand All @@ -70,11 +70,11 @@ func TestGroupListTableRowsIncludesPullRequestColumnWhenEnabled(t *testing.T) {

rows := groupListTableRows(worktrees, newListStatusFormatter(worktrees), true)
require.Len(t, rows, 1)
assert.Equal(t, "#42 ✓\n", rows[0][5])
assert.Equal(t, "#42 ✓\n", rows[0][6])

rows = groupListTableRows(worktrees, newListStatusFormatter(worktrees), false)
require.Len(t, rows, 1)
assert.Len(t, rows[0], 5)
assert.Len(t, rows[0], 6)
}

func TestFormatDirtyStatusColorsTrueYellow(t *testing.T) {
Expand All @@ -83,6 +83,21 @@ func TestFormatDirtyStatusColorsTrueYellow(t *testing.T) {
assert.Equal(t, warningStyle.Render("dirty"), formatDirtyStatus(false))
}

func TestListReportsTodoProgress(t *testing.T) {
t.Parallel()
const branchName = "feature/todo-progress"

testRepository := newTestRepository(t)
require.NoError(t, testRepository.runTimber(t, "create", at(testRepoName, branchName)).err)
require.NoError(t, testRepository.runTimber(t, "create", at(testRepoName, "feature/no-todo")).err)
writeTodoFile(t, todoPathForWorktree(t, testRepository.worktreePath(branchName)), "- [x] done\n- [ ] pending\n")

result := testRepository.runTimber(t, "list", at(testRepoName, ""))
require.NoError(t, result.err, result.stderr)
assert.Contains(t, result.stdout, "Todo")
assert.Contains(t, result.stdout, "1/2")
}

func TestListSucceedsWhenUpstreamRefIsMissing(t *testing.T) {
t.Parallel()
const branchName = "feature/no-upstream-ref"
Expand Down Expand Up @@ -258,6 +273,7 @@ func TestListJSONOutputsWorktrees(t *testing.T) {
testRepository := newTestRepository(t)
require.NoError(t, testRepository.runTimber(t, "create", at(testRepoName, "feature/healthy")).err)
require.NoError(t, testRepository.runTimber(t, "create", at(testRepoName, "feature/broken")).err)
writeTodoFile(t, todoPathForWorktree(t, testRepository.worktreePath("feature/healthy")), "- [x] done\n- [ ] pending\n")
require.NoError(t, os.RemoveAll(testRepository.worktreePath("feature/broken")))

result := testRepository.runTimber(t, "list", "--json", at(testRepoName, ""))
Expand All @@ -280,6 +296,8 @@ func TestListJSONOutputsWorktrees(t *testing.T) {
assert.NotEmpty(t, healthy.Commit)
assert.True(t, healthy.Clean)
assert.False(t, healthy.StatusError)
assert.Equal(t, 1, healthy.TodoDone)
assert.Equal(t, 2, healthy.TodoTotal)

broken, found := byName["feature/broken"]
require.True(t, found, "expected feature/broken in %s", result.stdout)
Expand Down
26 changes: 26 additions & 0 deletions internal/timber/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -1038,6 +1038,16 @@ func (x Runtime) collectWorktrees(repos []registeredRepo, enrich worktreeEnriche
return worktrees, nil
}

// worktreeGitDir resolves the Git directory backing a checkout, which is where
// worktree-scoped state such as TODO.md lives.
func (x Runtime) worktreeGitDir(path string) (string, error) {
result, err := gitOutput(x, path, "rev-parse", "--absolute-git-dir")
if err != nil {
return "", fmt.Errorf("resolve Git directory for %q: %w", path, err)
}
return filepath.Clean(result.stdout), nil
}

func (x Runtime) enrichWorktreeForList(repository *Repository, worktree managedWorktree) (managedWorktree, error) {
result, err := gitOutput(x, worktree.Path, "status", "--porcelain=v2", "--branch")
if err != nil {
Expand All @@ -1052,6 +1062,7 @@ func (x Runtime) enrichWorktreeForList(repository *Repository, worktree managedW
}
worktree.ListStatus = status
worktree.Clean = clean
worktree.Todo = x.todoProgressForWorktree(worktree)
// Record the repo default so Status can hide it and only call out
// worktrees tracking something else. Unknown defaults show upstream.
if defaultBranch, err := repository.remoteHeadBranch(); err == nil {
Expand All @@ -1070,6 +1081,21 @@ func (x Runtime) enrichWorktreeForList(repository *Repository, worktree managedW
return worktree, nil
}

// todoProgressForWorktree counts the worktree's TODO.md checklist items. A
// worktree whose checklist cannot be read reports no progress; list already
// degrades to placeholder cells for unreadable worktree state.
func (x Runtime) todoProgressForWorktree(worktree managedWorktree) todoProgress {
gitDir, err := x.worktreeGitDir(worktree.Path)
if err != nil {
return todoProgress{}
}
progress, err := readTodoProgress(gitDir)
if err != nil {
return todoProgress{}
}
return progress
}

// enrichListedWorktreesWithPullRequests attaches one gh pull request lookup
// per repository. It runs after collection so each repo needs a single call.
func (x Runtime) enrichListedWorktreesWithPullRequests(ctx context.Context, worktrees []managedWorktree) error {
Expand Down
18 changes: 18 additions & 0 deletions internal/timber/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package timber
import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -97,3 +98,20 @@ func TestDisplayHomePath(t *testing.T) {
assert.Equal(t, filepath.Join("~", ".local", "share", "timber", "repos", "demo.git"), runtime.displayHomePath(filepath.Join(home, ".local", "share", "timber", "repos", "demo.git")))
assert.Equal(t, "/tmp/other", runtime.displayHomePath("/tmp/other"))
}

func TestRuntimeWorktreeGitDirHoldsWorktreeTodoFile(t *testing.T) {
t.Parallel()
const branchName = "feature/git-dir"

testRepository := newTestRepository(t)
require.NoError(t, testRepository.runTimber(t, "create", at(testRepoName, branchName)).err)

gitDir, err := testRepository.runtime.worktreeGitDir(testRepository.worktreePath(branchName))
require.NoError(t, err)
assert.True(t, filepath.IsAbs(gitDir))

// The Git directory is where `timber todo` keeps the worktree's file.
todoResult := testRepository.runTimber(t, "todo", at(testRepoName, branchName), "--path")
require.NoError(t, todoResult.err, todoResult.stderr)
assert.Equal(t, todoFilePath(gitDir), strings.TrimSpace(todoResult.stdout))
}
5 changes: 5 additions & 0 deletions internal/timber/test_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,11 @@ func (x testRepository) commitFileInWorktree(t *testing.T, branchName string, fi
runGitCommand(t, path, "commit", "-m", "change")
}

func writeTodoFile(t *testing.T, path string, contents string) {
t.Helper()
require.NoError(t, os.WriteFile(path, []byte(contents), 0o644))
}

func (x testRepository) writeFileInWorktree(t *testing.T, branchName string, fileName string, contents string) {
t.Helper()
path := filepath.Join(x.worktreePath(branchName), fileName)
Expand Down
11 changes: 10 additions & 1 deletion internal/timber/todo.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ import (
// fallbackEditors is tried in order when EDITOR is unset.
var fallbackEditors = []string{"nvim", "nano", "vim", "vi"}

// todoFileName is the checklist `timber todo` edits inside a worktree's Git
// directory.
const todoFileName = "TODO.md"

// todoFilePath returns the TODO.md location for a worktree's Git directory.
func todoFilePath(gitDir string) string {
return filepath.Join(gitDir, todoFileName)
}

type todoCommandOptions struct {
repoSelection
pathOnly bool
Expand Down Expand Up @@ -50,7 +59,7 @@ func (x *todoCommandOptions) Execute(command *cobra.Command, args []string) erro
return err
}

todoPath := filepath.Join(gitDir, "TODO.md")
todoPath := todoFilePath(gitDir)
file, err := os.OpenFile(todoPath, os.O_CREATE|os.O_RDONLY, 0o644)
if err != nil {
return fmt.Errorf("create TODO.md: %w", err)
Expand Down
78 changes: 78 additions & 0 deletions internal/timber/todo_progress.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package timber

import (
"bufio"
"errors"
"fmt"
"io"
"io/fs"
"os"
"strings"
)

// todoProgress counts checklist items in a worktree's TODO.md.
type todoProgress struct {
Done int
Total int
}

func (x todoProgress) String() string {
return fmt.Sprintf("%d/%d", x.Done, x.Total)
}

// readTodoProgress counts the `- [ ]` and `- [x]` checklist items in a
// worktree's TODO.md. A missing TODO.md is an empty checklist, not an error,
// because `timber todo` creates the file lazily.
func readTodoProgress(gitDir string) (todoProgress, error) {
file, err := os.Open(todoFilePath(gitDir))
if errors.Is(err, fs.ErrNotExist) {
return todoProgress{}, nil
}
if err != nil {
return todoProgress{}, fmt.Errorf("open %s: %w", todoFilePath(gitDir), err)
}
progress, parseErr := parseTodoProgress(file)
// A close failure on a read-only file carries no information.
if err := file.Close(); err != nil && parseErr == nil {
parseErr = fmt.Errorf("close %s: %w", todoFilePath(gitDir), err)
}
return progress, parseErr
}

// parseTodoProgress counts checklist items line by line. Items may be nested,
// so leading whitespace is ignored, and only lines whose first non-space
// characters form a checkbox count toward the totals.
func parseTodoProgress(reader io.Reader) (todoProgress, error) {
var progress todoProgress
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
checked, ok := todoItemChecked(strings.TrimSpace(scanner.Text()))
if !ok {
continue
}
progress.Total++
if checked {
progress.Done++
}
}
if err := scanner.Err(); err != nil {
return todoProgress{}, fmt.Errorf("read TODO.md: %w", err)
}
return progress, nil
}

// todoItemChecked reports whether a trimmed line is a Markdown checkbox and
// whether that checkbox is checked.
func todoItemChecked(line string) (checked bool, ok bool) {
item, found := strings.CutPrefix(line, "- ")
if !found {
return false, false
}
switch {
case strings.HasPrefix(item, "[ ]"):
return false, true
case strings.HasPrefix(item, "[x]"), strings.HasPrefix(item, "[X]"):
return true, true
}
return false, false
}
Loading
Loading