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 quit

Fix 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_path

Re-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)
=        ""