diff --git a/README.md b/README.md index 7cc174d..7f9717a 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,7 @@ For full flags and examples, see `timber --help` and `timber --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` | diff --git a/internal/timber/list.go b/internal/timber/list.go index ee605de..eaa2f58 100644 --- a/internal/timber/list.go +++ b/internal/timber/list.go @@ -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") } @@ -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"` @@ -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"` } @@ -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, }) } @@ -142,6 +147,7 @@ func groupListTableRows(worktrees []managedWorktree, statusFormatter listStatusF worktree.Name, worktree.Repo, status, + worktree.Todo.String(), worktree.shortCommitHash(), formatDirtyStatus(worktree.Clean), } diff --git a/internal/timber/list_test.go b/internal/timber/list_test.go index d9c6315..4bc8533 100644 --- a/internal/timber/list_test.go +++ b/internal/timber/list_test.go @@ -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, "├")) @@ -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) { @@ -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" @@ -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, "")) @@ -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) diff --git a/internal/timber/runtime.go b/internal/timber/runtime.go index 39a4e78..6c25023 100644 --- a/internal/timber/runtime.go +++ b/internal/timber/runtime.go @@ -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 { @@ -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 { @@ -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 { diff --git a/internal/timber/runtime_test.go b/internal/timber/runtime_test.go index 955cac6..06a9f34 100644 --- a/internal/timber/runtime_test.go +++ b/internal/timber/runtime_test.go @@ -3,6 +3,7 @@ package timber import ( "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -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)) +} diff --git a/internal/timber/test_helpers_test.go b/internal/timber/test_helpers_test.go index 752f08f..d8c33d7 100644 --- a/internal/timber/test_helpers_test.go +++ b/internal/timber/test_helpers_test.go @@ -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) diff --git a/internal/timber/todo.go b/internal/timber/todo.go index 99ceffd..04c5cb8 100644 --- a/internal/timber/todo.go +++ b/internal/timber/todo.go @@ -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 @@ -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) diff --git a/internal/timber/todo_progress.go b/internal/timber/todo_progress.go new file mode 100644 index 0000000..ae8f6ef --- /dev/null +++ b/internal/timber/todo_progress.go @@ -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 +} diff --git a/internal/timber/todo_progress_test.go b/internal/timber/todo_progress_test.go new file mode 100644 index 0000000..ec392c8 --- /dev/null +++ b/internal/timber/todo_progress_test.go @@ -0,0 +1,97 @@ +package timber + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseTodoProgressCountsChecklistItems(t *testing.T) { + t.Parallel() + tests := []struct { + name string + lines []string + expected todoProgress + }{ + { + name: "mixed checkboxes", + lines: []string{ + "- [x] done", + "- [ ] pending", + }, + expected: todoProgress{Done: 1, Total: 2}, + }, + { + name: "uppercase checkbox counts as done", + lines: []string{ + "- [X] done", + }, + expected: todoProgress{Done: 1, Total: 1}, + }, + { + name: "nested and indented items count", + lines: []string{ + "## Section", + " - [ ] nested", + " - [x] deeper", + }, + expected: todoProgress{Done: 1, Total: 2}, + }, + { + name: "non-checkbox lines are ignored", + lines: []string{ + "# TODO", + "- plain bullet", + "- [-] not a checkbox", + "text [x] inline", + }, + expected: todoProgress{}, + }, + { + name: "empty checklist", + lines: nil, + expected: todoProgress{}, + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + progress, err := parseTodoProgress(strings.NewReader(strings.Join(testCase.lines, "\n"))) + require.NoError(t, err) + assert.Equal(t, testCase.expected, progress) + }) + } +} + +func TestTodoProgressStringRendersDoneOverTotal(t *testing.T) { + t.Parallel() + assert.Equal(t, "1/2", todoProgress{Done: 1, Total: 2}.String()) + assert.Equal(t, "0/0", todoProgress{}.String()) +} + +func TestReadTodoProgressReadsWorktreeGitDirectory(t *testing.T) { + t.Parallel() + gitDir := t.TempDir() + writeTodoFile(t, todoFilePath(gitDir), "- [x] done\n- [ ] pending\n") + + progress, err := readTodoProgress(gitDir) + require.NoError(t, err) + assert.Equal(t, todoProgress{Done: 1, Total: 2}, progress) + assert.Equal(t, "1/2", progress.String()) +} + +func TestReadTodoProgressTreatsMissingFileAsEmpty(t *testing.T) { + t.Parallel() + progress, err := readTodoProgress(t.TempDir()) + require.NoError(t, err) + assert.Equal(t, todoProgress{}, progress) +} + +func TestTodoFilePathLivesInGitDirectory(t *testing.T) { + t.Parallel() + assert.Equal(t, filepath.Join("/repo/.git/worktrees/one", todoFileName), todoFilePath("/repo/.git/worktrees/one")) +} diff --git a/internal/timber/todo_test.go b/internal/timber/todo_test.go index 0c3380b..43f9711 100644 --- a/internal/timber/todo_test.go +++ b/internal/timber/todo_test.go @@ -32,7 +32,7 @@ func todoPathForWorktree(t *testing.T, worktreePath string) string { t.Helper() gitDir := strings.TrimSpace(runGitCommand(t, worktreePath, "rev-parse", "--absolute-git-dir")) - return filepath.Join(gitDir, "TODO.md") + return todoFilePath(gitDir) } func TestTodoOpensWorktreeSpecificFile(t *testing.T) { diff --git a/internal/timber/worktree.go b/internal/timber/worktree.go index eb4d6b2..1b449e9 100644 --- a/internal/timber/worktree.go +++ b/internal/timber/worktree.go @@ -18,6 +18,7 @@ type managedWorktree struct { UpstreamRef referenceName Status string ListStatus listStatus + Todo todoProgress DefaultUpstream string ListError bool Clean bool