devtree
Tree-shaped development planning that lives inside your repository and draws itself in the GitHub viewport.

One Go binary. No dependencies, no service, no account.
Your plan is a file β .devtree/tree.yaml β that sits next to the code. It is versioned with the
code, reviewed in the pull request, and merged line by line. From it, devtree generates a
Mermaid diagram into TREE.md or straight into your README.md, and
GitHub and GitLab render it natively. No browser extension, no image to regenerate, nothing to host.
Why
A roadmap in a tracker drifts away from the branch it describes. A roadmap in a wiki is read once
and never again. Put it in the repository and three things follow for free:
|
|
| It gets reviewed |
A change to the plan shows up in the diff, next to the change to the code. |
| It gets merged |
The node list is flat, so two branches that each added a task merge cleanly. |
| It stays honest |
A pre-commit hook and a CI check refuse to let the diagram drift from the plan. |
What it looks like
This is a real devtree diagram β GitHub draws it right here in the page:
flowchart TD
classDef todo fill:#f6f8fa,stroke:#8c959f,color:#1f2328
classDef in_progress fill:#fff8c5,stroke:#bf8700,color:#1f2328
classDef blocked fill:#ffebe9,stroke:#cf222e,color:#1f2328
classDef done fill:#dafbe1,stroke:#1a7f37,color:#1f2328
classDef dropped fill:#eaeef2,stroke:#8c959f,color:#6e7781,stroke-dasharray:4 3
n_mvp["β MVP<br/><i>1/6</i>"]:::in_progress
n_mvp --> n_authentication
n_authentication["β Authentication<br/><i>0/2 Β· feat/auth Β· #35;12 Β· @ann</i>"]:::in_progress
n_authentication --> n_oauth_providers
n_oauth_providers["β OAuth providers<br/><i>feat/oauth</i>"]:::in_progress
n_authentication --> n_password_reset
n_password_reset["β Password reset"]:::blocked
n_mvp --> n_payments
n_payments["β Payments<br/><i>1/2</i>"]:::in_progress
n_payments --> n_stripe
n_stripe["β Stripe"]:::done
n_payments --> n_apple_pay
n_apple_pay["β Apple Pay<br/><i>#35;51</i>"]:::todo
n_public_api_v2["β Public API v2<br/><i>0/1</i>"]:::todo
n_public_api_v2 --> n_openapi_schema
n_openapi_schema["β OpenAPI schema"]:::todo
β not started Β· β in progress Β· β blocked Β· β done Β· β dropped
The same plan in the terminal:
Payment Gateway
β MVP (mvp) [1/6]
ββ β Authentication (authentication) feat/auth #12 @ann [0/2]
β ββ β OAuth providers (oauth-providers) feat/oauth
β ββ β Password reset (password-reset)
ββ β Payments (payments) [1/2]
ββ β Stripe (stripe)
ββ β Apple Pay (apple-pay) #51
β Public API v2 (public-api-v2) [0/1]
ββ β OpenAPI schema (openapi-schema)
ββββββββββββββββββββ 1/9
Install
go install github.com/SergeyLubivui-dev/devtree@latest
Or build it from source β Go 1.22 or newer, nothing else:
git clone https://github.com/SergeyLubivui-dev/devtree.git
cd devtree
go test ./...
go build -o devtree .
Cross-compiling is the usual one-liner:
GOOS=darwin GOARCH=arm64 go build -o devtree .
GOOS=windows GOARCH=amd64 go build -o devtree.exe .
Quickstart
cd your-project
devtree init --project "Payment Gateway" --repo https://github.com/acme/pay --hook --action
devtree add "Authentication" -p mvp -b feat/auth -i 12 -o ann -s wip
devtree add "OAuth providers" -p authentication -b feat/oauth
devtree add "Password reset" -p authentication -s blocked -n "waiting on SMTP"
devtree ls # the tree, in your terminal
devtree done oauth-providers
git add . && git commit -m "feat: oauth" # the hook refreshes TREE.md for you
IDs are derived from titles β "OAuth providers" becomes oauth-providers, and non-Latin titles are
transliterated so the ID stays typeable. Pass --id when you want to choose one yourself.
Commands
| Command |
What it does |
init [--project N] [--repo URL] [--outputs F] [--hook] [--action] [--empty] |
Creates .devtree/tree.yaml, .gitattributes, and the first diagram |
add "Title" [-p ID] [-s STATUS] [-b BRANCH] [-i N] [--pr N] [-o WHO] [--tags a,b] [-n NOTE] [--id ID] |
Adds a task |
set ID [--title T] [-s ...] [-p ...] [...] |
Changes fields on a task; only the flags you pass are touched |
done ID [ID...] |
Marks tasks done |
mv ID PARENT|root |
Re-parents a task |
rm ID [--cascade] |
Deletes a task; without --cascade its children move up to its parent |
ls [-s STATUS] |
Prints the tree in the terminal |
render [--file F] [--quiet] |
Regenerates the diagram |
check [--strict] |
Validates the plan β for CI and hooks |
install hook|action|all |
Installs the pre-commit hook and the GitHub Action |
outputs |
Prints the files the diagram is written to |
Statuses are todo, in_progress, blocked, done, and dropped. Shorthand works everywhere a
status is accepted: wip, b, d, x, ok, doing, cancel, and friends.
Flags may come before or after the title, so both of these do the same thing:
devtree add "Authentication" -p mvp -s wip
devtree add -p mvp -s wip "Authentication"
# Development tree. Edit by hand or through the `devtree` command.
# After editing, run `devtree render` to refresh the diagram.
version: 1
project: "Payment Gateway"
repo: "https://github.com/acme/pay"
outputs: "TREE.md"
nodes:
- id: "mvp"
title: "MVP"
status: "in_progress"
- id: "authentication"
title: "Authentication"
status: "in_progress"
parent: "mvp"
branch: "feat/auth"
issue: "12"
owner: "ann"
- id: "password-reset"
title: "Password reset"
status: "blocked"
parent: "authentication"
note: "waiting on SMTP"
The list is flat; the hierarchy comes from the parent field. That is a deliberate trade.
Nesting would mean that adding a task rewrites an existing block β exactly the shape that makes two
feature branches conflict. A flat list turns "add a task" into "append lines", so parallel branches
merge cleanly, and init writes a merge=union rule into .gitattributes to finish the job. Worst
case you end up with a duplicate ID, which devtree check catches on the spot.
Edit the file by hand whenever you like. The parser is strict and tells you the line number:
devtree: .devtree/tree.yaml: line 14: unknown node field "assignee"
Automation
Pre-commit hook β devtree install hook
Validates the plan, regenerates the diagram, and stages the result along with your commit. An
existing hook is preserved as pre-commit.devtree-backup. Teammates who have not installed devtree
are not blocked: the hook notices the binary is missing and steps aside.
GitHub Action β devtree install action
- on
pull_request: fails if the diagram is stale, so the author fixes it in their own diff;
- on
push to the default branch: refreshes the diagram and commits it.
Rendering into your README
devtree init --outputs "README.md"
The block is written between the <!-- devtree:begin --> and <!-- devtree:end --> markers, each on
a line of its own. If the markers are not there yet, the block is appended once and updated in place
from then on β so put the empty pair wherever you want the diagram to appear. Everything outside
them is left exactly as you wrote it, and re-rendering an unchanged plan rewrites nothing at all.
Only markers that start their own line count. Prose that mentions them mid-sentence β the paragraph
you are reading, for instance β is left alone.
Issue and pull request links are built from repo. If repo is not set, devtree falls back to
relative links (../../issues/12), which GitHub resolves correctly for files in the repository root
and which survive a fork or a move to another host.
How it is built
Four layers, and the dependencies only ever point inward:
main.go ~20 lines: parse nothing, print nothing, just wire and exit
βββ internal/cli flags, dispatch, and every line the user sees
βββ internal/store the strict YAML subset: parse, marshal, atomic save
βββ internal/render Mermaid, Markdown, ASCII β pure string functions
βββ internal/scaffold hook, workflow, and .gitattributes templates
βββ internal/tree the domain: nodes, parents, statuses, validation
internal/tree imports nothing but the standard library. It does not know that a file format
exists, and it certainly does not know about Mermaid β which is why the rules about what a valid
plan is live in one place and stay there. Everything below the CLI is silent: no printing, no
os.Exit, errors returned as values. App takes its working directory and its two writers as
fields, so the test suite drives whole commands against a temporary directory and reads back exactly
what a user would have seen.
Run the tests the usual way:
go test ./...
go vet ./...
Limitations
- The storage format is a strict subset of YAML β a flat list of scalar fields. Anchors, multi-line
block scalars, and nested mappings are not supported and will be rejected with a line number.
- GitHub's Mermaid renderer ignores
click directives, so a node in the diagram cannot itself be a
link. The links live in the collapsed table underneath instead.
- Very wide trees (hundreds of nodes) render slowly in the browser. Split them across several output
files with
--outputs.
devtree's own plan
Rendered by devtree, from this repository, on every push:
π³ devtree
ββββββββββββββββββββ 8 / 16 tasks done
flowchart TD
classDef todo fill:#f6f8fa,stroke:#8c959f,color:#1f2328
classDef in_progress fill:#fff8c5,stroke:#bf8700,color:#1f2328
classDef blocked fill:#ffebe9,stroke:#cf222e,color:#1f2328
classDef done fill:#dafbe1,stroke:#1a7f37,color:#1f2328
classDef dropped fill:#eaeef2,stroke:#8c959f,color:#6e7781,stroke-dasharray:4 3
n_v0_1["β v0.1 - first release<br/><i>6/6</i>"]:::done
n_v0_1 --> n_domain
n_domain["β Domain model and validation"]:::done
n_v0_1 --> n_format
n_format["β Strict YAML subset parser"]:::done
n_v0_1 --> n_diagram
n_diagram["β Mermaid renderer"]:::done
n_v0_1 --> n_cli
n_cli["β Command line"]:::done
n_v0_1 --> n_automation
n_automation["β Pre-commit hook and GitHub Action"]:::done
n_v0_1 --> n_tests
n_tests["β Test suite"]:::done
n_v0_2["β v0.2 - sharper day-to-day use<br/><i>0/4</i>"]:::todo
n_v0_2 --> n_filters
n_filters["β Filter ls by owner and tag"]:::todo
n_v0_2 --> n_focus
n_focus["β Render one subtree with --root"]:::todo
n_v0_2 --> n_open
n_open["β devtree open ID - jump to the issue or PR"]:::todo
n_v0_2 --> n_history
n_history["β Progress history from git log"]:::todo
n_distribution["β Distribution<br/><i>1/3</i>"]:::todo
n_distribution --> n_binaries
n_binaries["β Prebuilt binaries on every tag"]:::done
n_distribution --> n_homebrew
n_homebrew["β Homebrew tap"]:::todo
n_distribution --> n_gitlab
n_gitlab["β GitLab CI template"]:::todo
β not started Β· β in progress Β· β blocked Β· β done Β· β dropped
License
MIT Β© SergeyLubivui-dev