Devlog Excavator
I use it to excavate the content below, and in other projects listed on this site.
Devlog
Commits: 6
Fix some regular expressions in the spec
Regular expressions are hard to debug, and this one was tricky, because it made the spec evaluation flaky - once in a while it would randomly fail. The problem was that only one space was allowed int the time column, after the value. Somehow, sometimes there would be more than one, which is fine, but would result in no matches to the pattern.
Fortunately Nushell comes with a brilliant built-in explore regex
command, that is a lifesaver in such circumstances.
index c0ffaca..a7a3728 100644
--- a/spec/BASIC.md
+++ b/spec/BASIC.md
@@ -137,7 +137,7 @@ TODO: Setup a second project.
= * Expect the output to contain `the first commit from Alice`
=
= ``` regex
- │ +0 +│ +2026-03-27 +│ +Project Alpha +│ +Alice +│ +alice@example.com +│ +Write a readme +│ +Fri, 27 Mar 2026 13:22:00 \+\d{4} │ +[0-9a-f]+ +│ +.+/project-alpha +│
+ │ +0 +│ +2026-03-27 +│ +Project Alpha +│ +Alice +│ +alice@example.com +│ +Write a readme +│ +Fri, 27 Mar 2026 13:22:00 \+\d{4} +│ +[0-9a-f]+ +│ +.+/project-alpha +│
= ```
=
= > NOTE: To make the spec portable, the timezone, sha and part of the path has to be expressed as wildcard.
@@ -147,7 +147,7 @@ TODO: Setup a second project.
= Notice that the logical day (in the second column) is one before the actual day (in the seventh column). That's because work done at night is still counted toward the previous day. This can be controlled with `start-of-day` configuration parameter.
=
= ``` regex
- │ +2 +│ +2026-03-27 +│ +Project Alpha +│ +Alice +│ +alice@example.com +│ +Make Project Alpha work +│ +Sat, 28 Mar 2026 03:06:00 \+\d{4} │ +[0-9a-f]+ +│ +.+/project-alpha +│
+ │ +2 +│ +2026-03-27 +│ +Project Alpha +│ +Alice +│ +alice@example.com +│ +Make Project Alpha work +│ +Sat, 28 Mar 2026 03:06:00 \+\d{4} +│ +[0-9a-f]+ +│ +.+/project-alpha +│
= ```
=
= * Expect the output to contain `the silly commit from Bob`Correct the story timeline in the basic spec
The final commit from Alice was dated out of order.
index a7a3728..209b098 100644
--- a/spec/BASIC.md
+++ b/spec/BASIC.md
@@ -108,7 +108,7 @@ Before you excavate, it might be smart to list the commits. You can use `--dry-r
= }
= ```
=
- * On `2026-03-28 14:18` commit as `Alice <alice@example.com>`
+ * On `2026-04-04 14:18` commit as `Alice <alice@example.com>`
=
= ``` text
= I quitFix crashing when excavating unlisted projects
By unlisted projects I mean ones that are not in the configuration file. The intention is to warn about any activity in such projects, because maybe I want to include them in my devlog.
In such case the project lookup returned an empty list, and this was
breaking the first | get name part of the pipeline.
I'm still learning good techniques to debug complex Nushell code. Sometimes the errors are not clear, esp. in long pipelines. So I had to stuff some debug logging calls. And because this makes closures complex, I decided to name them and move the definitions out of the pipeline. I think it aids readability of my code.
It's a pity that built-in log functions don't pass on piped values. It would be nice to write code like this:
{ a: 1, b: coocoo }
| log info $"Got ($in)"
| get b
Alas it won't work, because log info outputs nothing. The workaround
is to explicitly return $in, like this:
{ a: 1, b: coocoo }
| (log info $"Got ($in)"; $in)
| get b
But that's brittle - it's very easy to accidentally not return it, and
write a bug while debugging. Maybe I'll write a wrapper for this, a
spy command that emmits logs, but also passes the value. I already
have a similar mechanism in tbb observe family of commands.
index f276b89..09691d2 100755
--- a/devlog.nu
+++ b/devlog.nu
@@ -19,14 +19,10 @@ export def main [
= | let config
=
= $until | default (date now) | let until
-
- $projects_dir
- | projects
- | each { project log $since $until }
- | flatten
- | update time { |commit| $commit.time | into datetime }
- | insert day {
- get time
+
+ let commit_day = { |commit|
+ $commit
+ | get time
= | if ($in | format date %T) < $day_start {
= $in - 1day
= } else {
@@ -34,18 +30,35 @@ export def main [
= }
= | format date %F
= }
- | insert project { |commit|
+ let commit_project = { |commit|
= $config.projects
= | where path == $commit.path
= | first
- | get name
+ | if ($in | is-not-empty) { get name } # Kind of like Maybe.andThen ...
+ }
+ let extract_log = { |project_path|
+ $project_path
+ | project log $since $until
+ | do {
+ $"Extracted ($in | length) commits from ($project_path)"
+ | log info $in
+ $in
+ }
= }
+
+ $projects_dir
+ | projects
+ | each $extract_log
+ | flatten
+ | update time { |commit| $commit.time | into datetime }
+ | insert day $commit_day
+ | insert project $commit_project
= | move day project author email subject time sha path --first
= | let commits
=
= if $dry_run {
= $commits
- | update time { format date }
+ | update time { format date } # For the output, format date
= | return $in
= }
=
@@ -148,15 +161,18 @@ def "project log" [
= | str join " "
= | let parse_format
=
- log debug $"Log format ($log_format)"
- log debug $"Parse format ($parse_format)"
-
- $log_format
- | $"git log --date=iso-strict --since=($since | format date %+) --until=($until | format date %+) --format=($in)"
- | log info $in
-
= $log_format
= | git log --date=iso-strict --since=($since | format date %+) --until=($until | format date %+) --format=($in)
+ | complete
+ | if ($in.exit_code != 0) {
+ log error $"Failed to run `git log` in ($project_path)."
+ log error $in.stderr
+ $in.stdout
+ } else {
+ log debug $"Done with `git log` in ($project_path)."
+ $in.stdout
+ }
+ | lines
= | parse $parse_format
= | reverse
= | insert path $project_pathRe-implement writing to markdown files
It's kind of a refactor, because it was possible to write a few days ago, but the code was a mess and it was hard to implement new needed features, like --until (already implemented) and unlisted projects warning (coming soon).
index 09691d2..a262ed2 100755
--- a/devlog.nu
+++ b/devlog.nu
@@ -41,13 +41,13 @@ export def main [
= | project log $since $until
= | do {
= $"Extracted ($in | length) commits from ($project_path)"
- | log info $in
+ | log debug $in
= $in
= }
= }
=
= $projects_dir
- | projects
+ | project list
= | each $extract_log
= | flatten
= | update time { |commit| $commit.time | into datetime }
@@ -55,6 +55,17 @@ export def main [
= | insert project $commit_project
= | move day project author email subject time sha path --first
= | let commits
+ | group-by --to-table day project
+ | sort-by day
+ | let entries
+
+ let stats = {
+ commits: ($commits | length),
+ projects: ($entries | get project | uniq | length),
+ days: ($entries | get day | uniq | length)
+ }
+
+ log info $"Excavated ($stats.commits) commits from ($stats.projects) projects across ($stats.days) days"
=
= if $dry_run {
= $commits
@@ -65,67 +76,24 @@ export def main [
= # TODO: Allow setting in a config file
= mkdir $out_dir
=
- # for project_path in ($projects_dir | projects) {
- # let project =
- # if ($project | is-empty) {
- # continue
- # }
-
- # let project_slug = $project.name | str kebab-case
-
- # if ($project | get --optional ignore | default false ) {
- # continue
- # }
-
- # # TODO: Iterate over days and write to a file
- # for day in (main days $since) {
- # let date = $day.start | format date %Y-%m-%d
- # let out_path = ($out_dir | path join $"($date)-($project_slug).md")
-
- # try {
- # # TODO: Catch and print errors
- # $project_path
- # | project log $day.start $day.end
- # | reverse
- # | let log
-
- # if ($log | is-empty) {
- # "No commits on that day"
- # continue
- # }
-
- # $log
- # | format log $project.name $config
- # | save --force $out_path
- # } catch { |error|
- # print --stderr $"Project path: ($project_path)"
- # print --stderr $"Date: ($date)"
- # print --stderr $"Output path: ($out_path)"
- # print --stderr $error.rendered
- # }
- # }
-
- # }
-}
=
-def "main days" [
- since: datetime,
- --day-start: string = "04:00"
-] {
- let until = $day_start | date from-human
- # TODO: Allow setting --until: datetime and be smart about day-start - the last day might be a fraction!
-
- 0..
- | each { |n| $n * 1day }
- | each { |duration| $until - $duration }
- | each { |start| {
- start: ([$start $since] | math max),
- end: ($start + 1day)
- } }
- | take while { |day| $day.start < $day.end }
+ for entry in $entries {
+ $entry
+ | get day project
+ | str join "-"
+ | str kebab-case
+ | { parent: $out_dir, stem: $in, extension: "md" }
+ | path join
+ | let out_path
+
+ log info $"Writing to ($out_path)..."
+ $entry
+ | format entry $in.project $config
+ | save --force $out_path
+ }
=}
=
-def "projects" []: path -> list<path> {
+def "project list" []: path -> list<path> {
= $in
= | path join "**/.git"
= | glob $in
@@ -178,21 +146,8 @@ def "project log" [
= | insert path $project_path
=}
=
-def "format commit" [] {
- [
- $"## ($in.subject | str trim)"
- ""
- ($in.body | str trim)
- ""
- ($in.diff | each { |diff| format diff } | str join "\n\n" )
- ] | str join "\n"
-}
-
-def "format diff" [] {
- $"``` diff\n($in | str trim)\n```"
-}
=
-def "format log" [
+def "format entry" [
= title: string
= config: record
=] {
@@ -206,8 +161,34 @@ def "format log" [
= $""
= $"# ($title)"
= $""
- $"Commits: ($in | length)"
+ $"Commits: ($in.items | length)"
= $""
- ($in | each { | commit | format commit } | str join "\n\n")
+ ($in.items | each { | commit | $commit | format commit } | str join "\n\n")
+ ] | str join "\n"
+}
+
+def "format commit" [] {
+ $in | do {
+ cd $in.path
+ git show --no-patch --format="%b" $in.sha
+ }
+ | let body
+
+ $in | do {
+ cd $in.path
+ git show --format="" $in.sha
+ }
+ | let patch
+
+ [
+ $"## ($in.subject | str trim)"
+ ""
+ ($body | str trim)
+ ""
+ ($patch | each { |diff| format diff } | str join "\n\n" )
= ] | str join "\n"
=}
+
+def "format diff" [] {
+ $"``` diff\n($in | str trim)\n```"
+}Split patches by file, use = as context indicator
I often have markdown code blocks nested in my diffs (coming from documentation or specs in markdown files). When those end up in devlog entries, then nested triple backticks (```) are tripping up many markdown tools (like syntax highlighters). Seems like tools "think" that those backtics demarkate the end of the diff block. In principle it should be unambiguous, because they are indented with at least one extra space compared to actual code block boundaries, but it is what it is.
So to prevent this now we ask git to use equal sign = in front of any
context line. This seems to do the trick.
index a262ed2..f082bba 100755
--- a/devlog.nu
+++ b/devlog.nu
@@ -176,7 +176,8 @@ def "format commit" [] {
=
= $in | do {
= cd $in.path
- git show --format="" $in.sha
+ git show --format="" --output-indicator-context='=' $in.sha
+ | split row --regex "(?m)^diff --git .+" | skip 1
= }
= | let patch
=
@@ -185,7 +186,7 @@ def "format commit" [] {
= ""
= ($body | str trim)
= ""
- ($patch | each { |diff| format diff } | str join "\n\n" )
+ ($patch | each { format diff } | str join "\n\n" )
= ] | str join "\n"
=}
=Do not emit the title h1
Instead make each commit subject an h1 and let the rendering system (like Zola) decide how to present. For example, I can use a template to to demote heading from the markdown document and insert a context dependent h1. If a devlog entry is presented as it's own page, it should include the date and project name. If it's rendered as a timeline under a project page, it should contain just the date.
index f082bba..0b8fa4c 100755
--- a/devlog.nu
+++ b/devlog.nu
@@ -159,10 +159,10 @@ def "format entry" [
= ($frontmatter | to yaml)
= $"---"
= $""
- $"# ($title)"
- $""
+ # TODO: Write this in front-matter (extra.stats.commits or something)
= $"Commits: ($in.items | length)"
= $""
+ $""
= ($in.items | each { | commit | $commit | format commit } | str join "\n\n")
= ] | str join "\n"
=}
@@ -182,7 +182,7 @@ def "format commit" [] {
= | let patch
=
= [
- $"## ($in.subject | str trim)"
+ $"# ($in.subject | str trim)"
= ""
= ($body | str trim)
= ""
Commits: 4
Fix indentation in the spec
Looks like TBB is really tripped off by misaligned backticks. It doesn't produce a readable error, sometimes fails to recognize steps beyond the borked region. This is really bad, because it may lead to positive evaluation result, while in reality some steps (or even whole scenarios) are not evaluated.
index 9a1451b..e61e1af 100644
--- a/spec/BASIC.md
+++ b/spec/BASIC.md
@@ -71,11 +71,11 @@ IT should eventually be possible to evaluate this spec using TBB. The difficulty
= # v1.0.0
=
= * Now it's working
- ```
+ ```
=
= * On `2026-03-28 03:06` commit as `Alice <alice@example.com>`
=
- Notice that it's logically the same day - any work done until 04:00 AM is considered to belong to the previous day.
+ Notice that it's logically the same day - any work done until 04:00 AM is considered to belong to the previous day.
=
= ``` text
= Make Project Alpha work
@@ -85,9 +85,9 @@ IT should eventually be possible to evaluate this spec using TBB. The difficulty
=
= * Write `LICENSE`
=
- ``` text
- All rights reserved! You monkeys better not even look at my pro codes.
- ```
+ ``` text
+ All rights reserved! You monkeys better not even look at my pro codes.
+ ```
=
=
= * On `2026-04-01 09:03` commit as `Bob <bob@example.com>`Implement the listing spec
Also modify it in some respects. I decided not to have a sub-command for
listing, but instead use the main command with --dry-run flag for the
purpose of listing.
Some parts of devlog code are dead now and will most likely be removed. The logic around getting the log is completely overhauled.
index d27f57c..6677495 100755
--- a/devlog.nu
+++ b/devlog.nu
@@ -1,5 +1,100 @@
=#!/usr/bin/env nu
=
+use std/log
+
+def main [
+ --since: datetime = 1970-01-01 # How far back should we look?
+ --until: datetime # When did you last committed anything good?
+ --projects-dir: path = "~/Projects", # Where do you keep your projects?
+ --day-start: string = "04:00" # Until how late can you code?
+ --out-dir: path = "drafts" # Where to write excavated posts
+ --config-file: path = "devlog.toml" # The configuration file
+ --dry-run # Just list the commits to excavate
+] {
+ # Read the config file, process and set some defaults
+ # TODO: DRY with `main list`
+ open $config_file
+ | update projects { update path { path expand } } # Resolve all project paths
+ | upsert frontmatter.projects_cell_path { default { [ extra projects ] } | into cell-path }
+ | let config
+
+ $until | default (date now) | let until
+
+ $projects_dir
+ | projects
+ | each { project log $since $until }
+ | flatten
+ | update time { |commit| $commit.time | into datetime }
+ | insert day {
+ get time
+ | if ($in | format date %T) < $day_start {
+ $in - 1day
+ } else {
+ $in
+ }
+ | format date %F
+ }
+ | insert project { |commit|
+ $config.projects
+ | where path == $commit.path
+ | first
+ | get name
+ }
+ | move day project author email subject time sha path --first
+ | let commits
+
+ if $dry_run {
+ $commits
+ | update time { format date }
+ | print
+ exit 0
+ }
+
+ # TODO: Allow setting in a config file
+ mkdir $out_dir
+
+ # for project_path in ($projects_dir | projects) {
+ # let project =
+ # if ($project | is-empty) {
+ # continue
+ # }
+
+ # let project_slug = $project.name | str kebab-case
+
+ # if ($project | get --optional ignore | default false ) {
+ # continue
+ # }
+
+ # # TODO: Iterate over days and write to a file
+ # for day in (main days $since) {
+ # let date = $day.start | format date %Y-%m-%d
+ # let out_path = ($out_dir | path join $"($date)-($project_slug).md")
+
+ # try {
+ # # TODO: Catch and print errors
+ # $project_path
+ # | project log $day.start $day.end
+ # | reverse
+ # | let log
+
+ # if ($log | is-empty) {
+ # "No commits on that day"
+ # continue
+ # }
+
+ # $log
+ # | format log $project.name $config
+ # | save --force $out_path
+ # } catch { |error|
+ # print --stderr $"Project path: ($project_path)"
+ # print --stderr $"Date: ($date)"
+ # print --stderr $"Output path: ($out_path)"
+ # print --stderr $error.rendered
+ # }
+ # }
+
+ # }
+}
=def "main days" [
= since: datetime,
= --day-start: string = "04:00"
@@ -17,38 +112,54 @@ def "main days" [
= | take while { |day| $day.start < $day.end }
=}
=
-def "main projects" [
- path: path
-]: nothing -> list<path> {
- $path
+def "projects" []: path -> list<path> {
+ $in
= | path join "**/.git"
= | glob $in
= | path dirname
=}
=
-def "main project log" [
- path: path,
+def "project log" [
= since: datetime,
= until: datetime,
-] {
- let log_format = "<--- commit --->%n%s%n<--- part --->%n%b%n<--- part --->%n"
- cd $path
-
- # Debugging:
- # print $env.PWD
- # let git_command = $'git log --since ($since | format date %+) --until ($until | format date %+) --unified --format="($log_format)"'
- # print $git_command
-
- # TODO: Handle binary stream somehow coming from Erna's log
- # This is a workaround for `skip` swallowing errors
- let chunks = git log --since=($since | format date %+) --until=($until | format date %+) --patch --format=($log_format)
- | split row "\n<--- commit --->\n"
-
- $chunks
- | skip 1 # First is empty
- | split column "\n<--- part --->\n"
- | rename "subject" "body" "diff"
- | update "diff" { | row | $row.diff | split row --regex "\ndiff --git .+" | skip 1 }
+]: path -> list {
+ let project_path = $in
+
+ cd $project_path
+
+ let format = {
+ "%s": "subject",
+ "%ad": "time",
+ "%an": "author",
+ "%ae": "email",
+ "%H": "sha",
+ }
+
+ $format
+ | columns
+ | each { $"⸨($in)⸩" } # Wrap in a double quote
+ | str join " "
+ | let log_format
+
+
+ $format
+ | values
+ | each { $"⸨{($in)}⸩" } # Wrap in a double quote and a curly brace
+ | str join " "
+ | let parse_format
+
+ log debug $"Log format ($log_format)"
+ log debug $"Parse format ($parse_format)"
+
+ $log_format
+ | $"git log --date=iso-strict --since=($since | format date %+) --until=($until | format date %+) --format=($in)"
+ | log info $in
+
+ $log_format
+ | git log --date=iso-strict --since=($since | format date %+) --until=($until | format date %+) --format=($in)
+ | parse $parse_format
+ | reverse
+ | insert path $project_path
=}
=
=def "format commit" [] {
@@ -84,61 +195,3 @@ def "format log" [
= ($in | each { | commit | format commit } | str join "\n\n")
= ] | str join "\n"
=}
-
-def main [
- since: datetime,
- --projects-dir: path = "~/Projects",
- --day-start: string = "04:00"
- --out-dir: path = "drafts"
- --config-file: path = "devlog.toml"
-] {
- # Read the config file, process and set some defaults
- let config = open $config_file
- | update projects { update path { path expand } } # Resolve all project paths
- | upsert frontmatter.projects_cell_path { default { [ extra projects ] } | into cell-path }
-
- # TODO: Allow setting in a config file
- mkdir $out_dir
-
- for project_path in (main projects $projects_dir) {
- let project = $config.projects
- | where path == $project_path
- | first
-
- if ($project | is-empty) {
- continue
- }
-
- let project_slug = $project.name | str kebab-case
-
- if ($project | get --optional ignore | default false ) {
- continue
- }
-
- # TODO: Iterate over days and write to a file
- for day in (main days $since) {
- let date = $day.start | format date %Y-%m-%d
- let out_path = ($out_dir | path join $"($date)-($project_slug).md")
-
- try {
- # TODO: Catch and print errors
- let log = main project log $project_path $day.start $day.end | reverse
-
- if ($log | is-empty) {
- "No commits on that day"
- continue
- }
-
- $log
- | format log $project.name $config
- | save --force $out_path
- } catch { |error|
- print --stderr $"Project path: ($project_path)"
- print --stderr $"Date: ($date)"
- print --stderr $"Output path: ($out_path)"
- print --stderr $error.rendered
- }
- }
-
- }
-}index e61e1af..cd8528d 100644
--- a/spec/BASIC.md
+++ b/spec/BASIC.md
@@ -7,15 +7,15 @@ interpreter: nu --stdin spec/interpreters/basic.nu
=This program excavates a devlog (i.e. a bunch of .md files) from commit messages in multiple repositories.
=
=
-## The `list` Subcommand
+## Listing Commits to Excavate
=
=``` yaml tbb
=tags: [ focus ]
=```
=
-IT should eventually be possible to evaluate this spec using TBB. The difficulty is, that any sample data to work on needs to be a git repository, while this project itself is version controlled using git. One way would be to have TBB make commits with fabricated dates, using `git commit --date`.
+Before you excavate, it might be smart to list the commits. You can use `--dry-run` flag for that. Consider the following scenario.
=
- * Write `weblog.toml`
+ * Write `devlog.toml`
=
= ``` toml
= [[projects]]
@@ -80,7 +80,7 @@ IT should eventually be possible to evaluate this spec using TBB. The difficulty
= ``` text
= Make Project Alpha work
=
- Coding hard. I should have used Tad Better Behavior to test my stuff.
+ Coding is hard for Bob. He should have used Tad Better Behavior to test his stuff before pushing it to prod!
= ```
=
= * Write `LICENSE`
@@ -109,8 +109,6 @@ IT should eventually be possible to evaluate this spec using TBB. The difficulty
= ```
=
= * On `2026-03-28 14:18` commit as `Alice <alice@example.com>`
-
-
=
= ``` text
= I quit
@@ -118,39 +116,44 @@ IT should eventually be possible to evaluate this spec using TBB. The difficulty
= Bob is an asshole.
= ```
=
+TODO: Setup a second project.
+
= * Change directory to `..`
=
= Leave the repository and go back to where the config file is.
=
- * Run the `devlog.nu list --projects-dir .` command
+ * Run the `devlog.nu --projects-dir . --dry-run` command
=
= TODO: Make it `devlog list`, without the extension,
=
- * Expect the output to contain `the heading row`
+ * Expect the output to contain `the header row`
=
= It contains column names, as produced by Nushell. The output format should be configurable.
=
= ``` regexp
- │ day +│ time +│ sha +│ author +│ email +│ title +│ project +│ path +│
+ │ +# +│ ++day +│ +project +│ +author +│ +email +│ +subject +│ +time +│ +sha +│ +path +│
= ```
+
= * Expect the output to contain `the first commit from Alice`
-
+
= ``` regex
- │ 2026-04-26 +│ 2026-04-27 13:22:00 +│ [0-9a-f]+ +│ Alice +│ alice@example.com +│ Make Project Alpha work +│ Project Alpha +│ .+/project-alpha/ +│
+ │ +0 +│ +2026-03-27 +│ +Project Alpha +│ +Alice +│ +alice@example.com +│ +Write a readme +│ +Fri, 27 Mar 2026 13:22:00 \+\d{4} │ +[0-9a-f]+ +│ +.+/project-alpha +│
= ```
=
+ > NOTE: To make the spec portable, the timezone, sha and part of the path has to be expressed as wildcard.
+
= * Expect the output to contain `the late night commit from Alice`
=
- Notice that the logical day is one before the actual day (in the second column), because work done at night is still counted toward the previous day. This can be controlled with `start-of-day` configuration parameter.
+ Notice that the logical day (in the second column) is one before the actual day (in the seventh column). That's because work done at night is still counted toward the previous day. This can be controlled with `start-of-day` configuration parameter.
=
= ``` regex
- │ 2026-03-27 │ 2026-03-28 03:06 +│ [0-9a-f]+ +│ Alice +│ alice@example.com +│ Write a readme +│ Project Alpha +│ .+/project-alpha/ +│
+ │ +2 +│ +2026-03-27 +│ +Project Alpha +│ +Alice +│ +alice@example.com +│ +Make Project Alpha work +│ +Sat, 28 Mar 2026 03:06:00 \+\d{4} │ +[0-9a-f]+ +│ +.+/project-alpha +│
= ```
=
= * Expect the output to contain `the silly commit from Bob`
=
= ``` regex
- │ 2026-04-01 │ 2026-04-01 09:03 +│ [0-9a-f]+ +│ Bob +│ bob@example.com +│ I've lawyered up +│ Project Alpha +│ .+/project-alpha/ +│
+ │ +3 +│ +2026-04-01 +│ +Project Alpha +│ +Bob +│ +bob@example.com +│ +I've lawyered up +│ +Wed, 1 Apr 2026 09:03:00 \+\d{4} +│ +[0-9a-f]+ +│ +.+/project-alpha +│
= ```
=
=## The `excavate` SubcommandWrite some thoughts about limiting time span and grouping
index cd8528d..c0ffaca 100644
--- a/spec/BASIC.md
+++ b/spec/BASIC.md
@@ -156,6 +156,16 @@ TODO: Setup a second project.
= │ +3 +│ +2026-04-01 +│ +Project Alpha +│ +Bob +│ +bob@example.com +│ +I've lawyered up +│ +Wed, 1 Apr 2026 09:03:00 \+\d{4} +│ +[0-9a-f]+ +│ +.+/project-alpha +│
= ```
=
+## Grouped view
+
+When using `--dry-run` it might be useful to see the commits grouped by project and day. Maybe there should be another flag? Or value?
+
+
+## Limiting the time span
+
+With `--since` and `--until` flags you can select a span of time from which commits are excavated.
+
+
=## The `excavate` Subcommand
=
=For each day run `git log` in each project. If there is any output, dump it to `devlog/<date>/<project-name>.md`. So each file should contain a log from a single project that was developed on a given day.Export the main function, let it return data
Now it's possible to do something like:
❯ use devlog.nu ❯ devlog --projects-dir . --dry-run | group-by project day --to-table
Helpful in development, but also can be useful in advanced use cases.
index 6677495..f276b89 100755
--- a/devlog.nu
+++ b/devlog.nu
@@ -2,7 +2,7 @@
=
=use std/log
=
-def main [
+export def main [
= --since: datetime = 1970-01-01 # How far back should we look?
= --until: datetime # When did you last committed anything good?
= --projects-dir: path = "~/Projects", # Where do you keep your projects?
@@ -46,8 +46,7 @@ def main [
= if $dry_run {
= $commits
= | update time { format date }
- | print
- exit 0
+ | return $in
= }
=
= # TODO: Allow setting in a config file
@@ -95,6 +94,7 @@ def main [
=
= # }
=}
+
=def "main days" [
= since: datetime,
= --day-start: string = "04:00"
Commits: 3
Write a spec for a list subcommand
The spec describes misadventures of Alice and Bob, working on a high-tech Project Alpha.
The subcommand is not implemented yet, and the interpreter is not ready to evaluate it, but that's how we roll in the BDD club.
index d426ca8..3303b7f 100644
--- a/spec/BASIC.md
+++ b/spec/BASIC.md
@@ -2,42 +2,149 @@
=interpreter: nu --stdin spec/interpreters/basic.nu
=---
=
-# Basic
+# The Dev Log Excavator
=
-This program generates a devlog for tad-lispy.com
+This program excavates a devlog (i.e. a bunch of .md files) from commit messages in multiple repositories.
=
-## Sample data
+
+## The `list` Subcommand
=
=IT should eventually be possible to evaluate this spec using TBB. The difficulty is, that any sample data to work on needs to be a git repository, while this project itself is version controlled using git. One way would be to have TBB make commits with fabricated dates, using `git commit --date`.
=
-## Get all projects
+ * Write `weblog.toml`
+
+ ``` toml
+ [[projects]]
+ name = "Project Alpha"
+ path = "./project-alpha"
+ ```
+
+ * Initialize a git repository `project-alpha`
=
-We need a mapping from `project-name` to `path`. This should sit in `projects.yaml`
+ * Write `README.md`
+
+ ``` markdown
+ # This is Project Alpha
+
+ The best project ever. Like seriosuly.
+ ```
+
+ * On `2026-03-27 13:22` commit as `Alice <alice@example.com>`
+
+ ``` text
+ Write a readme
+
+ It's very exciting to start a new project.
+ ```
+
+ * Write `src/main.rs`
+
+ ``` rust
+ fn main() {
+ println ("Hello, World!")
+ }
+ ```
=
-The listing of all paths with names guessed from path can be obtained like this:
+ * On `2026-03-27 14:06` commit as `Bob <bob@example.com>`
+
+ ``` text
+ Implement an MVP
+
+ I need to keep my phone charged. Investors are going to call any minute now.
+ ```
+
+ * Write `src/main.rs`
+
+ ``` rust
+ fn main() {
+ println!("Hello, World!")
+ }
+ ```
=
-``` nushell
-glob ~/Projects/**/.git
-| path dirname
-| each { |p| { name: ($p | path basename), path: $p } }
-| transpose --as-record -r
-| save --force all-projects.yaml
-```
+ * Write `CHANGELOG.md`
+
+ ``` markdown
+ # v1.0.0
+
+ * Now it's working
+ ```
=
+ * On `2026-03-28 03:06` commit as `Alice <alice@example.com>`
+
+ Notice that it's logically the same day - any work done until 04:00 AM is considered to belong to the previous day.
+
+ ``` text
+ Make Project Alpha work
+
+ Coding hard. I should have used Tad Better Behavior to test my stuff.
+ ```
=
-## Get days
+ * Write `LICENSE`
+
+ ``` text
+ All rights reserved! You monkeys better not even look at my pro codes.
+ ```
+
+
+ * On `2026-04-01 09:03` commit as `Bob <bob@example.com>`
+
+ Two days later
+
+ ``` text
+ I've lawyered up
+
+ With a perl like this project, I have to protect my IP.
+ ```
+
+ * Write `src/main.rs`
+
+ ``` rust
+ fn main() {
+ println ("Eat 💩, Bob!")
+ }
+ ```
=
-I want a list with `start` and `end` timestamps. It should support `--since` and `--until`. Until should by default be 4 A.M. today. Since will be supplied based on the last entry in the devlog (details tbd). First day should be from `since`, i.e. it probably won't be a full day, but a fraction.
+ * On `2026-03-28 14:18` commit as `Alice <alice@example.com>`
+
=
-An arbitrary date with a given time can be obtained with the following:
+
+ ``` text
+ I quit
+
+ Bob is an asshole.
+ ```
=
-``` nu
-("04:00" | date from-human) - 1day
-```
+ * Run the `devlog list --projects-path .` command
+
+ * Expect the output to contain `the heading row`
=
-So maybe I can just loop until I reach the time before `since`, emit the last fraction and break?
+ It contains column names, as produced by Nushell. The output format should be configurable.
+
+ ``` regexp
+
+ | day +\| time +\| sha +\| author +\| email +\| title +\| project +\| path +\|
+ ```
+ * Expect the output to contain `the first commit from Alice`
+
+ ``` regex
+ | 2026-04-26 | 2026-04-27 13:22:00 +\| [0-9a-f]+ +\| Alice +\| alice@example.com +\| Make Project Alpha work +\| Project Alpha +| .+/project-alpha/ +|
+ ```
+
+ * Expect the output to contain `the late night commit from Alice`
+
+ Notice that the logical day is one before the actual day (in the second column), because work done at night is still counted toward the previous day. This can be controlled with `start-of-day` configuration parameter.
+
+ ``` regex
+ | 2026-03-27 | 2026-03-28 03:06 +\| [0-9a-f]+ +\| Alice +\| alice@example.com +\| Write a readme +\| Project Alpha +| .+/project-alpha/ +|
+ ```
+
+ * Expect the output to contain `the silly commit from Bob`
+
+ ``` regex
+ | 2026-04-01 | 2026-04-01 09:03 +\| [0-9a-f]+ +\| Bob +\| bob@example.com +\| I've lawyered up +\| Project Alpha +| .+/project-alpha/ +|
+ ```
=
-## Generate the drafts
+## The `excavate` Subcommand
=
=For each day run `git log` in each project. If there is any output, dump it to `devlog/<date>/<project-name>.md`. So each file should contain a log from a single project that was developed on a given day.
=Correct devlog list scenario
The nushell tables are delimited not with pipe characters |, but with
a special unicode symbol │. The good thing about it is that, unlike
pipe characters, they don't need to be escaped.
For now the command has .nu extension, so that how it needs to be
called. I'm thinking about a way to change it. Maybe just rename the
file?
I've also removed the test scenario. It's going to be copied to the
TBB project itself, together with tbb.nu.
index 3303b7f..9a1451b 100644
--- a/spec/BASIC.md
+++ b/spec/BASIC.md
@@ -9,6 +9,10 @@ This program excavates a devlog (i.e. a bunch of .md files) from commit messages
=
=## The `list` Subcommand
=
+``` yaml tbb
+tags: [ focus ]
+```
+
=IT should eventually be possible to evaluate this spec using TBB. The difficulty is, that any sample data to work on needs to be a git repository, while this project itself is version controlled using git. One way would be to have TBB make commits with fabricated dates, using `git commit --date`.
=
= * Write `weblog.toml`
@@ -19,7 +23,7 @@ IT should eventually be possible to evaluate this spec using TBB. The difficulty
= path = "./project-alpha"
= ```
=
- * Initialize a git repository `project-alpha`
+ * Initialize a git repository at `project-alpha`
=
= * Write `README.md`
=
@@ -114,20 +118,25 @@ IT should eventually be possible to evaluate this spec using TBB. The difficulty
= Bob is an asshole.
= ```
=
- * Run the `devlog list --projects-path .` command
+ * Change directory to `..`
+
+ Leave the repository and go back to where the config file is.
+
+ * Run the `devlog.nu list --projects-dir .` command
+
+ TODO: Make it `devlog list`, without the extension,
=
= * Expect the output to contain `the heading row`
=
= It contains column names, as produced by Nushell. The output format should be configurable.
=
= ``` regexp
-
- | day +\| time +\| sha +\| author +\| email +\| title +\| project +\| path +\|
+ │ day +│ time +│ sha +│ author +│ email +│ title +│ project +│ path +│
= ```
= * Expect the output to contain `the first commit from Alice`
=
= ``` regex
- | 2026-04-26 | 2026-04-27 13:22:00 +\| [0-9a-f]+ +\| Alice +\| alice@example.com +\| Make Project Alpha work +\| Project Alpha +| .+/project-alpha/ +|
+ │ 2026-04-26 +│ 2026-04-27 13:22:00 +│ [0-9a-f]+ +│ Alice +│ alice@example.com +│ Make Project Alpha work +│ Project Alpha +│ .+/project-alpha/ +│
= ```
=
= * Expect the output to contain `the late night commit from Alice`
@@ -135,13 +144,13 @@ IT should eventually be possible to evaluate this spec using TBB. The difficulty
= Notice that the logical day is one before the actual day (in the second column), because work done at night is still counted toward the previous day. This can be controlled with `start-of-day` configuration parameter.
=
= ``` regex
- | 2026-03-27 | 2026-03-28 03:06 +\| [0-9a-f]+ +\| Alice +\| alice@example.com +\| Write a readme +\| Project Alpha +| .+/project-alpha/ +|
+ │ 2026-03-27 │ 2026-03-28 03:06 +│ [0-9a-f]+ +│ Alice +│ alice@example.com +│ Write a readme +│ Project Alpha +│ .+/project-alpha/ +│
= ```
=
= * Expect the output to contain `the silly commit from Bob`
=
= ``` regex
- | 2026-04-01 | 2026-04-01 09:03 +\| [0-9a-f]+ +\| Bob +\| bob@example.com +\| I've lawyered up +\| Project Alpha +| .+/project-alpha/ +|
+ │ 2026-04-01 │ 2026-04-01 09:03 +│ [0-9a-f]+ +│ Bob +│ bob@example.com +│ I've lawyered up +│ Project Alpha +│ .+/project-alpha/ +│
= ```
=
=## The `excavate` Subcommand
@@ -198,16 +207,3 @@ A project can be flagged to be ignored with the `ignore` key.
=
=Any project that is not listed, but had activity within the period for which a devlog is being generated should produce a warning with useful instructions.
=
-## Interpreter test
-
-This is not real spec, just a quick test of the WIP interpreter.
-
- * Do the twist `2` times
-
- * Do something `nice` and then `sleep` for `2` hours
-
- ``` text
- Zzzzz.....
- ```
-
- * Do the twist `4` timesWrite the real interpreter for the basic TBB suite
The spec is not satisfied yet, so the evaluation fails, but I believe
the interpreter is correct. Now finally off to work to implement the
devlog list command 😅
index 517943f..69c1644 100644
--- a/spec/interpreters/basic.nu
+++ b/spec/interpreters/basic.nu
@@ -1,34 +1,82 @@
=use tbb.nu
=
=def main [] {
- tbb run --initial-state { twists: 0 } {
- "Do something {0} and then {1} for {2} hours": { |how, what, how_long, data|
- $"OK boss. I will do something ($how) for ($how_long) hours and then I'll ($what)"
- | tbb observe text --transform { str upcase }
+ tbb run --initial-state { pwd: (mktemp --directory ) } {
+ "Write {0}": { |filename, data|
+ $data.state.pwd
+ | path join $filename
+ | tbb observe text --transform { $"Writing content to ($in)" }
+ | let path
+ | path dirname
+ | tbb observe text --transform { $"Parent directory ($in)" }
+ | mkdir $in
+
+
= $data.code_blocks
= | first
- | tbb observe snippet --caption "Received code block"
- | ignore # Do not send any state update.
+ | get value
+ | save --force $path
= },
- "Do the twist {0} times": { |how_many, data|
- # All arguments (except data) are passed as strings
- # TODO: Can we cast them based on closure parameter types?
- $how_many | into int | let how_many
=
- # This step demonstrates use of state. We count the total number of twists.
- for n in 1..$how_many {
- let done_twists = $n + $data.state.twists
+ "Initialize a git repository at {0}": {|dirname, data|
+ cd $data.state.pwd
+ mkdir $dirname
+ cd $dirname
+
+ git init
+ | complete
+ | tbb observe snippet --caption "Output from git init"
=
- $"Doing the twist ($done_twists) 🕺"
- | tbb observe text
=
- if $done_twists > 5 {
- error make "That's too many! My head is spinning 🤣"
- }
- }
+ { pwd: (pwd) }
+ },
+
+ "On {0} commit as {1}": { |time, author, data|
+ cd $data.state.pwd
=
- # Update the state for other steps. This record will be merged into the previous state.
- { twists: ($data.state.twists + $how_many) }
+ git add .
+
+ $data.code_blocks
+ | first
+ | get value
+ | git commit --date $time --author $author --message $in
+ | tbb observe text
+ | ignore
+ },
+ "Change directory to {0}": { |destination, data|
+ $data.state.pwd
+ | path join $destination
+ | { pwd: $in }
+ }
+
+ "Run the {0} command": { |command, data|
+ pwd
+ # | path join "bin/"
+ | tbb observe text --transform { $"Adding Devlog Excavator directory ($in) to PATH" }
+ | let devlog_path
+
+ $env.PATH = $env.PATH
+ | append $devlog_path
+ | tbb observe snippet --caption "Path environment variable"
+
+ cd $data.state.pwd
+
+ $command
+ | tbb shell $in
+ | { output: $in }
+ },
+ "Expect the output to contain {0}": { |label, data|
+ $data.code_blocks
+ | first
+ | get value
+ | let pattern
+ | tbb observe snippet --caption "The needle"
+
+ $data.state.output
+ | ansi strip
+ | tbb observe snippet --caption "The haystack"
+ | tbb assert match $pattern
= }
= }
=}
+index b2d1284..0bf7a1d 100644
--- a/spec/interpreters/tbb.nu
+++ b/spec/interpreters/tbb.nu
@@ -1,3 +1,5 @@
+use std/assert
+
=export def run [
= steps: record
= --initial-state: record = {}
@@ -118,3 +120,27 @@ export def "observe text" [
=
= $in # Pass the value on
=}
+
+export def "shell" [command: string]: string -> record {
+ $in
+ | sh -c $command
+ | complete
+ | observe snippet --caption $"Results of running `($command)`"
+ | if $in.exit_code != 0 {
+ error make {
+ msg: $"Command exited with code ($in.exit_code)",
+ labels: [{ text: $"Command:\n($command)", span: (metadata $command).span }]
+ }
+ } else {
+ $in.stdout
+ }
+}
+
+export def "assert match" [
+ pattern: string
+]: string -> string {
+ assert ($in | find --regex $pattern | is-not-empty) --error-label {
+ text: "Pattern not found in the input",
+ span: (metadata $pattern).span
+ }
+}
Commits: 3
Implement state management for TBB interpreter
It's implemented the functional / immutable way. Essentially each step
is an iterator in a fold operation. If the step returns a record, it's
going to be merged with current state and passed to the next steps as
data.state.
Currently there is a lot of noise on stderr. I decided not to clean it
up, so later I can transform it to TBB observations.
index 15b6d3f..d426ca8 100644
--- a/spec/BASIC.md
+++ b/spec/BASIC.md
@@ -95,10 +95,12 @@ Any project that is not listed, but had activity within the period for which a d
=
=This is not real spec, just a quick test of the WIP interpreter.
=
+ * Do the twist `2` times
+
= * Do something `nice` and then `sleep` for `2` hours
=
= ``` text
= Zzzzz.....
= ```
=
- * Do the twist `7` times
+ * Do the twist `4` timesindex 77cd3e2..3b4cbac 100644
--- a/spec/interpreters/basic.nu
+++ b/spec/interpreters/basic.nu
@@ -7,13 +7,28 @@ def main [] {
= $data.code_blocks | first | get value | print --stderr
= },
= "Do the twist {0} times": { |how_many, data|
+ # All arguments (except data) are passed as strings
+ # TODO: Can we cast them based on closure parameter types?
= $how_many | into int | let how_many
- if $how_many > 5 {
- error make "That's too many!"
- }
+
+ # This step demonstrates use of state. We count the total number of twists.
+ $data.state
+ | get --optional "twists" # On first run it won't be there
+ | default 0 # ...so we need an initial value
+ | let twists_so_far
+
= for n in 1..$how_many {
- $"Doing the twist ($n)" | print --stderr
+ let done_twists = $n + $twists_so_far
+
+ $"Doing the twist ($done_twists) 🕺" | print --stderr
+
+ if $done_twists > 5 {
+ error make "That's too many! My head is spinning 🤣"
+ }
= }
+
+ # Update the state for other steps. This will be merged.
+ { twists: ($twists_so_far + $how_many) }
= }
= }
=}index afb7882..e8512cd 100644
--- a/spec/interpreters/tbb.nu
+++ b/spec/interpreters/tbb.nu
@@ -6,23 +6,44 @@ export def run [steps: record]: string -> nothing {
= | print
= }
= | lines
- | each { handle incoming $steps }
+ | reduce --fold {} { |line, state|
+ print --stderr "State before the step"
+ $state | print --stderr
+
+ $line
+ | handle incoming $state $steps
+ | let state_update
+
+ print --stderr "Record update"
+ $state_update | describe --detailed | print --stderr
+
+ if ($state_update | describe | str starts-with "record") {
+ $state | merge $state_update
+ } else {
+ print --stderr $"Not a record: ($state_update)"
+ $state
+ }
+ }
=
= print --stderr "Interpreter done."
=}
=
-def "handle incoming" [steps: record] {
+def "handle incoming" [
+ state: record,
+ steps: record,
+] {
= let incoming = $in | from json
=
= print --stderr --raw $incoming
= match $incoming.type {
= Execute => {
= try {
- let step = $incoming.step
+ let step = $incoming.step | insert state $state
= print --stderr $"Executing step ($step.description)"
= let closure = $steps | get $step.variant
- do $closure ...$step.arguments $step
+ let state_update = do $closure ...$step.arguments $step
= { type: Success } | to json --raw | print
+ $state_update
= } catch { |err|
= {
= type: Failure,Convert the stderr logging to TBB observations
Recently I've started working on observations API for TBB. It allows to collect additional information during steps execution. Currently only two types of observation are implemented:
- text (just a string)
- snippet, rendered as a code block with:
- language (like json or rust)
- content (the code)
- optional metadata
- optional caption
Accordingly TBB exports two commands:
observe textobserve snippet
Both take content as input, allow a custom transformation before sending the observation (as demonstrated in basic.nu interpreter) and output the original value. This is important, as it allows to make observations from the middle of a pipeline.
The observe snippet by default sends a formatted Nushell table, with
language set to "table". To send code, one can use --raw <language>
flag. If the language is anything else than Nuon (Nushell Object
Notation), then the code should be a string (use transform if the input
is not a string already). As a special (but likely common) case,
structure data can be piped with --raw nuon and will be handled
correctly (although not formatter, i.e. the code will be all on a single
line). This is thanks to the fact that Nushell will serialize any
data structure to a Nuon string.
Finally the --caption flag is useful to explain what the snippet
represents. The text command doesn't have such a flag, as a text
observaion should be self-explanatory.
The tbb module itself is making a lot of observations. I'm still
trying to find out what is the best practice here, but I think it's good
to make generous observations, as they will all be hidden in a
successful scenario report. Should the scenario fail, the observations
can aid debugging.
In general, after the initial trouble with I/O, I think Nushell might be a great platform for TBB interpreters.
index 671416e..745410f 100644
--- a/flake.lock
+++ b/flake.lock
@@ -849,11 +849,11 @@
= "systems": "systems_2"
= },
= "locked": {
- "lastModified": 1775638737,
- "narHash": "sha256-h2QcCWhV7hA+v8Tmyh5zmiZmeDLj23Rh5ciJwWiuhJI=",
+ "lastModified": 1776280929,
+ "narHash": "sha256-Z6wXuMFjoRzeCgMSEhb1H93w5SoqtfC539ZRdemaDEU=",
= "ref": "refs/heads/main",
- "rev": "ed447ca6ee1a377bf4afcde6a785fdc0870e4f13",
- "revCount": 162,
+ "rev": "b70531b7ce4b77625cd8824842d5b0cdb62a2b88",
+ "revCount": 163,
= "type": "git",
= "url": "http://codeberg.org/tad-lispy/tad-better-behavior"
= },index 3b4cbac..801b3f1 100644
--- a/spec/interpreters/basic.nu
+++ b/spec/interpreters/basic.nu
@@ -3,8 +3,12 @@ use tbb.nu
=def main [] {
= tbb run {
= "Do something {0} and then {1} for {2} hours": { |how, what, how_long, data|
- print --stderr $"OK boss. I will do something ($how) for ($how_long) hours and then I'll ($what)"
- $data.code_blocks | first | get value | print --stderr
+ $"OK boss. I will do something ($how) for ($how_long) hours and then I'll ($what)"
+ | tbb observe text --transform { str upcase }
+ $data.code_blocks
+ | first
+ | tbb observe snippet --caption "Received code block"
+ | ignore # Do not send any state update.
= },
= "Do the twist {0} times": { |how_many, data|
= # All arguments (except data) are passed as strings
@@ -20,14 +24,15 @@ def main [] {
= for n in 1..$how_many {
= let done_twists = $n + $twists_so_far
=
- $"Doing the twist ($done_twists) 🕺" | print --stderr
+ $"Doing the twist ($done_twists) 🕺"
+ | tbb observe text
=
= if $done_twists > 5 {
= error make "That's too many! My head is spinning 🤣"
= }
= }
=
- # Update the state for other steps. This will be merged.
+ # Update the state for other steps. This record will be merged into the previous state.
= { twists: ($twists_so_far + $how_many) }
= }
= }index e8512cd..45dc7f5 100644
--- a/spec/interpreters/tbb.nu
+++ b/spec/interpreters/tbb.nu
@@ -7,20 +7,20 @@ export def run [steps: record]: string -> nothing {
= }
= | lines
= | reduce --fold {} { |line, state|
- print --stderr "State before the step"
- $state | print --stderr
+ $state
+ | observe snippet --caption "State before the step"
=
= $line
= | handle incoming $state $steps
= | let state_update
=
- print --stderr "Record update"
- $state_update | describe --detailed | print --stderr
-
- if ($state_update | describe | str starts-with "record") {
+ if ($state_update | describe --detailed | $in.type == "record") {
+ $state_update
+ | observe snippet --caption "State update to be merged"
= $state | merge $state_update
= } else {
- print --stderr $"Not a record: ($state_update)"
+ $state_update
+ | observe snippet --caption "The value returned by the step is not a record; Not updating"
= $state
= }
= }
@@ -32,14 +32,19 @@ def "handle incoming" [
= state: record,
= steps: record,
=] {
- let incoming = $in | from json
+ $in
+ | observe snippet --raw "json" --caption "Received input"
+ | from json
+ | observe snippet --caption "Decoded control message"
+ | let incoming
=
- print --stderr --raw $incoming
= match $incoming.type {
= Execute => {
= try {
- let step = $incoming.step | insert state $state
- print --stderr $"Executing step ($step.description)"
+ $incoming.step
+ | insert state $state
+ | let step
+
= let closure = $steps | get $step.variant
= let state_update = do $closure ...$step.arguments $step
= { type: Success } | to json --raw | print
@@ -57,3 +62,56 @@ def "handle incoming" [
= }
= }
=}
+
+export def "observe snippet" [
+ --raw: string,
+ --caption: string,
+ --meta: string = "",
+ --transform: closure
+]: any -> any {
+ # By default transform is identity
+ $transform
+ | default { {||} }
+ | let transform
+
+ $in
+ | do $transform
+ | let transformed
+ | if ($raw | is-empty) { table --expand } else { $in }
+ | let formatted
+
+ {
+ type: Snippet
+ caption: $caption,
+ language: ($raw | default "table"),
+ meta: $meta,
+ content: ($formatted | default "nothing")
+ }
+ | to json --raw
+ | print
+
+ $in # Pass the value on
+}
+
+export def "observe text" [
+ --transform: closure
+] {
+ # By default transform is identity
+ $transform
+ | default { {||} }
+ | let transform
+
+ $in
+ | do $transform
+ | let formatted
+
+
+ {
+ type: Text
+ content: $formatted
+ }
+ | to json --raw
+ | print
+
+ $in # Pass the value on
+}Allow setting initial state of a TBB interpreter
Now with the --initial-state flag to tbb run command, an interpreter
can set an initial state. This simplifies steps implementation, as they
don't have to worry about expected state missing - as evidenced by the
"Do the twist" step.
The only remaining plumbing in the steps is type conversion from string.
It would be very nice to have it done implicitly and in a generic
fashion (i.e. in the tbb module).
index 801b3f1..517943f 100644
--- a/spec/interpreters/basic.nu
+++ b/spec/interpreters/basic.nu
@@ -1,7 +1,7 @@
=use tbb.nu
=
=def main [] {
- tbb run {
+ tbb run --initial-state { twists: 0 } {
= "Do something {0} and then {1} for {2} hours": { |how, what, how_long, data|
= $"OK boss. I will do something ($how) for ($how_long) hours and then I'll ($what)"
= | tbb observe text --transform { str upcase }
@@ -16,13 +16,8 @@ def main [] {
= $how_many | into int | let how_many
=
= # This step demonstrates use of state. We count the total number of twists.
- $data.state
- | get --optional "twists" # On first run it won't be there
- | default 0 # ...so we need an initial value
- | let twists_so_far
-
= for n in 1..$how_many {
- let done_twists = $n + $twists_so_far
+ let done_twists = $n + $data.state.twists
=
= $"Doing the twist ($done_twists) 🕺"
= | tbb observe text
@@ -33,7 +28,7 @@ def main [] {
= }
=
= # Update the state for other steps. This record will be merged into the previous state.
- { twists: ($twists_so_far + $how_many) }
+ { twists: ($data.state.twists + $how_many) }
= }
= }
=}index 45dc7f5..b2d1284 100644
--- a/spec/interpreters/tbb.nu
+++ b/spec/interpreters/tbb.nu
@@ -1,4 +1,7 @@
-export def run [steps: record]: string -> nothing {
+export def run [
+ steps: record
+ --initial-state: record = {}
+]: string -> nothing {
= tee { # This preserves the input stream for the lines command
= print --stderr "Interpreter listening..."
= { type: InterpreterState, ready: true }
@@ -6,7 +9,7 @@ export def run [steps: record]: string -> nothing {
= | print
= }
= | lines
- | reduce --fold {} { |line, state|
+ | reduce --fold $initial_state { |line, state|
= $state
= | observe snippet --caption "State before the step"
=
Commits: 7
WIP: Setup TBB to evaluate the spec
Yesterday I've made an attempt to implement a Nushell interpreter for
Tad Better Behavior. I got stuck on handling I/O. An interpreter needs
to first write a line to indicate that it's ready to handle input, and
then handle it line by line. After each line received on stdin it
needs to respond with one or more lines on stdout and wait for more
input. Once the input stream is closed, an interpreter should terminate.
I tried two approaches:
- Using
inputin a loop
This works very well, except the interpreter won't terminate when the input is closed, leaving zombie processes after each evaluation.
- Using
lineswithout any explicit input
This is what I'm committing here. I've found this technique at
https://github.com/nushell/nushell/issues/14901. It doesn't wait for
input and terminates immediately, unless I remove (comment out) any
output (to stdout and stderr) before calling the lines command.
It seems like a bug in Nushell, so I've wrote an issue about it:
https://github.com/nushell/nushell/issues/18033
index 7d5ec13..671416e 100644
--- a/flake.lock
+++ b/flake.lock
@@ -165,11 +165,11 @@
= "rust-overlay": "rust-overlay"
= },
= "locked": {
- "lastModified": 1775848233,
- "narHash": "sha256-+V6K66AsFCxD0PmKOASSSFUdEjmAtIwX4XlQ+2JBrmk=",
+ "lastModified": 1776080969,
+ "narHash": "sha256-2uZxF6q0KOIH8tq3r65ZCz7dM+XYP3avtRP8IBq5zYY=",
= "owner": "cachix",
= "repo": "devenv",
- "rev": "cf4f57c61f5dc9d58300bdf18102d9cf5b4f29ea",
+ "rev": "8d558a84fa38242a7f13781670fee1a6a8902b48",
= "type": "github"
= },
= "original": {
@@ -565,11 +565,11 @@
= ]
= },
= "locked": {
- "lastModified": 1775657489,
- "narHash": "sha256-v1KwZrIMGpteHPwxXvbapc7o3iduhU61phPUfyrnjM8=",
+ "lastModified": 1775984952,
+ "narHash": "sha256-FciKF0weMXVirN+ZBSniR4wpKx168cBa9IXhuaLOkkU=",
= "owner": "cachix",
= "repo": "nix",
- "rev": "5c0da4397902105a84611c6d49e9d39a618ca025",
+ "rev": "e671135fc5b783798c444e4ece101f6b15ff0c46",
= "type": "github"
= },
= "original": {
@@ -656,11 +656,11 @@
= "nixpkgs-src": {
= "flake": false,
= "locked": {
- "lastModified": 1773840656,
- "narHash": "sha256-9tpvMGFteZnd3gRQZFlRCohVpqooygFuy9yjuyRL2C0=",
+ "lastModified": 1775888245,
+ "narHash": "sha256-nwASzrRDD1JBEu/o8ekKYEXm/oJW6EMCzCRdrwcLe90=",
= "owner": "NixOS",
= "repo": "nixpkgs",
- "rev": "9cf7092bdd603554bd8b63c216e8943cf9b12512",
+ "rev": "13043924aaa7375ce482ebe2494338e058282925",
= "type": "github"
= },
= "original": {
@@ -707,11 +707,11 @@
= "nixpkgs-src": "nixpkgs-src"
= },
= "locked": {
- "lastModified": 1774287239,
- "narHash": "sha256-W3krsWcDwYuA3gPWsFA24YAXxOFUL6iIlT6IknAoNSE=",
+ "lastModified": 1776097194,
+ "narHash": "sha256-XD4DsgNcfXC5nlCxlAcCP5hSjTYlgLXEIoTj7fKkQg4=",
= "owner": "cachix",
= "repo": "devenv-nixpkgs",
- "rev": "fa7125ea7f1ae5430010a6e071f68375a39bd24c",
+ "rev": "6e8a07b02f6f8557ffab71274feac9827bcc2532",
= "type": "github"
= },
= "original": {
@@ -783,7 +783,8 @@
= "inputs": {
= "devenv": "devenv",
= "nixpkgs": "nixpkgs_4",
- "systems": "systems"
+ "systems": "systems",
+ "tad-better-behavior": "tad-better-behavior"
= }
= },
= "rust-overlay": {
@@ -822,6 +823,45 @@
= "type": "github"
= }
= },
+ "systems_2": {
+ "locked": {
+ "lastModified": 1681028828,
+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+ "owner": "nix-systems",
+ "repo": "default",
+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-systems",
+ "repo": "default",
+ "type": "github"
+ }
+ },
+ "tad-better-behavior": {
+ "inputs": {
+ "devenv": [
+ "devenv"
+ ],
+ "nixpkgs": [
+ "nixpkgs"
+ ],
+ "systems": "systems_2"
+ },
+ "locked": {
+ "lastModified": 1775638737,
+ "narHash": "sha256-h2QcCWhV7hA+v8Tmyh5zmiZmeDLj23Rh5ciJwWiuhJI=",
+ "ref": "refs/heads/main",
+ "rev": "ed447ca6ee1a377bf4afcde6a785fdc0870e4f13",
+ "revCount": 162,
+ "type": "git",
+ "url": "http://codeberg.org/tad-lispy/tad-better-behavior"
+ },
+ "original": {
+ "type": "git",
+ "url": "http://codeberg.org/tad-lispy/tad-better-behavior"
+ }
+ },
= "treefmt-nix": {
= "inputs": {
= "nixpkgs": [index b6bf848..5b67689 100644
--- a/flake.nix
+++ b/flake.nix
@@ -4,6 +4,11 @@
= systems.url = "github:nix-systems/default";
= devenv.url = "github:cachix/devenv";
= devenv.inputs.nixpkgs.follows = "nixpkgs";
+ tad-better-behavior = {
+ url = "git+http://codeberg.org/tad-lispy/tad-better-behavior";
+ inputs.nixpkgs.follows = "nixpkgs";
+ inputs.devenv.follows = "devenv";
+ };
= };
=
= nixConfig = {
@@ -11,7 +16,7 @@
= extra-substituters = "https://devenv.cachix.org";
= };
=
- outputs = { self, nixpkgs, devenv, systems, ... } @ inputs:
+ outputs = { self, nixpkgs, devenv, systems, tad-better-behavior, ... } @ inputs:
= let
= forEachSystem = nixpkgs.lib.genAttrs (import systems);
= in
@@ -20,6 +25,7 @@
= (system:
= let
= pkgs = nixpkgs.legacyPackages.${system};
+ tbb = tad-better-behavior.packages.${system};
= in
= {
= default = devenv.lib.mkShell {
@@ -27,7 +33,10 @@
= modules = [
= {
= # https://devenv.sh/reference/options/
- packages = [ pkgs.nushell ];
+ packages = [
+ pkgs.nushell
+ tbb.default
+ ];
= }
= ];
= };index 531b178..94eeb4c 100644
--- a/spec/BASIC.md
+++ b/spec/BASIC.md
@@ -1,3 +1,7 @@
+---
+interpreter: nu --stdin spec/interpreters/tbb.nu
+---
+
=# Basic
=
=This program generates a devlog for tad-lispy.comnew file mode 100644
index 0000000..2bc42df
--- /dev/null
+++ b/spec/interpreters/tbb.nu
@@ -0,0 +1,19 @@
+def main [] {
+ print --stderr "Interpreter listening..."
+
+ { type: InterpreterState, ready: true }
+ | to json --raw
+ | print
+
+ # FIXME: It doesn't wait for input if anything is printed
+ # See https://github.com/nushell/nushell/issues/18033
+ lines | each { str reverse | print --stderr }
+
+ # Alternative aproach to I/O. Also doesnt work:
+ # loop {
+ # input | str reverse | print --stderr
+ # }
+
+ print --stderr "Interpreter done."
+}
+Elaborate on projects list in a front matter
index 94eeb4c..9e97e08 100644
--- a/spec/BASIC.md
+++ b/spec/BASIC.md
@@ -39,7 +39,11 @@ So maybe I can just loop until I reach the time before `since`, emit the last fr
=
=## Generate the drafts
=
-For each day run `git log` in each project. If there is any output, dump it to `devlog/<date>/<project-name>.md`. So each file should contain a log from a single project that was developed on a given day. The project name should be listed in a front-matter (under `extra.projects`), so they can be linked to project page. Each commit should start with an `h2` followed by the message body and a complete diff.
+For each day run `git log` in each project. If there is any output, dump it to `devlog/<date>/<project-name>.md`. So each file should contain a log from a single project that was developed on a given day.
+
+The project name should be listed in a front-matter (under `extra.projects`), so they can be linked to projects pages. It's plural, and should be an array, because sometimes work in one repository is related to multiple projects - for example integrating TBB can be linked to the integration, but also TBB.
+
+Each commit should start with an `h2` followed by the message body and a complete diff.
=
=
=## Unlisted projectsLet it write project name in front matter
It writes it in two places:
- title
- extra.projects (configurable, a singleton list)
The idea is, that title might be changed for editorial purposes (for example to reflect particular developments of the day) while projects list is more of a metadata (it can be used to link pages together). Also, projects are sometimes renamed. In that case, it's ok to change the metadata, but titles are often used to generate URLs, so it's better to keep them stable and not break any links.
I demonstrate how to change the cell path of projects in
devlog-sample.toml. Nushell is really nice to work with data formats
and structures.
index 698c93f..9a8d531 100644
--- a/devlog-sample.toml
+++ b/devlog-sample.toml
@@ -1,3 +1,10 @@
+[frontmatter]
+
+# By default projects are listed under extra.projects. This example shows how
+# to use custom cell path to list them under taxonomies, for example to use with
+# Zola (https://www.getzola.org/documentation/content/taxonomies/).
+projects_cell_path = [ "taxonomies", "projects" ]
+
=[[projects]]
=name = "Tad Better Behavior"
=path = "~/Projects/tad-better-behavior"
@@ -7,5 +14,5 @@ name = "Better Tech Club website"
=path = "~/Projects/better-tech-club/bettertech.eu"
=
=[[projects]]
-name = "DevLog"
+name = "DevLog Extracavator"
=path = "."index 7622b93..d27f57c 100755
--- a/devlog.nu
+++ b/devlog.nu
@@ -67,8 +67,16 @@ def "format diff" [] {
=
=def "format log" [
= title: string
+ config: record
=] {
+ let frontmatter = { title: $title }
+ | insert $config.frontmatter.projects_cell_path [ $title ]
+
= [
+ $"---"
+ ($frontmatter | to yaml)
+ $"---"
+ $""
= $"# ($title)"
= $""
= $"Commits: ($in | length)"
@@ -84,8 +92,10 @@ def main [
= --out-dir: path = "drafts"
= --config-file: path = "devlog.toml"
=] {
+ # Read the config file, process and set some defaults
= let config = open $config_file
= | update projects { update path { path expand } } # Resolve all project paths
+ | upsert frontmatter.projects_cell_path { default { [ extra projects ] } | into cell-path }
=
= # TODO: Allow setting in a config file
= mkdir $out_dir
@@ -120,7 +130,7 @@ def main [
= }
=
= $log
- | format log $project.name
+ | format log $project.name $config
= | save --force $out_path
= } catch { |error|
= print --stderr $"Project path: ($project_path)"Fix the Nushell TBB interpreter I/O problem
Thanks to Juhan280 at GitHub:
https://github.com/nushell/nushell/issues/18033#issuecomment-4241910862
Apparently the first command takes over the input stream. It's still a
bit fuzzy to me, but the solution with tee works, so I can continue
from here. It's still a WIP, but at least the I/O seems to be working
correctly.
index 2bc42df..5a8bfa2 100644
--- a/spec/interpreters/tbb.nu
+++ b/spec/interpreters/tbb.nu
@@ -1,18 +1,12 @@
=def main [] {
- print --stderr "Interpreter listening..."
-
- { type: InterpreterState, ready: true }
- | to json --raw
- | print
-
- # FIXME: It doesn't wait for input if anything is printed
- # See https://github.com/nushell/nushell/issues/18033
- lines | each { str reverse | print --stderr }
-
- # Alternative aproach to I/O. Also doesnt work:
- # loop {
- # input | str reverse | print --stderr
- # }
+ tee { # This preserves the input stream for the lines command
+ print --stderr "Interpreter listening..."
+ { type: InterpreterState, ready: true }
+ | to json --raw
+ | print
+ }
+ | lines
+ | each { str reverse | print --stderr }
=
= print --stderr "Interpreter done."
=}Setup the incoming handler in the TBB interpreter
I have to focus on something else now, but I want to set the stage for next steps.
index 5a8bfa2..4fefadb 100644
--- a/spec/interpreters/tbb.nu
+++ b/spec/interpreters/tbb.nu
@@ -6,8 +6,14 @@ def main [] {
= | print
= }
= | lines
- | each { str reverse | print --stderr }
+ | each { handle incoming }
=
= print --stderr "Interpreter done."
=}
=
+def "handle incoming" [] {
+ $in
+ | from json
+ # TODO: Actually process TBB incoming messages and send responses
+ | print --stderr
+}Implement POC TBB interpreter in Nushell
All the basics are covered:
- Step implementation (by variant)
- Execution
- Arguments passing (no type casting (yet?))
- Extra data passing (code_blocks, tables, etc.)
- Success reporting
- Error reporting (including nice "did you mean?" on typos)
No observations yet. Also the interpreter and internals are all mixed together.
There is also some nonsense spec scenario to test it all. When evaluated it fails on purpose.
index 9e97e08..d20b3a7 100644
--- a/spec/BASIC.md
+++ b/spec/BASIC.md
@@ -90,3 +90,15 @@ By default `./devlog.toml` shall be used, but it can be changed with `--config-f
=A project can be flagged to be ignored with the `ignore` key.
=
=Any project that is not listed, but had activity within the period for which a devlog is being generated should produce a warning with useful instructions.
+
+## Interpreter test
+
+This is not real spec, just a quick test of the WIP interpreter.
+
+ * Do something `nice` and then `sleep` for `2` hours
+
+ ``` text
+ Zzzzz.....
+ ```
+
+ * Do the twist `7` timesindex 4fefadb..69652ea 100644
--- a/spec/interpreters/tbb.nu
+++ b/spec/interpreters/tbb.nu
@@ -1,3 +1,19 @@
+let steps = {
+ "Do something {0} and then {1} for {2} hours": { |how, what, how_long, data|
+ print --stderr $"OK boss. I will do something ($how) for ($how_long) hours and then I'll ($what)"
+ $data.code_blocks | first | get value | print --stderr
+ },
+ "Do the twist {0} times": { |how_many, data|
+ $how_many | into int | let how_many
+ if $how_many > 5 {
+ error make "That's too many!"
+ }
+ for n in 1..$how_many {
+ $"Doing the twist ($n)" | print --stderr
+ }
+ }
+}
+
=def main [] {
= tee { # This preserves the input stream for the lines command
= print --stderr "Interpreter listening..."
@@ -12,8 +28,27 @@ def main [] {
=}
=
=def "handle incoming" [] {
- $in
- | from json
- # TODO: Actually process TBB incoming messages and send responses
- | print --stderr
+ let incoming = $in | from json
+
+ print --stderr --raw $incoming
+ match $incoming.type {
+ Execute => {
+ try {
+ let step = $incoming.step
+ print --stderr $"Executing step ($step.description)"
+ let closure = $steps | get $step.variant
+ do $closure ...$step.arguments $step
+ { type: Success } | to json --raw | print
+ } catch { |err|
+ {
+ type: Failure,
+ reason: $err.msg
+ hint: $err.rendered
+ } | to json --raw | print
+ }
+ },
+ _ => {
+ error make "Unsupported message type: ($incoming.type)"
+ }
+ }
=}Separate plumbing from the TBB interpreter
The "plumbing" involes setting up I/O, which IMO is a bit convoluted. Developers who implement interpreters shouldn't be bothered with it. They should just write steps' implementations and everything should work.
Now the is a tbb module holds the reusable parts and plumbing, while a
new basic.nu interpreter defines the steps and calls tbb run,
passing the steps. The tbb run (previously main) does all the I/O
setup.
The only caveat is that tbb run must be the very first command in the
interpreter. Otherwise the I/O is broken. See the (ongoing) discussion
here:
https://github.com/nushell/nushell/issues/18033#issuecomment-4241910862
index d20b3a7..15b6d3f 100644
--- a/spec/BASIC.md
+++ b/spec/BASIC.md
@@ -1,5 +1,5 @@
=---
-interpreter: nu --stdin spec/interpreters/tbb.nu
+interpreter: nu --stdin spec/interpreters/basic.nu
=---
=
=# Basicnew file mode 100644
index 0000000..77cd3e2
--- /dev/null
+++ b/spec/interpreters/basic.nu
@@ -0,0 +1,19 @@
+use tbb.nu
+
+def main [] {
+ tbb run {
+ "Do something {0} and then {1} for {2} hours": { |how, what, how_long, data|
+ print --stderr $"OK boss. I will do something ($how) for ($how_long) hours and then I'll ($what)"
+ $data.code_blocks | first | get value | print --stderr
+ },
+ "Do the twist {0} times": { |how_many, data|
+ $how_many | into int | let how_many
+ if $how_many > 5 {
+ error make "That's too many!"
+ }
+ for n in 1..$how_many {
+ $"Doing the twist ($n)" | print --stderr
+ }
+ }
+ }
+}index 69652ea..afb7882 100644
--- a/spec/interpreters/tbb.nu
+++ b/spec/interpreters/tbb.nu
@@ -1,20 +1,4 @@
-let steps = {
- "Do something {0} and then {1} for {2} hours": { |how, what, how_long, data|
- print --stderr $"OK boss. I will do something ($how) for ($how_long) hours and then I'll ($what)"
- $data.code_blocks | first | get value | print --stderr
- },
- "Do the twist {0} times": { |how_many, data|
- $how_many | into int | let how_many
- if $how_many > 5 {
- error make "That's too many!"
- }
- for n in 1..$how_many {
- $"Doing the twist ($n)" | print --stderr
- }
- }
-}
-
-def main [] {
+export def run [steps: record]: string -> nothing {
= tee { # This preserves the input stream for the lines command
= print --stderr "Interpreter listening..."
= { type: InterpreterState, ready: true }
@@ -22,12 +6,12 @@ def main [] {
= | print
= }
= | lines
- | each { handle incoming }
+ | each { handle incoming $steps }
=
= print --stderr "Interpreter done."
=}
=
-def "handle incoming" [] {
+def "handle incoming" [steps: record] {
= let incoming = $in | from json
=
= print --stderr --raw $incoming
Commits: 7
Write about binary data in diffs
index 34979e9..2768f3f 100644
--- a/spec/BASIC.md
+++ b/spec/BASIC.md
@@ -42,3 +42,16 @@ What about new projects that are not listed yet?
=If some work was done on such a project, there should be a warning about it.
=
=If a project is then added to the list, running the command again should not override existing drafts, but create new files for the newly listed projects.
+
+## Binary files issue
+
+A non-unicode fragment in the diff breaks my script. For example in a repo with some PDFs checked in I'm getting this:
+
+``` nushell-session
+❯ git log --since 2026-04-01T00:00:00+00:00 --until 2026-04-12T00:00:00+00:00 --unified --format="<--- commit --->%n%s%n<--- part --->%b%n<--- part --->" | split row "<--- commit --->"
+Error: nu::shell::only_supports_this_input_type
+```
+
+The PDF files are mixed - they are mostly text, but can contain embedded binary blobs (like fonts or bitmap images).
+
+The solutions is the `.gitattributes` in a project repo to filter our any file that might contain binary fragments.Write about an idea to produce sample data
index 2768f3f..415f213 100644
--- a/spec/BASIC.md
+++ b/spec/BASIC.md
@@ -2,6 +2,9 @@
=
=This program generates a devlog for tad-lispy.com
=
+## Sample data
+
+IT should eventually be possible to evaluate this spec using TBB. The difficulty is, that any sample data to work on needs to be a git repository, while this project itself is version controlled using git. One way would be to have TBB make commits with fabricated dates, using `git commit --date`.
=
=## Get all projects
=Work around skip swallowing errors
I elaborate on in in the spec.
index a87f7bf..a74113b 100755
--- a/devlog.nu
+++ b/devlog.nu
@@ -42,8 +42,11 @@ def "main project log" [
= # print $git_command
=
= # TODO: Handle binary stream somehow coming from Erna's log
- git log --since=($since | format date %+) --until=($until | format date %+) --patch --format=($log_format)
+ # This is a workaround for `skip` swallowing errors
+ let chunks = git log --since=($since | format date %+) --until=($until | format date %+) --patch --format=($log_format)
= | split row "<--- commit --->"
+
+ $chunks
= | skip 1 # First is empty
= | split column "<--- part --->"
= | rename "subject" "body" "diff"index 415f213..42c8077 100644
--- a/spec/BASIC.md
+++ b/spec/BASIC.md
@@ -58,3 +58,18 @@ Error: nu::shell::only_supports_this_input_type
=The PDF files are mixed - they are mostly text, but can contain embedded binary blobs (like fonts or bitmap images).
=
=The solutions is the `.gitattributes` in a project repo to filter our any file that might contain binary fragments.
+
+A secondary problem is that Nushell's `skip` command seems to be swallowing the errors in a pipeline. If the preceding command fails (like above), it just produces empty list. Consider this, same as above but with an extra step:
+
+``` nushell-session
+❯ git log --since 2026-04-01T00:00:00+00:00 --until 2026-04-12T00:00:00+00:00 --unified --format="<--- commit --->%n%s%n<--- part --->%b%n<--- part --->"
+| split row "<--- commit --->"
+| skip 1
+╭────────────╮
+│ empty list │
+╰────────────╯
+```
+
+This seems to be specific to `skip`. Other commands fail as expected.
+
+> TODO: Open an issue in Nushell. There is a similar one about `transpose`: <https://github.com/nushell/nushell/issues/14027>.Make the git log markers stricter
The <--- commit ---> and <--- part ---> markers are now immediately
preceded and followed by newline characters, to avoid confusion when
those strings are used in code or docs - for example in this project.
index a74113b..b87590b 100755
--- a/devlog.nu
+++ b/devlog.nu
@@ -33,7 +33,7 @@ def "main project log" [
= since: datetime,
= until: datetime,
=] {
- let log_format = "<--- commit --->%n%s%n<--- part --->%b%n<--- part --->"
+ let log_format = "<--- commit --->%n%s%n<--- part --->%n%b%n<--- part --->%n"
= cd $path
=
= # Debugging:
@@ -44,13 +44,13 @@ def "main project log" [
= # TODO: Handle binary stream somehow coming from Erna's log
= # This is a workaround for `skip` swallowing errors
= let chunks = git log --since=($since | format date %+) --until=($until | format date %+) --patch --format=($log_format)
- | split row "<--- commit --->"
+ | split row "\n<--- commit --->\n"
=
= $chunks
= | skip 1 # First is empty
- | split column "<--- part --->"
+ | split column "\n<--- part --->\n"
= | rename "subject" "body" "diff"
- | update "diff" { | row | $row.diff | split row --regex "diff --git .+" | skip 1 }
+ | update "diff" { | row | $row.diff | split row --regex "\ndiff --git .+" | skip 1 }
=}
=
=def "format commit" [] {Make it iterate over days and write the documents
So basically put it all together. The main command now scans all git
repositories under the --project-dir (defaults to ~/Projects) and
writes markdown documents to files named like 2026-04-13-my-project.md
to --out-dir (defaults to ./drafts/). So there is one output file
for each day and each project. Days without commits are skipped.
When errors occur, extra diagnostic information is printed. It's a bit ugly. Debugging Nushell scripts seems tricky. Maybe I don't know how to do it properly.
index b87590b..5bad215 100755
--- a/devlog.nu
+++ b/devlog.nu
@@ -81,10 +81,37 @@ def "format log" [
=
=def main [
= since: datetime,
- --path: path = "~/Projects",
+ --projects-dir: path = "~/Projects",
= --day-start: string = "04:00"
+ --out-dir: path = "drafts"
=] {
- # TODO: Handle multiple projects and write the markdown file
- main project log $path $since (date now)
- | format log "Test"
+ mkdir $out_dir
+
+ for project in (main projects $projects_dir | transpose name path) {
+ let project_slug = $project.name | str kebab-case
+
+ # TODO: Iterate over days and write to a file
+ for day in (main days $since) {
+ let date = $day.start | format date %Y-%m-%d
+ let out_path = ($out_dir | path join $"($date)-($project_slug).md")
+
+ try {
+ # TODO: Catch and print errors
+ let log = main project log $project.path $day.start $day.end
+ if ($log | is-empty) {
+ "No commits on that day"
+ continue
+ }
+ $log
+ | format log $project.name
+ | save --force $out_path
+ } catch { |error|
+ print --stderr $"Project path: ($project.path)"
+ print --stderr $"Date: ($date)"
+ print --stderr $"Output path: ($out_path)"
+ print --stderr $error.rendered
+ }
+ }
+
+ }
=}Make the order of commits chronological
The commits done earlier in the day should be listed first, unlike in git log, where they are listed in reverse chronological order.
index 5bad215..cd1aa1a 100755
--- a/devlog.nu
+++ b/devlog.nu
@@ -97,7 +97,7 @@ def main [
=
= try {
= # TODO: Catch and print errors
- let log = main project log $project.path $day.start $day.end
+ let log = main project log $project.path $day.start $day.end | reverse
= if ($log | is-empty) {
= "No commits on that day"
= continueWrite a spec and partially implement configuration
The name and ignore part are working, but there is no warning about recent activity. The difficulty is, that with current architecture it's difficult to print the warning only once. A refactoring is needed before this can be implemented.
A sample configuration file is provided.
index 69d93e1..74bb444 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,4 @@
+/drafts/
+/devlog.toml
=.direnv
=.devenvnew file mode 100644
index 0000000..698c93f
--- /dev/null
+++ b/devlog-sample.toml
@@ -0,0 +1,11 @@
+[[projects]]
+name = "Tad Better Behavior"
+path = "~/Projects/tad-better-behavior"
+
+[[projects]]
+name = "Better Tech Club website"
+path = "~/Projects/better-tech-club/bettertech.eu"
+
+[[projects]]
+name = "DevLog"
+path = "."index cd1aa1a..7622b93 100755
--- a/devlog.nu
+++ b/devlog.nu
@@ -19,13 +19,11 @@ def "main days" [
=
=def "main projects" [
= path: path
-] {
+]: nothing -> list<path> {
= $path
= | path join "**/.git"
= | glob $in
= | path dirname
- | each { |p| { name: ($p | path basename), path: $p } }
- | transpose --as-record -r
=}
=
=def "main project log" [
@@ -84,12 +82,29 @@ def main [
= --projects-dir: path = "~/Projects",
= --day-start: string = "04:00"
= --out-dir: path = "drafts"
+ --config-file: path = "devlog.toml"
=] {
+ let config = open $config_file
+ | update projects { update path { path expand } } # Resolve all project paths
+
+ # TODO: Allow setting in a config file
= mkdir $out_dir
=
- for project in (main projects $projects_dir | transpose name path) {
+ for project_path in (main projects $projects_dir) {
+ let project = $config.projects
+ | where path == $project_path
+ | first
+
+ if ($project | is-empty) {
+ continue
+ }
+
= let project_slug = $project.name | str kebab-case
=
+ if ($project | get --optional ignore | default false ) {
+ continue
+ }
+
= # TODO: Iterate over days and write to a file
= for day in (main days $since) {
= let date = $day.start | format date %Y-%m-%d
@@ -97,16 +112,18 @@ def main [
=
= try {
= # TODO: Catch and print errors
- let log = main project log $project.path $day.start $day.end | reverse
+ let log = main project log $project_path $day.start $day.end | reverse
+
= if ($log | is-empty) {
= "No commits on that day"
= continue
= }
+
= $log
= | format log $project.name
= | save --force $out_path
= } catch { |error|
- print --stderr $"Project path: ($project.path)"
+ print --stderr $"Project path: ($project_path)"
= print --stderr $"Date: ($date)"
= print --stderr $"Output path: ($out_path)"
= print --stderr $error.renderedindex 42c8077..531b178 100644
--- a/spec/BASIC.md
+++ b/spec/BASIC.md
@@ -73,3 +73,12 @@ A secondary problem is that Nushell's `skip` command seems to be swallowing the
=This seems to be specific to `skip`. Other commands fail as expected.
=
=> TODO: Open an issue in Nushell. There is a similar one about `transpose`: <https://github.com/nushell/nushell/issues/14027>.
+
+
+## Configuration file
+
+By default `./devlog.toml` shall be used, but it can be changed with `--config-file` flag. The file must list all the projects for which devlog is to be generated. The name of the project comes from there. See the devlog-sample.toml file.
+
+A project can be flagged to be ignored with the `ignore` key.
+
+Any project that is not listed, but had activity within the period for which a devlog is being generated should produce a warning with useful instructions.
Commits: 9
Setup a devenv flake
new file mode 100644
index 0000000..c5d670d
--- /dev/null
+++ b/.envrc
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+
+if ! has nix_direnv_version || ! nix_direnv_version 3.1.0; then
+ source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.1.0/direnvrc" "sha256-yMJ2OVMzrFaDPn7q8nCBZFRYpL/f0RcHzhmw/i6btJM="
+fi
+
+export DEVENV_IN_DIRENV_SHELL=true
+
+watch_file flake.nix
+watch_file flake.lock
+if ! use flake . --no-pure-eval; then
+ echo "devenv could not be built. The devenv environment was not loaded. Make the necessary changes to devenv.nix and hit enter to try again." >&2
+finew file mode 100644
index 0000000..69d93e1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+.direnv
+.devenvnew file mode 100644
index 0000000..7d5ec13
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,850 @@
+{
+ "nodes": {
+ "cachix": {
+ "inputs": {
+ "devenv": [
+ "devenv"
+ ],
+ "flake-compat": [
+ "devenv",
+ "flake-compat"
+ ],
+ "git-hooks": [
+ "devenv",
+ "git-hooks"
+ ],
+ "nixpkgs": [
+ "devenv",
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1767714506,
+ "narHash": "sha256-WaTs0t1CxhgxbIuvQ97OFhDTVUGd1HA+KzLZUZBhe0s=",
+ "owner": "cachix",
+ "repo": "cachix",
+ "rev": "894c649f0daaa38bbcfb21de64be47dfa7cd0ec9",
+ "type": "github"
+ },
+ "original": {
+ "owner": "cachix",
+ "ref": "latest",
+ "repo": "cachix",
+ "type": "github"
+ }
+ },
+ "cachix_2": {
+ "inputs": {
+ "devenv": [
+ "devenv",
+ "crate2nix"
+ ],
+ "flake-compat": [
+ "devenv",
+ "crate2nix"
+ ],
+ "git-hooks": "git-hooks",
+ "nixpkgs": "nixpkgs"
+ },
+ "locked": {
+ "lastModified": 1767714506,
+ "narHash": "sha256-WaTs0t1CxhgxbIuvQ97OFhDTVUGd1HA+KzLZUZBhe0s=",
+ "owner": "cachix",
+ "repo": "cachix",
+ "rev": "894c649f0daaa38bbcfb21de64be47dfa7cd0ec9",
+ "type": "github"
+ },
+ "original": {
+ "owner": "cachix",
+ "ref": "latest",
+ "repo": "cachix",
+ "type": "github"
+ }
+ },
+ "cachix_3": {
+ "inputs": {
+ "devenv": [
+ "devenv",
+ "crate2nix",
+ "crate2nix_stable"
+ ],
+ "flake-compat": [
+ "devenv",
+ "crate2nix",
+ "crate2nix_stable"
+ ],
+ "git-hooks": "git-hooks_2",
+ "nixpkgs": "nixpkgs_2"
+ },
+ "locked": {
+ "lastModified": 1767714506,
+ "narHash": "sha256-WaTs0t1CxhgxbIuvQ97OFhDTVUGd1HA+KzLZUZBhe0s=",
+ "owner": "cachix",
+ "repo": "cachix",
+ "rev": "894c649f0daaa38bbcfb21de64be47dfa7cd0ec9",
+ "type": "github"
+ },
+ "original": {
+ "owner": "cachix",
+ "ref": "latest",
+ "repo": "cachix",
+ "type": "github"
+ }
+ },
+ "crate2nix": {
+ "inputs": {
+ "cachix": "cachix_2",
+ "crate2nix_stable": "crate2nix_stable",
+ "devshell": "devshell_2",
+ "flake-compat": "flake-compat_2",
+ "flake-parts": "flake-parts_2",
+ "nix-test-runner": "nix-test-runner_2",
+ "nixpkgs": [
+ "devenv",
+ "nixpkgs"
+ ],
+ "pre-commit-hooks": "pre-commit-hooks_2"
+ },
+ "locked": {
+ "lastModified": 1772186516,
+ "narHash": "sha256-8s28pzmQ6TOIUzznwFibtW1CMieMUl1rYJIxoQYor58=",
+ "owner": "rossng",
+ "repo": "crate2nix",
+ "rev": "ba5dd398e31ee422fbe021767eb83b0650303a6e",
+ "type": "github"
+ },
+ "original": {
+ "owner": "rossng",
+ "repo": "crate2nix",
+ "rev": "ba5dd398e31ee422fbe021767eb83b0650303a6e",
+ "type": "github"
+ }
+ },
+ "crate2nix_stable": {
+ "inputs": {
+ "cachix": "cachix_3",
+ "crate2nix_stable": [
+ "devenv",
+ "crate2nix",
+ "crate2nix_stable"
+ ],
+ "devshell": "devshell",
+ "flake-compat": "flake-compat",
+ "flake-parts": "flake-parts",
+ "nix-test-runner": "nix-test-runner",
+ "nixpkgs": "nixpkgs_3",
+ "pre-commit-hooks": "pre-commit-hooks"
+ },
+ "locked": {
+ "lastModified": 1769627083,
+ "narHash": "sha256-SUuruvw1/moNzCZosHaa60QMTL+L9huWdsCBN6XZIic=",
+ "owner": "nix-community",
+ "repo": "crate2nix",
+ "rev": "7c33e664668faecf7655fa53861d7a80c9e464a2",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-community",
+ "ref": "0.15.0",
+ "repo": "crate2nix",
+ "type": "github"
+ }
+ },
+ "devenv": {
+ "inputs": {
+ "cachix": "cachix",
+ "crate2nix": "crate2nix",
+ "flake-compat": "flake-compat_3",
+ "flake-parts": "flake-parts_3",
+ "git-hooks": "git-hooks_3",
+ "nix": "nix",
+ "nixd": "nixd",
+ "nixpkgs": [
+ "nixpkgs"
+ ],
+ "rust-overlay": "rust-overlay"
+ },
+ "locked": {
+ "lastModified": 1775848233,
+ "narHash": "sha256-+V6K66AsFCxD0PmKOASSSFUdEjmAtIwX4XlQ+2JBrmk=",
+ "owner": "cachix",
+ "repo": "devenv",
+ "rev": "cf4f57c61f5dc9d58300bdf18102d9cf5b4f29ea",
+ "type": "github"
+ },
+ "original": {
+ "owner": "cachix",
+ "repo": "devenv",
+ "type": "github"
+ }
+ },
+ "devshell": {
+ "inputs": {
+ "nixpkgs": [
+ "devenv",
+ "crate2nix",
+ "crate2nix_stable",
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1768818222,
+ "narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=",
+ "owner": "numtide",
+ "repo": "devshell",
+ "rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76",
+ "type": "github"
+ },
+ "original": {
+ "owner": "numtide",
+ "repo": "devshell",
+ "type": "github"
+ }
+ },
+ "devshell_2": {
+ "inputs": {
+ "nixpkgs": [
+ "devenv",
+ "crate2nix",
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1768818222,
+ "narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=",
+ "owner": "numtide",
+ "repo": "devshell",
+ "rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76",
+ "type": "github"
+ },
+ "original": {
+ "owner": "numtide",
+ "repo": "devshell",
+ "type": "github"
+ }
+ },
+ "flake-compat": {
+ "locked": {
+ "lastModified": 1733328505,
+ "narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=",
+ "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
+ "revCount": 69,
+ "type": "tarball",
+ "url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz"
+ },
+ "original": {
+ "type": "tarball",
+ "url": "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz"
+ }
+ },
+ "flake-compat_2": {
+ "locked": {
+ "lastModified": 1733328505,
+ "narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=",
+ "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
+ "revCount": 69,
+ "type": "tarball",
+ "url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz"
+ },
+ "original": {
+ "type": "tarball",
+ "url": "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz"
+ }
+ },
+ "flake-compat_3": {
+ "flake": false,
+ "locked": {
+ "lastModified": 1767039857,
+ "narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
+ "owner": "edolstra",
+ "repo": "flake-compat",
+ "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
+ "type": "github"
+ },
+ "original": {
+ "owner": "edolstra",
+ "repo": "flake-compat",
+ "type": "github"
+ }
+ },
+ "flake-parts": {
+ "inputs": {
+ "nixpkgs-lib": [
+ "devenv",
+ "crate2nix",
+ "crate2nix_stable",
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1768135262,
+ "narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=",
+ "owner": "hercules-ci",
+ "repo": "flake-parts",
+ "rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac",
+ "type": "github"
+ },
+ "original": {
+ "owner": "hercules-ci",
+ "repo": "flake-parts",
+ "type": "github"
+ }
+ },
+ "flake-parts_2": {
+ "inputs": {
+ "nixpkgs-lib": [
+ "devenv",
+ "crate2nix",
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1768135262,
+ "narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=",
+ "owner": "hercules-ci",
+ "repo": "flake-parts",
+ "rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac",
+ "type": "github"
+ },
+ "original": {
+ "owner": "hercules-ci",
+ "repo": "flake-parts",
+ "type": "github"
+ }
+ },
+ "flake-parts_3": {
+ "inputs": {
+ "nixpkgs-lib": [
+ "devenv",
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1772408722,
+ "narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
+ "owner": "hercules-ci",
+ "repo": "flake-parts",
+ "rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
+ "type": "github"
+ },
+ "original": {
+ "owner": "hercules-ci",
+ "repo": "flake-parts",
+ "type": "github"
+ }
+ },
+ "git-hooks": {
+ "inputs": {
+ "flake-compat": [
+ "devenv",
+ "crate2nix",
+ "cachix",
+ "flake-compat"
+ ],
+ "gitignore": "gitignore",
+ "nixpkgs": [
+ "devenv",
+ "crate2nix",
+ "cachix",
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1765404074,
+ "narHash": "sha256-+ZDU2d+vzWkEJiqprvV5PR26DVFN2vgddwG5SnPZcUM=",
+ "owner": "cachix",
+ "repo": "git-hooks.nix",
+ "rev": "2d6f58930fbcd82f6f9fd59fb6d13e37684ca529",
+ "type": "github"
+ },
+ "original": {
+ "owner": "cachix",
+ "repo": "git-hooks.nix",
+ "type": "github"
+ }
+ },
+ "git-hooks_2": {
+ "inputs": {
+ "flake-compat": [
+ "devenv",
+ "crate2nix",
+ "crate2nix_stable",
+ "cachix",
+ "flake-compat"
+ ],
+ "gitignore": "gitignore_2",
+ "nixpkgs": [
+ "devenv",
+ "crate2nix",
+ "crate2nix_stable",
+ "cachix",
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1765404074,
+ "narHash": "sha256-+ZDU2d+vzWkEJiqprvV5PR26DVFN2vgddwG5SnPZcUM=",
+ "owner": "cachix",
+ "repo": "git-hooks.nix",
+ "rev": "2d6f58930fbcd82f6f9fd59fb6d13e37684ca529",
+ "type": "github"
+ },
+ "original": {
+ "owner": "cachix",
+ "repo": "git-hooks.nix",
+ "type": "github"
+ }
+ },
+ "git-hooks_3": {
+ "inputs": {
+ "flake-compat": [
+ "devenv",
+ "flake-compat"
+ ],
+ "gitignore": "gitignore_5",
+ "nixpkgs": [
+ "devenv",
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1772893680,
+ "narHash": "sha256-JDqZMgxUTCq85ObSaFw0HhE+lvdOre1lx9iI6vYyOEs=",
+ "owner": "cachix",
+ "repo": "git-hooks.nix",
+ "rev": "8baab586afc9c9b57645a734c820e4ac0a604af9",
+ "type": "github"
+ },
+ "original": {
+ "owner": "cachix",
+ "repo": "git-hooks.nix",
+ "type": "github"
+ }
+ },
+ "gitignore": {
+ "inputs": {
+ "nixpkgs": [
+ "devenv",
+ "crate2nix",
+ "cachix",
+ "git-hooks",
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1709087332,
+ "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
+ "owner": "hercules-ci",
+ "repo": "gitignore.nix",
+ "rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
+ "type": "github"
+ },
+ "original": {
+ "owner": "hercules-ci",
+ "repo": "gitignore.nix",
+ "type": "github"
+ }
+ },
+ "gitignore_2": {
+ "inputs": {
+ "nixpkgs": [
+ "devenv",
+ "crate2nix",
+ "crate2nix_stable",
+ "cachix",
+ "git-hooks",
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1709087332,
+ "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
+ "owner": "hercules-ci",
+ "repo": "gitignore.nix",
+ "rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
+ "type": "github"
+ },
+ "original": {
+ "owner": "hercules-ci",
+ "repo": "gitignore.nix",
+ "type": "github"
+ }
+ },
+ "gitignore_3": {
+ "inputs": {
+ "nixpkgs": [
+ "devenv",
+ "crate2nix",
+ "crate2nix_stable",
+ "pre-commit-hooks",
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1709087332,
+ "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
+ "owner": "hercules-ci",
+ "repo": "gitignore.nix",
+ "rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
+ "type": "github"
+ },
+ "original": {
+ "owner": "hercules-ci",
+ "repo": "gitignore.nix",
+ "type": "github"
+ }
+ },
+ "gitignore_4": {
+ "inputs": {
+ "nixpkgs": [
+ "devenv",
+ "crate2nix",
+ "pre-commit-hooks",
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1709087332,
+ "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
+ "owner": "hercules-ci",
+ "repo": "gitignore.nix",
+ "rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
+ "type": "github"
+ },
+ "original": {
+ "owner": "hercules-ci",
+ "repo": "gitignore.nix",
+ "type": "github"
+ }
+ },
+ "gitignore_5": {
+ "inputs": {
+ "nixpkgs": [
+ "devenv",
+ "git-hooks",
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1709087332,
+ "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
+ "owner": "hercules-ci",
+ "repo": "gitignore.nix",
+ "rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
+ "type": "github"
+ },
+ "original": {
+ "owner": "hercules-ci",
+ "repo": "gitignore.nix",
+ "type": "github"
+ }
+ },
+ "nix": {
+ "inputs": {
+ "flake-compat": [
+ "devenv",
+ "flake-compat"
+ ],
+ "flake-parts": [
+ "devenv",
+ "flake-parts"
+ ],
+ "git-hooks-nix": [
+ "devenv",
+ "git-hooks"
+ ],
+ "nixpkgs": [
+ "devenv",
+ "nixpkgs"
+ ],
+ "nixpkgs-23-11": [
+ "devenv"
+ ],
+ "nixpkgs-regression": [
+ "devenv"
+ ]
+ },
+ "locked": {
+ "lastModified": 1775657489,
+ "narHash": "sha256-v1KwZrIMGpteHPwxXvbapc7o3iduhU61phPUfyrnjM8=",
+ "owner": "cachix",
+ "repo": "nix",
+ "rev": "5c0da4397902105a84611c6d49e9d39a618ca025",
+ "type": "github"
+ },
+ "original": {
+ "owner": "cachix",
+ "ref": "devenv-2.34",
+ "repo": "nix",
+ "type": "github"
+ }
+ },
+ "nix-test-runner": {
+ "flake": false,
+ "locked": {
+ "lastModified": 1588761593,
+ "narHash": "sha256-FKJykltAN/g3eIceJl4SfDnnyuH2jHImhMrXS2KvGIs=",
+ "owner": "stoeffel",
+ "repo": "nix-test-runner",
+ "rev": "c45d45b11ecef3eb9d834c3b6304c05c49b06ca2",
+ "type": "github"
+ },
+ "original": {
+ "owner": "stoeffel",
+ "repo": "nix-test-runner",
+ "type": "github"
+ }
+ },
+ "nix-test-runner_2": {
+ "flake": false,
+ "locked": {
+ "lastModified": 1588761593,
+ "narHash": "sha256-FKJykltAN/g3eIceJl4SfDnnyuH2jHImhMrXS2KvGIs=",
+ "owner": "stoeffel",
+ "repo": "nix-test-runner",
+ "rev": "c45d45b11ecef3eb9d834c3b6304c05c49b06ca2",
+ "type": "github"
+ },
+ "original": {
+ "owner": "stoeffel",
+ "repo": "nix-test-runner",
+ "type": "github"
+ }
+ },
+ "nixd": {
+ "inputs": {
+ "flake-parts": [
+ "devenv",
+ "flake-parts"
+ ],
+ "nixpkgs": [
+ "devenv",
+ "nixpkgs"
+ ],
+ "treefmt-nix": "treefmt-nix"
+ },
+ "locked": {
+ "lastModified": 1773634079,
+ "narHash": "sha256-49qb4QNMv77VOeEux+sMd0uBhPvvHgVc0r938Bulvbo=",
+ "owner": "nix-community",
+ "repo": "nixd",
+ "rev": "8ecf93d4d93745e05ea53534e8b94f5e9506e6bd",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-community",
+ "repo": "nixd",
+ "type": "github"
+ }
+ },
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1765186076,
+ "narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixos-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "nixpkgs-src": {
+ "flake": false,
+ "locked": {
+ "lastModified": 1773840656,
+ "narHash": "sha256-9tpvMGFteZnd3gRQZFlRCohVpqooygFuy9yjuyRL2C0=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "9cf7092bdd603554bd8b63c216e8943cf9b12512",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixpkgs-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "nixpkgs_2": {
+ "locked": {
+ "lastModified": 1765186076,
+ "narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixos-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "nixpkgs_3": {
+ "locked": {
+ "lastModified": 1769433173,
+ "narHash": "sha256-Gf1dFYgD344WZ3q0LPlRoWaNdNQq8kSBDLEWulRQSEs=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "13b0f9e6ac78abbbb736c635d87845c4f4bee51b",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixpkgs-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "nixpkgs_4": {
+ "inputs": {
+ "nixpkgs-src": "nixpkgs-src"
+ },
+ "locked": {
+ "lastModified": 1774287239,
+ "narHash": "sha256-W3krsWcDwYuA3gPWsFA24YAXxOFUL6iIlT6IknAoNSE=",
+ "owner": "cachix",
+ "repo": "devenv-nixpkgs",
+ "rev": "fa7125ea7f1ae5430010a6e071f68375a39bd24c",
+ "type": "github"
+ },
+ "original": {
+ "owner": "cachix",
+ "ref": "rolling",
+ "repo": "devenv-nixpkgs",
+ "type": "github"
+ }
+ },
+ "pre-commit-hooks": {
+ "inputs": {
+ "flake-compat": [
+ "devenv",
+ "crate2nix",
+ "crate2nix_stable",
+ "flake-compat"
+ ],
+ "gitignore": "gitignore_3",
+ "nixpkgs": [
+ "devenv",
+ "crate2nix",
+ "crate2nix_stable",
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1769069492,
+ "narHash": "sha256-Efs3VUPelRduf3PpfPP2ovEB4CXT7vHf8W+xc49RL/U=",
+ "owner": "cachix",
+ "repo": "pre-commit-hooks.nix",
+ "rev": "a1ef738813b15cf8ec759bdff5761b027e3e1d23",
+ "type": "github"
+ },
+ "original": {
+ "owner": "cachix",
+ "repo": "pre-commit-hooks.nix",
+ "type": "github"
+ }
+ },
+ "pre-commit-hooks_2": {
+ "inputs": {
+ "flake-compat": [
+ "devenv",
+ "crate2nix",
+ "flake-compat"
+ ],
+ "gitignore": "gitignore_4",
+ "nixpkgs": [
+ "devenv",
+ "crate2nix",
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1769069492,
+ "narHash": "sha256-Efs3VUPelRduf3PpfPP2ovEB4CXT7vHf8W+xc49RL/U=",
+ "owner": "cachix",
+ "repo": "pre-commit-hooks.nix",
+ "rev": "a1ef738813b15cf8ec759bdff5761b027e3e1d23",
+ "type": "github"
+ },
+ "original": {
+ "owner": "cachix",
+ "repo": "pre-commit-hooks.nix",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "devenv": "devenv",
+ "nixpkgs": "nixpkgs_4",
+ "systems": "systems"
+ }
+ },
+ "rust-overlay": {
+ "inputs": {
+ "nixpkgs": [
+ "devenv",
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1773630837,
+ "narHash": "sha256-zJhgAGnbVKeBMJOb9ctZm4BGS/Rnrz+5lfSXTVah4HQ=",
+ "owner": "oxalica",
+ "repo": "rust-overlay",
+ "rev": "f600ea449c7b5bb596fa1cf21c871cc5b9e31316",
+ "type": "github"
+ },
+ "original": {
+ "owner": "oxalica",
+ "repo": "rust-overlay",
+ "type": "github"
+ }
+ },
+ "systems": {
+ "locked": {
+ "lastModified": 1681028828,
+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+ "owner": "nix-systems",
+ "repo": "default",
+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-systems",
+ "repo": "default",
+ "type": "github"
+ }
+ },
+ "treefmt-nix": {
+ "inputs": {
+ "nixpkgs": [
+ "devenv",
+ "nixd",
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1772660329,
+ "narHash": "sha256-IjU1FxYqm+VDe5qIOxoW+pISBlGvVApRjiw/Y/ttJzY=",
+ "owner": "numtide",
+ "repo": "treefmt-nix",
+ "rev": "3710e0e1218041bbad640352a0440114b1e10428",
+ "type": "github"
+ },
+ "original": {
+ "owner": "numtide",
+ "repo": "treefmt-nix",
+ "type": "github"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}new file mode 100644
index 0000000..f90aba3
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,42 @@
+{
+ inputs = {
+ nixpkgs.url = "github:cachix/devenv-nixpkgs/rolling";
+ systems.url = "github:nix-systems/default";
+ devenv.url = "github:cachix/devenv";
+ devenv.inputs.nixpkgs.follows = "nixpkgs";
+ };
+
+ nixConfig = {
+ extra-trusted-public-keys = "devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw=";
+ extra-substituters = "https://devenv.cachix.org";
+ };
+
+ outputs = { self, nixpkgs, devenv, systems, ... } @ inputs:
+ let
+ forEachSystem = nixpkgs.lib.genAttrs (import systems);
+ in
+ {
+ devShells = forEachSystem
+ (system:
+ let
+ pkgs = nixpkgs.legacyPackages.${system};
+ in
+ {
+ default = devenv.lib.mkShell {
+ inherit inputs pkgs;
+ modules = [
+ {
+ # https://devenv.sh/reference/options/
+ packages = [ pkgs.hello ];
+
+ enterShell = ''
+ hello
+ '';
+
+ processes.hello.exec = "hello";
+ }
+ ];
+ };
+ });
+ };
+}Write some requirements and thoughts in a spec file
new file mode 100644
index 0000000..9a9a14c
--- /dev/null
+++ b/spec/BASIC.md
@@ -0,0 +1,37 @@
+# Basic
+
+This program generates a devlog for tad-lispy.com
+
+
+## Get all projects
+
+We need a mapping from `project-name` to `path`. This should sit in `projects.yaml`
+
+The listing of all paths with names guessed from path can be obtained like this:
+
+``` nushell
+glob ~/Projects/**/.git
+| path dirname
+| each { |p| { name: ($p | path basename), path: $p } }
+| transpose --as-record -r
+| save --force all-projects.yaml
+```
+
+
+## Get days
+
+I want a list with `start` and `end` timestamps. It should support `--since` and `--until`. Until should by default be 4 A.M. today. Since will be supplied based on the last entry in the devlog (details tbd). First day should be from `since`, i.e. it probably won't be a full day, but a fraction.
+
+An arbitrary date with a given time can be obtained with the following:
+
+``` nu
+("04:00" | date from-human) - 1day
+```
+
+So maybe I can just loop until I reach the time before `since`, emit the last fraction and break?
+
+## Generate the drafts
+
+For each day run `git log` in each project. If there is any output, dump it to `devlog/<date>/<project-name>.md`. So each file should contain a log from a single project that was developed on a given day. The project name should be listed in a front-matter (under `extra.projects`), so they can be linked to project page. Each commit should start with an `h2` followed by the message body and a complete diff.
+
+ - What about new projects that are not listed yet?Implement a days command to get a list of records
Given a date since and an optional day start, it produces a list of
records with start and end of each day.
new file mode 100755
index 0000000..2710393
--- /dev/null
+++ b/devlog.nu
@@ -0,0 +1,17 @@
+def days [
+ since: datetime,
+ --day-start: string = "04:00"
+] {
+ let until = $day_start | date from-human
+ # TODO: Allow setting --until: datetime and be smart about day-start - the last day might be a fraction!
+
+ 0..
+ | each { |n| $n * 1day }
+ | each { |duration| $until - $duration }
+ | each { |start| {
+ start: ([$start $since] | math max),
+ end: ($start + 1day)
+ } }
+ | take while { |day| $day.start < $day.end }
+}
+index f90aba3..b6bf848 100644
--- a/flake.nix
+++ b/flake.nix
@@ -27,13 +27,7 @@
= modules = [
= {
= # https://devenv.sh/reference/options/
- packages = [ pkgs.hello ];
-
- enterShell = ''
- hello
- '';
-
- processes.hello.exec = "hello";
+ packages = [ pkgs.nushell ];
= }
= ];
= };Make the script runnable with main function
So it can be executed without sourcing it first, like this
nu devlog.nu 2026-03-21index 2710393..ba04ac8 100755
--- a/devlog.nu
+++ b/devlog.nu
@@ -15,3 +15,6 @@ def days [
= | take while { |day| $day.start < $day.end }
=}
=
+def main [since: datetime] {
+ days $since
+}Expose the days subcommand, add a shebang
Now the script can be run as
./devlog days 2026-03-20index ba04ac8..2dd0fd5 100755
--- a/devlog.nu
+++ b/devlog.nu
@@ -1,4 +1,6 @@
-def days [
+#!/usr/bin/env nu
+
+def "main days" [
= since: datetime,
= --day-start: string = "04:00"
=] {
@@ -15,6 +17,7 @@ def days [
= | take while { |day| $day.start < $day.end }
=}
=
-def main [since: datetime] {
- days $since
+def main [] {
+ print "Not implemented yet"
+ exit 1
=}Write a spec about unlisted projects
index 9a9a14c..34979e9 100644
--- a/spec/BASIC.md
+++ b/spec/BASIC.md
@@ -34,4 +34,11 @@ So maybe I can just loop until I reach the time before `since`, emit the last fr
=
=For each day run `git log` in each project. If there is any output, dump it to `devlog/<date>/<project-name>.md`. So each file should contain a log from a single project that was developed on a given day. The project name should be listed in a front-matter (under `extra.projects`), so they can be linked to project page. Each commit should start with an `h2` followed by the message body and a complete diff.
=
- - What about new projects that are not listed yet?
+
+## Unlisted projects
+
+What about new projects that are not listed yet?
+
+If some work was done on such a project, there should be a warning about it.
+
+If a project is then added to the list, running the command again should not override existing drafts, but create new files for the newly listed projects.Write a command to list all projects
It produces a large name -> path record.
index 2dd0fd5..1f501f1 100755
--- a/devlog.nu
+++ b/devlog.nu
@@ -17,6 +17,17 @@ def "main days" [
= | take while { |day| $day.start < $day.end }
=}
=
+def "main projects" [
+ path: path
+] {
+ $path
+ | path join "**/.git"
+ | glob $in
+ | path dirname
+ | each { |p| { name: ($p | path basename), path: $p } }
+ | transpose --as-record -r
+}
+
=def main [] {
= print "Not implemented yet"
= exit 1Implement the project log sub-command
It parses the output from git log, including a unified diff and
extracts a Nushell table with subject, body and diff columns. The
diff is split by file.
index 1f501f1..632e9d5 100755
--- a/devlog.nu
+++ b/devlog.nu
@@ -28,7 +28,26 @@ def "main projects" [
= | transpose --as-record -r
=}
=
-def main [] {
+def "main project log" [
+ path: path,
+ since: datetime,
+ until: datetime,
+] {
+ let log_format = "<--- commit --->%n%s%n<--- part --->%b%n<--- part --->"
+ cd $path
+ git log --since $since --until $until --unified --format=$"($log_format)"
+ | split row "<--- commit --->"
+ | skip 1 # First is empty
+ | split column "<--- part --->"
+ | rename "subject" "body" "diff"
+ | update "diff" { | row | $row.diff | split row --regex "diff --git .+" | skip 1 }
+}
+
+def main [
+ since: datetime,
+ --path: path = "~/Projects",
+ --day-start: string = "04:00"
+] {
= print "Not implemented yet"
= exit 1
=}WIP: Implement formatting project log as Markdown
There is some issue with diffs containing binary data. I will need to investigate when I have fresh mind.
index 632e9d5..a87f7bf 100755
--- a/devlog.nu
+++ b/devlog.nu
@@ -35,7 +35,14 @@ def "main project log" [
=] {
= let log_format = "<--- commit --->%n%s%n<--- part --->%b%n<--- part --->"
= cd $path
- git log --since $since --until $until --unified --format=$"($log_format)"
+
+ # Debugging:
+ # print $env.PWD
+ # let git_command = $'git log --since ($since | format date %+) --until ($until | format date %+) --unified --format="($log_format)"'
+ # print $git_command
+
+ # TODO: Handle binary stream somehow coming from Erna's log
+ git log --since=($since | format date %+) --until=($until | format date %+) --patch --format=($log_format)
= | split row "<--- commit --->"
= | skip 1 # First is empty
= | split column "<--- part --->"
@@ -43,11 +50,38 @@ def "main project log" [
= | update "diff" { | row | $row.diff | split row --regex "diff --git .+" | skip 1 }
=}
=
+def "format commit" [] {
+ [
+ $"## ($in.subject | str trim)"
+ ""
+ ($in.body | str trim)
+ ""
+ ($in.diff | each { |diff| format diff } | str join "\n\n" )
+ ] | str join "\n"
+}
+
+def "format diff" [] {
+ $"``` diff\n($in | str trim)\n```"
+}
+
+def "format log" [
+ title: string
+] {
+ [
+ $"# ($title)"
+ $""
+ $"Commits: ($in | length)"
+ $""
+ ($in | each { | commit | format commit } | str join "\n\n")
+ ] | str join "\n"
+}
+
=def main [
= since: datetime,
= --path: path = "~/Projects",
= --day-start: string = "04:00"
=] {
- print "Not implemented yet"
- exit 1
+ # TODO: Handle multiple projects and write the markdown file
+ main project log $path $since (date now)
+ | format log "Test"
=}