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
42 changes: 39 additions & 3 deletions AGENT_INSTRUCTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,24 @@ devlog show --from yesterday --json
devlog sync --quiet && devlog show --from yesterday --json
```

### Discover and filter by tag

```bash
# List all tags and their usage counts
devlog tags list --json
# Returns:
# {
# "version": "1",
# "tags": [
# { "tag": "auth", "count": 5 },
# { "tag": "backend", "count": 3 }
# ]
# }

# Then filter show output to a specific tag
devlog show --from 2026-05-01 --tag auth --json
```

### Target a specific date

```bash
Expand Down Expand Up @@ -117,6 +135,21 @@ Returns a JSON array where each element has the same shape as the single-day sch
]
```

### `devlog tags list --json`

```json
{
"version": "1",
"tags": [
{ "tag": "auth", "count": 12 },
{ "tag": "backend", "count": 5 },
{ "tag": "frontend", "count": 2 }
]
}
```

`count` is the number of day files containing the tag (not the number of bullets). Tags are sorted by count descending, then alphabetically. Use this to discover available tags before calling `devlog show --tag <tag>` to filter entries.

### Version field semantics

- All `--json` output includes `"version": "1"`.
Expand Down Expand Up @@ -157,15 +190,15 @@ devlog/
│ ├── edit.go # Open day file in $EDITOR
│ ├── show.go # Read and render day files
│ ├── sync.go # Import commits and PRs from git/GitHub
│ └── standup.go # Compile and render standup view
│ └── tags.go # List and rename tags across all day files
└── internal/
├── config/config.go # Load/write config.toml; env var overrides
├── store/store.go # Open/create day files; path resolution; date parsing
├── store/store.go # Open/create day files; path resolution; date parsing; AllDates()
├── store/entry.go # DayEntry struct (frontmatter + sections)
├── store/tags.go # ListTags(), RenameTag() — tag aggregation and rewrite
├── git/scanner.go # exec git log, parse commits
├── git/github.go # gh CLI wrapper + GitHub REST client
└── render/
├── markdown.go # Serialize DayEntry to .md
├── terminal.go # Human-readable colored output
└── json.go # --json output structs
```
Expand Down Expand Up @@ -236,4 +269,7 @@ GOOS=linux GOARCH=amd64 go build -o devlog-linux-amd64 .
- **Write safety:** `devlog add` and `devlog sync` take per-file advisory locks and write atomically.
- **Idempotent sync:** Running `devlog sync` multiple times deduplicates by commit SHA and PR number.
- **Graceful degradation:** Missing `gh` CLI, no GitHub token, or repo without a GitHub remote is not an error — that source is skipped silently.
- **Tags are validated on write:** Tags must match `[a-z0-9_]+` — lowercase letters, digits, and underscores. `devlog add --tag Auth` or `--tag api-v2` will return an error. The new tag in `devlog tags rename` is also validated; the old tag is matched case-insensitively (to handle pre-validation legacy tags).
- **Tag rename is case-insensitive and atomic:** `devlog tags rename` matches the old tag case-insensitively, writes the new name exactly as given, and rewrites each affected file atomically under an advisory lock. Files without the tag are not touched.
- **Tag counts are per-day:** `devlog tags list` counts the number of day files containing each tag, not the number of occurrences within a file.
- **Version:** `devlog --version` prints the build version (e.g. `v0.1.0`; `dev` when built from source).
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ Versioning: [Semantic Versioning](https://semver.org/)

## [Unreleased]

### Added
- `devlog tags [list]` — list all tags across all day files with usage counts, sorted by frequency then alphabetically; `--json` supported
- `devlog tags rename <old> <new>` — rename a tag (case-insensitive match) across all day files; preserves tag order, leaves unaffected files untouched

---

## [0.1.0] — 2026-06-04
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ devlog add "Implemented rate limiter middleware" # log what you're working on
devlog show today # review your day
devlog show --from yesterday # review a date range
devlog sync # import today's commits and PRs
devlog tags list # see all tags you've used and how often
devlog tags rename auth oauth # rename a tag across every day file
```

---
Expand All @@ -72,6 +74,8 @@ devlog sync # import today's commits and PRs
| `show [today\|yesterday\|YYYY-MM-DD\|week]` | Print entries for a day or the last 7 days |
| `show --from DATE [--until DATE]` | Print entries for a date range (--until defaults to today) |
| `sync [--quiet]` | Import today's commits and PRs from configured repos |
| `tags [list]` | List all tags with per-day usage counts |
| `tags rename <old> <new>` | Rename a tag across all entries (case-insensitive) |

**Global flags:** `--json` (structured output on `show`, `sync`) · `--date YYYY-MM-DD` (target a specific date on `add`, `edit`, `show`, `sync`)

Expand Down Expand Up @@ -187,6 +191,7 @@ devlog show today --json # read today's context before starting work
devlog add "what was done" # log completed tasks
devlog sync --quiet # import git activity
devlog show --from yesterday --json # review recent activity as JSON
devlog tags list --json # discover tag usage across the journal
```

---
Expand Down
7 changes: 7 additions & 0 deletions cmd/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@ func runAdd(cmd *cobra.Command, args []string) error {
return fmt.Errorf("--dep is only valid for blockers and action_items sections")
}

// Validate tags before touching any files
for _, tag := range addTags {
if err := store.ValidateTag(tag); err != nil {
return err
}
}

// Determine target date for day-file sections
date, err := resolveDate(globalDate)
if err != nil {
Expand Down
42 changes: 42 additions & 0 deletions cmd/add_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,48 @@ func TestAddCmd_UnknownSection_Errors(t *testing.T) {
}
}

func TestAddCmd_RejectsUppercaseTag(t *testing.T) {
dir := t.TempDir()
t.Setenv("DEVLOG_DIR", dir)
addSection = ""
addTags = nil
globalDate = ""

rootCmd.SetArgs([]string{"add", "--tag", "Auth", "some note"})
err := rootCmd.Execute()
if err == nil {
t.Error("expected error for uppercase tag, got nil")
}
}

func TestAddCmd_RejectsHyphenatedTag(t *testing.T) {
dir := t.TempDir()
t.Setenv("DEVLOG_DIR", dir)
addSection = ""
addTags = nil
globalDate = ""

rootCmd.SetArgs([]string{"add", "--tag", "auth-backend", "some note"})
err := rootCmd.Execute()
if err == nil {
t.Error("expected error for hyphenated tag, got nil")
}
}

func TestAddCmd_RejectsEmptyTag(t *testing.T) {
dir := t.TempDir()
t.Setenv("DEVLOG_DIR", dir)
addSection = ""
addTags = nil
globalDate = ""

rootCmd.SetArgs([]string{"add", "--tag", "", "some note"})
err := rootCmd.Execute()
if err == nil {
t.Error("expected error for empty tag, got nil")
}
}

func TestAddCmd_TagFlag(t *testing.T) {
dir := t.TempDir()
t.Setenv("DEVLOG_DIR", dir)
Expand Down
111 changes: 111 additions & 0 deletions cmd/tags.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package cmd

import (
"fmt"

"github.com/spf13/cobra"

"github.com/kacheo/devlog/internal/config"
"github.com/kacheo/devlog/internal/render"
"github.com/kacheo/devlog/internal/store"
)

var tagsCmd = &cobra.Command{
Use: "tags",
Short: "Manage journal tags",
Long: `Manage tags across all journal entries.

By default, lists all tags with usage counts.

Examples:
devlog tags # list all tags
devlog tags list # same as above
devlog tags list --json # machine-readable
devlog tags rename auth oauth # rename a tag across all entries`,
Args: cobra.NoArgs,
RunE: runTagsList,
}

var tagsListCmd = &cobra.Command{
Use: "list",
Short: "List all tags with usage counts",
Args: cobra.NoArgs,
RunE: runTagsList,
}

var tagsRenameCmd = &cobra.Command{
Use: "rename <old> <new>",
Short: "Rename a tag across all journal entries",
Args: cobra.ExactArgs(2),
RunE: runTagsRename,
}

func init() {
tagsCmd.AddCommand(tagsListCmd)
tagsCmd.AddCommand(tagsRenameCmd)
rootCmd.AddCommand(tagsCmd)
}

func openStore() (*store.Store, error) {
cfg, err := config.Load(config.DefaultPath())
if err != nil {
return nil, fmt.Errorf("loading config: %w", err)
}
st, err := store.New(cfg.Journal.Dir)
if err != nil {
return nil, fmt.Errorf("journal not configured: %w\nRun 'devlog init' to set up", err)
}
return st, nil
}

func runTagsList(cmd *cobra.Command, _ []string) error {
st, err := openStore()
if err != nil {
return err
}

tags, err := st.ListTags()
if err != nil {
return fmt.Errorf("listing tags: %w", err)
}

w := cmd.OutOrStdout()

if globalJSON {
b, err := render.TagsJSON(tags)
if err != nil {
return err
}
fmt.Fprintln(w, string(b))
return nil
}

render.TagsTerminal(tags, w)
return nil
}

func runTagsRename(cmd *cobra.Command, args []string) error {
oldTag, newTag := args[0], args[1]

if err := store.ValidateTag(newTag); err != nil {
return err
}

st, err := openStore()
if err != nil {
return err
}

n, err := st.RenameTag(oldTag, newTag)
if err != nil {
return err
}

w := cmd.OutOrStdout()
if n == 0 {
fmt.Fprintf(w, "No entries use tag %q.\n", oldTag)
} else {
fmt.Fprintf(w, "Renamed tag %q → %q in %d file(s).\n", oldTag, newTag, n)
}
return nil
}
Loading
Loading