aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CLAUDE.md8
-rw-r--r--CMakeLists.txt39
-rw-r--r--cmake/BuildNumber.cmake38
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md78
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md47
-rw-r--r--src/CMakeLists.txt6
-rw-r--r--src/main.cpp4
-rw-r--r--src/mainwindow.cpp2
-rw-r--r--src/messageview.cpp2
-rw-r--r--src/version.h.in23
10 files changed, 196 insertions, 51 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 4a1026b..e0e4cf0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -963,7 +963,13 @@ because the first four steps were treated as the whole job.
a user's own config or habits need to change.
2. Bump `project(qtmaildir VERSION ...)` in `CMakeLists.txt`, the only place
the version lives. Reconfigure, build, and check `./build/src/qtmaildir
- --version`.
+ --version`. An ordinary dev build answers `X.Y.Z build N`, because
+ `QTMAILDIR_BUILD_NUMBER` counts rebuilds so one binary of an unreleased
+ version can be told from another (item 167). That is the DISPLAY version;
+ what a release ships is the clean one, from
+ `-DQTMAILDIR_BUILD_NUMBER=OFF`, which is what the SlackBuild configures and
+ what a tarball with no build directory produces anyway. Check the clean
+ form before tagging.
3. Commit as `release: X.Y.Z`, then `git tag -s vX.Y.Z -m "qtmaildir X.Y.Z"`.
Tags are annotated and GPG-signed, matching every existing one.
4. `git push && git push --tags`. `origin` carries two push URLs, the personal
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 9b4426c..e3cf82b 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -11,6 +11,12 @@ set(CMAKE_AUTORCC ON)
# that ships.
option(QTMAILDIR_BUILD_TESTS "Build the test suite" ON)
+# A dev build counts its own rebuilds so one binary of an unreleased X.Y.Z can
+# be told from another (item 167). OFF for a release: the version a release
+# prints must be a clean X.Y.Z, which the release procedure checks and the
+# SlackBuild builds from a tarball that carries no counter at all.
+option(QTMAILDIR_BUILD_NUMBER "Number each build of a dev tree" ON)
+
set(QTMAILDIR_QT_COMPONENTS Widgets Svg WebEngineWidgets)
if(QTMAILDIR_BUILD_TESTS)
list(APPEND QTMAILDIR_QT_COMPONENTS Test)
@@ -58,6 +64,39 @@ configure_file(
${CMAKE_CURRENT_BINARY_DIR}/generated/qtmaildir/version.h
@ONLY)
+# buildnumber.h is generated separately, and by a BUILD step rather than by
+# configure_file: this whole block runs once per cmake run, so a counter
+# written here would not move when the user rebuilt after a fix, which is the
+# defect item 167 describes. The header always exists so version.h can include
+# it unconditionally; in a release build it is empty and
+# QTMAILDIR_VERSION_DISPLAY collapses to the plain version.
+set(QTMAILDIR_BUILD_NUMBER_HEADER
+ ${CMAKE_CURRENT_BINARY_DIR}/generated/qtmaildir/buildnumber.h)
+
+if(QTMAILDIR_BUILD_NUMBER)
+ # Once now, so the header exists before the first compile of a fresh build
+ # directory, and then again on every build.
+ execute_process(COMMAND ${CMAKE_COMMAND}
+ -DCOUNTER_FILE=${CMAKE_CURRENT_BINARY_DIR}/build-number
+ -DHEADER_FILE=${QTMAILDIR_BUILD_NUMBER_HEADER}
+ -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/BuildNumber.cmake)
+
+ add_custom_target(qtmaildir_buildnumber ALL
+ COMMAND ${CMAKE_COMMAND}
+ -DCOUNTER_FILE=${CMAKE_CURRENT_BINARY_DIR}/build-number
+ -DHEADER_FILE=${QTMAILDIR_BUILD_NUMBER_HEADER}
+ -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/BuildNumber.cmake
+ BYPRODUCTS ${QTMAILDIR_BUILD_NUMBER_HEADER}
+ COMMENT "Numbering this build"
+ VERBATIM)
+else()
+ file(WRITE ${QTMAILDIR_BUILD_NUMBER_HEADER}
+ "// Release build: no counter, so QTMAILDIR_VERSION_DISPLAY is the
+// plain version. Generated by the top-level CMakeLists.txt.
+#pragma once
+")
+endif()
+
add_subdirectory(src)
if(QTMAILDIR_BUILD_TESTS)
enable_testing()
diff --git a/cmake/BuildNumber.cmake b/cmake/BuildNumber.cmake
new file mode 100644
index 0000000..a79f1e7
--- /dev/null
+++ b/cmake/BuildNumber.cmake
@@ -0,0 +1,38 @@
+# Increment the build counter and write buildnumber.h.
+#
+# Run with `cmake -P` as a build step, so it fires on every build rather than
+# once per configure. Item 167: the version alone cannot tell one build of an
+# unreleased X.Y.Z from another, and the user hand-tests unreleased builds
+# daily.
+#
+# Expects COUNTER_FILE and HEADER_FILE on the command line.
+#
+# The counter lives in the BUILD directory and is deliberately not tracked: a
+# committed counter would conflict on every pull and leave the tree dirty
+# after every build. A fresh build directory therefore restarts at 1, which is
+# honest, since it is a different build tree.
+
+if(EXISTS "${COUNTER_FILE}")
+ file(READ "${COUNTER_FILE}" current)
+ string(STRIP "${current}" current)
+endif()
+
+if(NOT current MATCHES "^[0-9]+$")
+ set(current 0)
+endif()
+
+math(EXPR next "${current} + 1")
+file(WRITE "${COUNTER_FILE}" "${next}\n")
+
+# Written to a temporary and copied only if different, so a rebuild that
+# changes nothing else does not force every translation unit including
+# version.h to recompile... except that the number itself changes every time,
+# so it always differs. That is the accepted cost of the feature: the few
+# files that read the version are recompiled and relinked on every build.
+file(WRITE "${HEADER_FILE}.tmp"
+"// Generated by cmake/BuildNumber.cmake on every build. Do not edit, and do
+// not commit: this file lives in the build directory.
+#pragma once
+#define QTMAILDIR_BUILD_NUMBER \"${next}\"
+")
+file(COPY_FILE "${HEADER_FILE}.tmp" "${HEADER_FILE}" ONLY_IF_DIFFERENT)
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md
index ae6443d..b9eb8cf 100644
--- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md
+++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md
@@ -8034,3 +8034,81 @@ ever be empty. That is real, independent of this item, and outside this
repository.
**Size: XS.** Done.
+
+## 167. No way to tell one build of an unreleased version from another
+
+**Observed (user, from the notes):** "we should add a dev build number to be
+pushed everytime we rebuild after a fix, so that I can verify if I'm in the
+correct app version." The note has sat unrecorded through several sessions;
+the 2026-08-25 reconciliation is the first to pick it up.
+
+**Cause (verified in the code, 2026-08-25.)** The version lives in exactly one
+place, `project(qtmaildir VERSION ...)`, and `src/version.h.in` interpolates
+`@PROJECT_VERSION@` and nothing else. That is correct for a release and says
+nothing between two of them: the string moves only when the release procedure
+bumps it, so every rebuild of `0.27.0` reports `0.27.0`. The status table above
+shows why it bites in practice, since most closed items since 0.27.0 read
+"unreleased" and the user hand-tests each one against a binary they rebuilt
+themselves.
+
+Both surfaces that show the version take it from the same macro, so whatever is
+added reaches them at once: the window title (`mainwindow.cpp:939`), the About
+dialog (`mainwindow.cpp:2449`), the placeholder pane (`messageview.cpp:547`),
+`--version` and `--help` (`main.cpp`).
+
+**Approach.** Needs a DECISION before any code, because the two candidates fail
+in opposite directions.
+
+A git description (`git describe --always --dirty`, or the short hash) is
+accurate and self-explaining: it names the commit the binary was built from, and
+a reviewer can check out exactly that. Its cost is that CMake computes it at
+CONFIGURE time, so a build after a new commit reports the previous hash unless
+the configure step is made to re-run, which is a custom command with a dependency
+on `.git/HEAD` and the packed refs, and is the part that usually ships subtly
+wrong.
+
+A monotonic counter always moves and needs no git, but it means nothing on its
+own: build 412 does not say which fix is in it, and it differs between the user's
+machine and any other, so it cannot be quoted in a report.
+
+**Constraints.** A release build must keep printing a clean `X.Y.Z`, since the
+SlackBuild in the `my-slackbuilds` repo builds from the release tarball where
+there is no git checkout at all, and the release procedure checks
+`./build/src/qtmaildir --version`. Whatever is added is therefore an addition to
+the string in a dev build and absent in a release one, not a change to the
+version itself.
+
+**Decision (user, 2026-08-25): the counter.** The git description was
+offered as the recommendation and was not chosen; what the user wants is to
+know a rebuild happened, not which commit it was.
+
+**Built 2026-08-25, unreleased.** `QTMAILDIR_BUILD_NUMBER`, a cmake option ON
+by default, runs `cmake/BuildNumber.cmake` as a build step: it increments a
+counter and writes `buildnumber.h`, which `version.h` includes.
+`QTMAILDIR_VERSION_DISPLAY` is `X.Y.Z build N` when that macro is defined and
+plain `X.Y.Z` when it is not.
+
+Two macros, not one, and the split is the load-bearing part.
+`QTMAILDIR_VERSION` stays clean and keeps the window title, `applicationVersion`
+and anything that might ever compare versions; `QTMAILDIR_VERSION_DISPLAY` goes
+to the three surfaces the user picked: `--version`, `--help`, the About dialog
+and the placeholder pane. The window title was offered and declined, since the
+number would then sit in every screenshot.
+
+**The counter had to be a BUILD step, not `configure_file`.** That is the whole
+reason this is not two lines: `configure_file` runs once per cmake run, so a
+counter interpolated into `version.h.in` sits still across exactly the rebuilds
+this item exists to distinguish. `version.h.in` therefore includes a second
+generated header rather than carrying the number itself.
+
+The counter file lives in the build directory and is not tracked, so it cannot
+conflict on a pull or dirty the tree; a fresh build directory restarts at 1,
+which is honest, because it is a different build tree. A release build passes
+`-DQTMAILDIR_BUILD_NUMBER=OFF` and the header is written empty.
+
+**Verified by running it**, since none of this is reachable from a C++ test:
+three consecutive builds reported `build 2`, `build 3`, `build 4`, and a
+separate Release configure with the option OFF reported a clean `0.27.0`. The
+suite is 37 of 38, the one failure being item 136 on an unrelated path.
+
+**Size: XS**, as sized.
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
index 6c29fe0..3cdc71a 100644
--- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
+++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
@@ -240,7 +240,7 @@ taking that too literally.
| 164 | A draft this application saved keeps `inbox` | defect | S | open, 2026-08-25, **cause corrected 2026-08-25**. The first diagnosis blamed a missing drafts helper and was WRONG: `NOT_ARRIVALS` in `qtmaildirconf.py` is `("sent", "drafts")`, the folder list includes every account's drafts folder, and `notmuch count` confirms the carve-out query MATCHES the affected draft. The carve-out is scoped to `tag:new`, and the draft carries `inbox` while `tag:new` is 0, so it was never in scope when the hook ran. Measured separately: an mbsync-style rename does NOT re-add `new.tags`, so the retag theory is out too. What remains unestablished is WHICH pass tagged it; establish that before writing code |
| 165 | A draft gets a new Message-ID on every autosave | enhancement | ? | open, 2026-08-25, found while hand-testing 163 and 164. `MessageBuilder::build()` generates an id unconditionally and every autosave calls it, so each revision is a distinct MESSAGE to notmuch and to the server rather than a new version of one. Invisible while the file is replaced correctly, which item 163's fix restores; it is what turned that fork into two messages rather than one duplicated file. Needs a DECISION on what a draft's identity is before any code: a stable id reused at send, a stable id discarded at send, or the status quo. Neither `ComposeContext` nor `OutgoingMessage` has a field to carry an id, so it is not a changed call site |
| 166 | Mail you send to your own other account loses `inbox` | defect | S | open, found 2026-08-25. Wholly this repo's: the hooks live in `assets/hooks/` with their own suites, and the live `post-new` symlinks to them |
-| 167 | No way to tell one build of an unreleased version from another | enhancement | XS | open, found 2026-08-25 from the notes, unrecorded until now. `src/version.h.in` interpolates `PROJECT_VERSION` alone, so every build between two releases reports the same string and a rebuilt binary cannot be told from the one it replaced. The user runs unreleased builds daily, which is when it matters. Needs a DECISION on the source: the git describe/short hash (accurate, needs the build to re-run cmake to pick up a new commit) or a monotonic counter (always moves, means nothing on its own) |
+| 167 | No way to tell one build of an unreleased version from another | enhancement | XS | **done 2026-08-25**, unreleased. The user chose a counter over a git description: `QTMAILDIR_BUILD_NUMBER`, a cmake option ON by default, increments a counter in the BUILD directory on every build and writes `buildnumber.h`. `QTMAILDIR_VERSION_DISPLAY` carries it; `QTMAILDIR_VERSION` stays clean and is what the window title, `applicationVersion` and the release procedure use |
Sizes are rough: XS under an hour, S a sitting, M a session.
@@ -1519,48 +1519,3 @@ id and a draft of a reply carries both.
- Item 163's fix stands on its own and this does not block it: the file is
replaced correctly now, so the fork this would have mitigated no longer
happens by that route.
-
-## 167. No way to tell one build of an unreleased version from another
-
-**Observed (user, from the notes):** "we should add a dev build number to be
-pushed everytime we rebuild after a fix, so that I can verify if I'm in the
-correct app version." The note has sat unrecorded through several sessions;
-the 2026-08-25 reconciliation is the first to pick it up.
-
-**Cause (verified in the code, 2026-08-25.)** The version lives in exactly one
-place, `project(qtmaildir VERSION ...)`, and `src/version.h.in` interpolates
-`@PROJECT_VERSION@` and nothing else. That is correct for a release and says
-nothing between two of them: the string moves only when the release procedure
-bumps it, so every rebuild of `0.27.0` reports `0.27.0`. The status table above
-shows why it bites in practice, since most closed items since 0.27.0 read
-"unreleased" and the user hand-tests each one against a binary they rebuilt
-themselves.
-
-Both surfaces that show the version take it from the same macro, so whatever is
-added reaches them at once: the window title (`mainwindow.cpp:939`), the About
-dialog (`mainwindow.cpp:2449`), the placeholder pane (`messageview.cpp:547`),
-`--version` and `--help` (`main.cpp`).
-
-**Approach.** Needs a DECISION before any code, because the two candidates fail
-in opposite directions.
-
-A git description (`git describe --always --dirty`, or the short hash) is
-accurate and self-explaining: it names the commit the binary was built from, and
-a reviewer can check out exactly that. Its cost is that CMake computes it at
-CONFIGURE time, so a build after a new commit reports the previous hash unless
-the configure step is made to re-run, which is a custom command with a dependency
-on `.git/HEAD` and the packed refs, and is the part that usually ships subtly
-wrong.
-
-A monotonic counter always moves and needs no git, but it means nothing on its
-own: build 412 does not say which fix is in it, and it differs between the user's
-machine and any other, so it cannot be quoted in a report.
-
-**Constraints.** A release build must keep printing a clean `X.Y.Z`, since the
-SlackBuild in the `my-slackbuilds` repo builds from the release tarball where
-there is no git checkout at all, and the release procedure checks
-`./build/src/qtmaildir --version`. Whatever is added is therefore an addition to
-the string in a dev build and absent in a release one, not a change to the
-version itself.
-
-**Size: XS** once the decision is made.
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 700b185..4d9dbaa 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -44,6 +44,12 @@ target_include_directories(qtmaildir_lib
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} ${NOTMUCH_INCLUDE_DIR}
${CMAKE_BINARY_DIR}/generated/qtmaildir)
+# The counter target rewrites buildnumber.h, which version.h includes, so the
+# library must not start compiling before it has run.
+if(TARGET qtmaildir_buildnumber)
+ add_dependencies(qtmaildir_lib qtmaildir_buildnumber)
+endif()
+
target_link_libraries(qtmaildir_lib
PUBLIC Qt6::Widgets Qt6::Svg Qt6::WebEngineWidgets PkgConfig::GMIME
${NOTMUCH_LIBRARY} PkgConfig::CMARK_GFM
diff --git a/src/main.cpp b/src/main.cpp
index 2ed8057..a7908d0 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -43,7 +43,7 @@ int main(int argc, char *argv[])
for (int i = 1; i < argc; ++i) {
if (std::strcmp(argv[i], "--version") == 0
|| std::strcmp(argv[i], "-v") == 0) {
- std::printf("qtmaildir %s\n", QTMAILDIR_VERSION);
+ std::printf("qtmaildir %s\n", QTMAILDIR_VERSION_DISPLAY);
return 0;
}
if (std::strcmp(argv[i], "--help") == 0
@@ -59,7 +59,7 @@ int main(int argc, char *argv[])
"Configuration: ~/.config/qtmaildir/qtmaildir.conf\n"
"qtmaildir reads a notmuch-indexed Maildir. It does no network\n"
"protocol work: fetching and sending are external commands.\n",
- QTMAILDIR_VERSION);
+ QTMAILDIR_VERSION_DISPLAY);
return 0;
}
}
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 5845922..6b48880 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -2446,7 +2446,7 @@ void MainWindow::showAbout()
"version 2.</p>"
"<p>Developed with AI assistance. All code is reviewed, "
"tested and curated by the maintainer.</p>")
- .arg(QStringLiteral(QTMAILDIR_VERSION)));
+ .arg(QStringLiteral(QTMAILDIR_VERSION_DISPLAY)));
auto *link = new QLabel(
QStringLiteral("<a href='https://danix.xyz/qtmaildir'>"
diff --git a/src/messageview.cpp b/src/messageview.cpp
index 86e40eb..eb0dccd 100644
--- a/src/messageview.cpp
+++ b/src/messageview.cpp
@@ -544,7 +544,7 @@ void MessageView::showPlaceholder(
// uses it: a style sheet or a themed parent can give this pane different
// colours from the application.
setDocument(HtmlBuilder::buildPlaceholder(
- helpers, QStringLiteral(QTMAILDIR_VERSION),
+ helpers, QStringLiteral(QTMAILDIR_VERSION_DISPLAY),
HtmlBuilder::brandPaletteFrom(palette())));
}
diff --git a/src/version.h.in b/src/version.h.in
index 8d76a3a..743adc5 100644
--- a/src/version.h.in
+++ b/src/version.h.in
@@ -25,3 +25,26 @@
#define QTMAILDIR_VERSION_MINOR @PROJECT_VERSION_MINOR@
#define QTMAILDIR_VERSION_PATCH @PROJECT_VERSION_PATCH@
#define QTMAILDIR_VERSION "@PROJECT_VERSION@"
+
+/// The version as shown to a person, which in a DEV build carries the build
+/// number and in a release build is exactly QTMAILDIR_VERSION.
+///
+/// Two separate macros deliberately. The release procedure checks `--version`
+/// against a clean X.Y.Z, the SlackBuild builds from a release tarball where
+/// no build counter exists, and the window title is a poor place for a number
+/// that changes on every rebuild. Anything comparing versions uses
+/// QTMAILDIR_VERSION; anything a person reads to answer "which build am I
+/// running" uses this one.
+///
+/// buildnumber.h is generated at BUILD time, not here: this file is written
+/// by configure_file(), which runs once per cmake run, so a counter
+/// interpolated into it would sit still across every rebuild, which is the
+/// entire thing item 167 is about. It defines QTMAILDIR_BUILD_NUMBER only in
+/// a dev build.
+#include "buildnumber.h"
+
+#ifdef QTMAILDIR_BUILD_NUMBER
+# define QTMAILDIR_VERSION_DISPLAY QTMAILDIR_VERSION " build " QTMAILDIR_BUILD_NUMBER
+#else
+# define QTMAILDIR_VERSION_DISPLAY QTMAILDIR_VERSION
+#endif