Skip to content

feat(dd): add visual diff family (hug dd s/u/w), productize git-dd#166

Merged
elifarley merged 8 commits into
mainfrom
feat-dd-visual-diff
Jun 4, 2026
Merged

feat(dd): add visual diff family (hug dd s/u/w), productize git-dd#166
elifarley merged 8 commits into
mainfrom
feat-dd-visual-diff

Conversation

@elifarley

Copy link
Copy Markdown
Owner

✅ Add a visual (difftool) diff family hug dd s|u|w (staged/unstaged/working) mirroring the text commands ss/su/sw, plus hug dd <ref|range> and bare hug dd. Productizes the old bare dd gitconfig alias into a real, tested git-dd. ⚠️ The old "combined" visual idea used git difftool --dir-diff with no ref = unstaged-only, which silently hides staged changes; dd w and bare dd now use HEAD. ⚠️ Bare dd semantics change (was unstaged-only) and your shellbase dd alias must be removed so the script isn't shadowed. Backstory: https://github.com/elifarley/hug-scm/blob/main/docs/plans/2026-06-04-visual-diff-flag-design.md

Usage

hug dd s            # visual diff of staged changes    (index    vs HEAD)
hug dd u            # visual diff of unstaged changes  (worktree vs index)
hug dd w            # visual diff of ALL uncommitted   (worktree vs HEAD)
hug dd              # same as `dd w`
hug dd HEAD~3       # visual diff of a commit / range
hug dd w -- src/    # scope to a path
hug dd --           # pick files interactively, then one difftool window
command shows git
hug dd s staged git difftool --dir-diff --no-prompt --cached
hug dd u unstaged git difftool --dir-diff --no-prompt
hug dd w / bare all uncommitted (net) git difftool --dir-diff --no-prompt HEAD

dd w is net worktree-vs-HEAD, so it intentionally differs from text sw (which renders two sections): a hunk staged then reverted cancels out in the net view. Documented; the split view stays in sw.

🔁 Post-merge

  1. Remove the dd alias from your shellbase .gitconfig. Git resolves aliases before an external git-<cmd>, so the alias would shadow the new git-dd script.

Follow-ups

  • Global visual-diff contract for shp/lp/lc/lcr (deferred in the design doc).
  • tests/unit/test_dispatcher.bats:334 (hug init, no args) isn't worktree-safe: passes on main, fails only when the suite runs from a linked worktree. Pre-existing, unrelated to this PR.
Review story, must-fixes, testing

Reframed via /autoplan (CEO + Eng + DX, dual-voice Codex + Claude). The original draft added a -d/--visual flag to sw/ss/su; review caught a critical bug (the "combined" view would silently drop staged changes) plus a partial-convention smell (the flag would live on only 3 of ~8 diff-producing commands). Reframed into a productized dd family. Implemented test-first via subagent-driven development (implementer → spec review → code-quality review → final whole-feature review).

Must-fixes baked in:

  • difftool preflight — friendly problem→cause→fix error when no diff.tool / difftool.*.cmd is configured (never falls through to git's vimdiff/prompt).
  • no-changes guards per mode (no confusing empty-tool launch).
  • non-TTY refusal + --no-prompt (a blocking GUI won't hijack a pipe).
  • set -euo pipefail safety around difftool exit codes (user-cancel / missing tool doesn't crash the command).
  • single normalized -- path scoping (no -- -- doubling, no flag leaking in as a pathspec).
  • interactive picker batches all selected files into ONE difftool window, never N blocking sessions.

Structure follows the repo convention (thin git-config/bin/git-dd, logic in new git-config/lib/hug-git-difftool; diff_has_working_changes added beside its siblings in hug-git-diff).

Tests: 24 BATS cases in tests/unit/test_dd.bats using a fake difftool that records argv, so dispatch is verifiable without a GUI (the dd w → HEAD case is the regression guard for the combined-diff bug). test_status_staging stays 142/142 (no regression from the shared-lib addition). make sanitize clean.

claude and others added 4 commits June 4, 2026 09:56
…sual diff

WHY: The original `dd` gitconfig alias had a critical semantic bug: bare
`git difftool --dir-diff` (no ref) compares worktree-vs-index = UNSTAGED
changes only. Users who ran `hug dd` expecting "all my changes" silently lost
their staged hunks from the visual diff. Additionally, the alias had no help
text, no tests, no completion, no TTY guard, and no no-changes guard — an
undocumented, untested, unfixable API.

This commit productizes `dd` into a first-class `git-dd` script that fixes
the combined-diff correctness bug by design, and adds all the guards the
alias lacked. Design reviewed by /autoplan (CEO + Eng + DX, dual-voice
Codex + Claude, 7/7 unanimous on the critical bug).

WHAT:
- NEW git-config/bin/git-dd — thin gateway script; sources hug-git-difftool;
  declares _hug_category + _hug_keywords for hug help discovery; show_help
  with full subcommand table, CAPTURING OUTPUT note, SEE ALSO cross-refs.
- NEW git-config/lib/hug-git-difftool — all visual-diff logic:
    dd_check_tty     : refuses to launch a blocking GUI on non-TTY stdout
    dd_preflight     : validates diff.tool / difftool.<x>.cmd; friendly error
    run_visual_diff  : strict-mode-safe wrapper (set +e / set -e around call)
    dd_staged        : --cached mode (index vs HEAD)
    dd_unstaged      : bare mode (worktree vs index)
    dd_working       : HEAD mode (worktree vs HEAD — net all-uncommitted)
    dd_ref           : arbitrary ref / range forwarding
    dd_dispatch      : arg parser; subcommand or ref; normalized -- path args
- MOD git-config/lib/hug-git-diff — adds diff_has_working_changes() beside
  the existing diff_has_staged_changes / diff_has_unstaged_changes pair.
- NEW tests/unit/test_dd.bats — 20 tests covering all acceptance criteria.

HOW — key design decisions (each deliberate, each had an alternative):

1. dd w = git difftool --dir-diff HEAD (the CRITICAL invariant):
   Do NOT simplify this to bare `git difftool --dir-diff` — that is
   worktree-vs-index (unstaged only). HEAD gives net worktree-vs-HEAD =
   all uncommitted changes. A dir-diff cannot show a split staged+unstaged
   view in one tool invocation, so dd w is documented as "net" (same as
   `git commit -a` would produce), distinct from text sw's split view.

2. Strict-mode safety via set +e / set -e wrapper in run_visual_diff:
   `git difftool` exits 1 when the user closes the tool early or the tool
   binary is missing — both normal, expected events. A naked call under
   `set -euo pipefail` would abort the entire hug command silently. The
   wrapper captures dt_exit, treats 0 and 1 as clean, propagates >=2.

3. TTY guard checks stdout (fd 1), bypassed by HUG_TEST_MODE=true:
   The project's stdout/stderr discipline flags visual commands as TTY-only.
   HUG_TEST_MODE allows BATS tests to bypass the TTY guard to exercise the
   difftool-preflight and no-changes guards independently — without it, every
   test would exit at the TTY check before reaching anything interesting.

4. Difftool preflight reads BOTH diff.tool AND difftool.<x>.cmd:
   A user may configure an inline cmd entry without a diff.tool pointer.
   Checking only diff.tool would give a false "unconfigured" error. Both
   checks use `git config --get-regexp` so they honor the same config scopes
   (local -> global -> system) as git itself.

5. Fake-difftool harness (option A) + PATH git shim (option B):
   Option A (diff.tool fake + difftool.fake.cmd logging to ARGV_LOG) verifies
   end-to-end plumbing but can only assert "tool was/was not invoked".
   Option B (thin git shim on PATH that logs argv for difftool calls, execs
   real git for everything else) asserts EXACT argument vectors — critical for
   the regression test that HEAD is passed (not --cached, not nothing).
   WHY resolve real git BEFORE prepending the shim: once the shim is on PATH,
   `command -v git` returns the shim itself, causing infinite recursion. The
   absolute path is baked into the shim at creation time.

6. GIT_CONFIG_GLOBAL=/dev/null in the unconfigured-difftool test:
   The developer running the tests likely has kitty (or another difftool)
   in their ~/.gitconfig. Without this guard, the "unconfigured" test would
   silently pass the preflight and try to launch kitty, getting an unrelated
   /dev/tty error instead of the expected failure. This is a recurring trap
   in tests that check for absent config — always isolate config scope.

IMPACT:
- hug dd w now includes staged changes (the core correctness fix)
- hug dd s / hug dd u give staged-only / unstaged-only visual views
- Friendly errors replace silent vimdiff fallback and blocking pipeline hijack
- 20 new unit tests; 0 regressions in 142 status/staging tests
- hug help dd and hug help /visual now work; command is discoverable

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
)

WHY: After `hug dd` was productized with s/u/w subcommands and guards, the
only remaining gap was the interactive file picker. Every text-diff sibling
(hug ss / hug su / hug sw) exposes a trailing bare `--` trigger to let users
cherry-pick files from a gum-powered list. Without parity, `hug dd --` did
nothing useful (it treated `--` as a pathspec separator with no paths, silently
launching the full working diff). This left the "I want to visually diff just
a handful of files" use-case unaddressed.

WHAT: Trailing bare `--` now triggers an interactive file picker for all dd
modes (bare, s, u, w). After selection, exactly ONE difftool invocation is
launched with all chosen files as pathspecs.

New library function `dd_pick_and_diff(mode)` in `hug-git-difftool`:
  - Builds select_files_with_status flags per mode (mirrors _diff_cmd_setup)
  - Collects selections via mapfile + select_files_with_status
  - Cancels cleanly (exit 0 + "No files selected or cancelled") on empty pick
  - Issues exactly ONE run_visual_diff call with all selected files after `--`

Updated `dd_dispatch` in the same library:
  - Detects trailing bare `--` BEFORE the main parse loop (same pre-loop check
    as _diff_cmd_setup in hug-git-diff — strip last arg, set picker_mode flag)
  - After the parse loop, if picker_mode: resolves mode name → dd_pick_and_diff
  - Ref/range mode with `--` is rejected with a clear error (no sensible scope)

Three new BATS tests in `tests/unit/test_dd.bats` (total now 23):
  - #10a: `hug dd --`   → gum-mock selects 2 files → 1 difftool call with HEAD
  - #10b: `hug dd s --` → gum-mock selects 1 staged file → 1 call with --cached
  - #11:  `hug dd --`   → gum-mock cancels (exit 1) → message, 0 difftool calls

HOW: Key design decisions and their rationale:

  BATCHED CALL (never a per-file loop):
    git difftool --dir-diff is designed to open ONE tool window showing all
    changed files side-by-side in two temp directories. A per-file loop would
    produce N sequential blocking windows — the opposite of the dir-diff UX.
    The invariant: N selected files → exactly 1 tool window. Enforced in
    dd_pick_and_diff by building the full file list then calling run_visual_diff
    once with all files as positional args after `--`.

  select_mode MIRRORS _diff_cmd_setup (hug-git-diff):
    The text-diff picker uses "--staged", "--unstaged", "--staged --unstaged"
    for the three modes. We use the same vocabulary with select_files_with_status
    so both families feel consistent. The mapping is explicit in dd_pick_and_diff:
      staged   → --staged
      unstaged → --unstaged
      working  → --staged --unstaged  (net view = both categories)

  TRAILING `--` DETECTION IS PRE-LOOP:
    Checking ${!#} == "--" before the parse loop (and stripping it with
    set -- "${@:1:$(($#-1))}") is exactly how _diff_cmd_setup does it. This
    avoids the complexity of detecting it mid-loop after args are consumed.
    PITFALL: do NOT detect trailing `--` by letting it fall through the loop —
    the loop already treats `--` as a pathspec separator (consuming remaining
    args), so by the time you'd check "did we get any pathspecs?", the `--`
    is gone and the empty-pathspecs heuristic is ambiguous.

  NO CHANGE TO hug-git-difftool's LOAD CONTRACT:
    hug-common already sources hug-select-files (which provides
    select_files_with_status). Since git-dd sources hug-common first, the
    function is available without adding another `for f in ...` entry to git-dd.
    Adding it explicitly would be misleading (implies it's not already loaded).

  PICKER NOT AVAILABLE FOR REF/RANGE MODE:
    `hug dd HEAD~3 --` is rejected with a clear error. A ref already scopes
    the diff completely; the picker lists "current working-tree changes" which
    are unrelated to an arbitrary ref. If we silently ignored `--` for refs,
    the user would get the full ref diff and wonder why the picker didn't open.

IMPACT:
  - `hug dd --`, `hug dd s --`, `hug dd u --`, `hug dd w --` all open the
    gum file picker, narrowing the visual diff to the selected subset.
  - Consistent with the established hug `--` convention from text-diff siblings.
  - One tool window regardless of selection count — honours dir-diff semantics.
  - All 20 existing tests still pass; 3 new tests cover the picker paths.
  - make sanitize clean (shellcheck + shfmt + ruff + mypy all pass).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ejection test, polish docs and tests

WHY: A code review of the hug-git-difftool library identified six concrete
improvement areas: two logic-clarity bugs (I1, I2) and four polish items
(M1–M4). None changed runtime semantics, but together they make the code
teach its intent instead of hiding it.

WHAT: Six targeted fixes across three files:

  I1 — Clean up no-changes guards in dd_staged / dd_unstaged
  ───────────────────────────────────────────────────────────
  Both functions used a convoluted double-negation:
    if ! git diff --quiet; then true; else info "No changes"; return 0; fi

  The dummy `true` branch existed only to satisfy the structure of an
  inverted condition — no meaningful work happened there. The inverted form
  forced readers to mentally negate "exits 0 when quiet" twice before
  understanding the intent.

  Replaced with the canonical early-return guard:
    if git diff --quiet; then info "No changes."; return 0; fi
    run_visual_diff ...

  WHY non-inverted form is strictly better:
  - `git diff --quiet` exits 0 = no differences = "nothing to show" — the
    condition maps directly to "nothing to do", which reads naturally.
  - Early-return is the industry-standard guard pattern: the guard is the
    entire `if` block; the happy path follows unconditionally.
  - The dummy `true` was a code smell that signalled "this branch does
    nothing" — a maintenance trap that invites accidental insertion of
    logic into the wrong branch.
  - dd_working already used this clean pattern (via diff_has_working_changes);
    dd_staged and dd_unstaged are now consistent with it.

  I2 — Add missing rejection test for `hug dd <ref> --`
  ──────────────────────────────────────────────────────
  The rejection path at dd_dispatch:444-448 (error when picker is combined
  with a ref/range argument) had NO test. The code path existed and worked,
  but was invisible to the regression suite.

  Added:
    @test "hug dd <ref> --: interactive picker with a ref/range is rejected"

  Uses create_test_repo_with_history (to have HEAD~1) and
  configure_fake_difftool (consistent with other Category 1 tests). Does NOT
  set up the git shim — the command is expected to fail before difftool is
  ever called, so the shim would only add noise.

  WHY this gap was worth closing: the rejection logic is a UX invariant ("a
  ref scopes itself — there is no current-changes pool to pick from"). If
  someone accidentally removed or refactored the guard, no test would catch
  the regression.

  M1 — Add diff_has_working_changes to hug-git-diff header comment
  ─────────────────────────────────────────────────────────────────
  The Functions: list in the file header was the source of truth for
  "what does this module export?" diff_has_working_changes was added as
  the third change-detection helper but never added to the list, making
  the header incomplete and misleading (readers would think only staged and
  unstaged detection existed).

  M2 — Document unreachable `return 1` lines after error() calls
  ──────────────────────────────────────────────────────────────
  error() in hug-output calls exit — the return 1 lines on the next line
  are therefore dead code. Removing them is the ideal fix but shellcheck
  uses them for fallthrough analysis in case/if blocks and may warn without
  them (shellcheck is already clean with them present).

  Decision: keep the lines, add a comment:
    # error() calls exit — unreachable; kept for shellcheck's fallthrough analysis

  This documents the intent (would remove if shellcheck allowed it) without
  introducing a new shellcheck warning. make sanitize confirmed clean before
  and after.

  M3 — Remove dead outer `source` and fix misleading comment in test_dd.bats
  ────────────────────────────────────────────────────────────────────────────
  The diff_has_working_changes BATS test body contained:
    source "$PROJECT_ROOT/git-config/lib/hug-common"   # dead — no effect on subshell

  This line ran in the BATS test body process. It had zero effect on the
  `run bash -c "source ... && diff_has_working_changes"` subshell below,
  because `run` spawns a fresh subprocess that inherits nothing sourced in
  the parent. The outer source was not wrong (it doesn't break anything)
  but was a teaching trap — future readers might think it was necessary.

  Replaced with an explanatory comment that captures the key lesson:
  - WHY hug-common (not hug-git-diff directly): hug-common is the umbrella
    loader; sourcing it gives diff_has_working_changes all transitive deps.
  - NOTE: this source is for the human reader — the subshell sources for
    itself. The outer source has no effect on the subshell.

  M4 — Strengthen picker multi-select test to assert actual filenames
  ───────────────────────────────────────────────────────────────────
  The existing test for `dd --` (picker selects multiple files) verified:
  - Exactly ONE difftool invocation
  - HEAD is present in the log
  - --no-prompt is present
  - -- separator is present

  What it did NOT verify: whether the selected FILE NAMES were actually
  forwarded as pathspecs after --. The count + flag assertions only prove
  the plumbing was called once with the right flags; they say nothing about
  the data payload.

  Added two assertions after the existing ones:
    assert_shim_logged "staged.txt"
    assert_shim_logged "README.md"

  Why these specific files: the fixture create_repo_with_staged_and_unstaged
  creates staged.txt (staged) and README.md (unstaged). In working mode,
  select_files_with_status lists staged files first (via --staged flag in the
  loop), then unstaged — so index 0 = staged.txt, index 1 = README.md. The
  HUG_TEST_GUM_SELECTION_INDICES="0,1" mock selects both by position, and
  the assertions close the loop by verifying the filenames arrived in the
  shim log as pathspecs.

HOW:
  - All edits are surgically minimal: no surrounding logic was restructured.
  - The I1 fix changes condition polarity and removes the dummy `true` branch
    only; the run_visual_diff call and "$@" forwarding are untouched.
  - The I2 test follows the exact pattern of the other Category 1 tests
    (arrange with fixture helper, configure_fake_difftool, run git-dd, assert).
  - M3 and M4 are additive-only changes to the test file (no test logic removed).
  - make sanitize (shellcheck + ruff + mypy) and make test-unit TEST_FILE=test_dd.bats
    were both clean after each change.

IMPACT:
  - Test count: 23 → 24 (new I2 rejection test is now a permanent regression guard).
  - No behaviour change: all guards produce the same runtime outcomes; only
    their source-code expression improved.
  - Future maintainers reading dd_staged / dd_unstaged will immediately see
    the early-return pattern, understand git diff --quiet's exit-code semantics,
    and not be confused by a dummy `true` branch.
  - The M3 comment is a standalone lesson in BATS subshell isolation — one of
    the most common BATS pitfalls (thinking outer `source` affects `run`).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…-diff family

WHY: A new visual-diff family (`hug dd`, `hug dd s`, `hug dd u`, `hug dd w`) was
added as GUI/difftool counterparts to the existing text-diff commands.  Without
bidirectional help cross-references, users who already know `hug ss` would never
discover that `hug dd s` can open the same staged diff side-by-side in their
configured difftool — and vice versa.  SEE ALSO sections are the canonical
discovery mechanism in this codebase; adding a line there is the minimum viable
discoverability change.

WHAT: One line added to the SEE ALSO block of each of four commands:
  git-sw  → "hug dd w  : Visual working-tree diff (difftool)"
  git-ss  → "hug dd s  : Visual staged diff (difftool)"
  git-su  → "hug dd u  : Visual unstaged diff (difftool)"
  git-shp → "hug dd    : Visual directory diff (difftool)"

git-dd's own SEE ALSO already lists ss/su/sw/shp — these four lines complete
the bidirectional link graph without touching git-dd.

HOW: Each new line mirrors its file's existing column alignment: 4-space indent,
command padded to 13 chars before the colon (e.g. "hug dd w     :").  This keeps
the block visually scannable at a glance — a maintainer adding a future entry
should count to 13 chars or align visually against existing lines.

ALIGNMENT NOTE FOR FUTURE CONTRIBUTORS:
  All SEE ALSO entries in this codebase follow the pattern:
      hug <cmd>     : <one-line description>
  where the text up to (not including) the colon is 13 characters wide.
  "hug dd w" is 8 chars → pad with 5 spaces; "hug ss" is 6 chars → pad with 7.
  Breaking this alignment makes the block harder to read — always measure.

IMPACT: Users discover the visual-diff siblings from text-diff help and vice
versa.  No behavior changes; pure documentation.  The coverage check confirmed
all 15 test-plan cases are already exercised by the existing 24 tests in
tests/unit/test_dd.bats — no new tests needed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 85d848ee70

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +74 to +76
# Check 2: any difftool.<name>.cmd entry
if git config --get-regexp 'difftool\..+\.cmd' > /dev/null 2>&1; then
return 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require a selected diff.tool for custom difftool commands

When a user has only difftool.<name>.cmd configured, this preflight returns success even though run_visual_diff later invokes plain git difftool without --tool=<name>. I checked git difftool -h, which exposes selecting a custom tool via --tool <tool> or diff.tool; with only difftool.fake.cmd set, Git still reports diff.tool is not configured and falls through to its default/prompt path. That breaks the advertised no-fallthrough friendly error for users who define a custom command but have not selected it with diff.tool.

Useful? React with 👍 / 👎.

Comment thread git-config/lib/hug-git-difftool Outdated
Comment on lines +381 to +383
# Guards run before argument parsing so they always fire regardless of mode.
dd_check_tty
dd_preflight

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Parse help before TTY and difftool guards

Because these guards run before the argument parser handles -h|--help, hug dd --help can fail instead of showing setup instructions whenever stdout is captured/piped or the user has not configured a difftool yet. This is especially likely for first-time users trying to learn how to configure the new command, and help should not require an interactive terminal, a Git repo, or a working difftool configuration.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already fixed in this PR (commit cc2eebd, fix(dd): show --help/-h before the TTY and difftool guards): a pre-scan for -h/--help now runs at the very top of dd_dispatch, ahead of both dd_check_tty and dd_preflight. It stops at the first --, so a file literally named --help after a path separator is still treated as a pathspec.

The two help tests were also strengthened to run with HUG_TEST_MODE="" and GIT_CONFIG_GLOBAL=/dev/null, so they exercise the real non-TTY, no-difftool path — previously they masked this exact issue by bypassing both guards.

claude and others added 2 commits June 4, 2026 12:32
WHY:
`hug dd --help` (and `-h`) failed in any non-interactive context —
`hug dd --help | less`, redirected output, or CI — with
"requires an interactive terminal (TTY)" and exit 1, instead of printing
help. Help is documentation; it must never depend on a TTY or on a
difftool being configured.

WHAT:
dd_dispatch ran dd_check_tty + dd_preflight BEFORE the arg loop that
handled -h/--help, so the guards fired first. Added a small -h/--help
pre-scan at the very top of dd_dispatch, ahead of both guards. The scan
stops at the first `--`, so a file literally named `--help` after `--` is
still treated as a pathspec, not a help request. Removed the now-dead
-h/--help case from the main parse loop.

HOW THE TESTS HID IT (the lesson):
The help tests passed only because test_helper exports HUG_TEST_MODE=true
(bypassing the TTY guard) AND the dev's global git config happened to have
a difftool (satisfying preflight). Neither holds in clean CI. The two help
tests now run with HUG_TEST_MODE="" and GIT_CONFIG_GLOBAL=/dev/null, so
they exercise the real non-TTY, no-difftool path and would catch this
regression. Lesson: a test that sets a bypass env can silently mask the
very codepath it appears to cover.

IMPACT:
`hug dd --help` now works everywhere — pipes, redirects, CI, no difftool.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WHY:
The dd feature shipped with help + SEE ALSO but not the user-facing
reference the project's "adding a command" workflow requires (README,
command-map, docs/commands). And dd's subtlest point — that `dd w` is a
NET worktree-vs-HEAD view, not the two-section split that text `sw` shows
— needed one clear, authoritative explanation so a user who sees `dd w`
"miss" a staged-then-reverted change understands why.

WHAT:
- docs/commands/status-staging.md: new "Visual diff: hug dd" section — the
  single source of truth, with the three-trees model (HEAD -> index ->
  worktree) and a worked cancellation example.
- git-config/bin/git-dd: a concise "NET vs SPLIT VIEW" note in help that
  points to that section.
- README.md + command-map.md: one-line dd entries for discoverability.
- design doc: a pointer to the user walkthrough (it records the decision;
  the user-facing explanation lives in status-staging.md).

HOW:
Single-source-of-truth per docs/DOCS_ORGANIZATION.md: the worked example
exists once (status-staging.md); help carries the must-know plus a pointer;
README/command-map/design-doc link, never duplicate — so the explanation
cannot drift across surfaces.

IMPACT:
Completes the add-command doc checklist for `dd`, and gives a reader one
authoritative place explaining why `dd w` is net-not-split and which
command (`dd s` + `dd u`, or `sw`) gives the staged/unstaged split.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
claude and others added 2 commits June 4, 2026 13:00
WHY:
`hug version` / `hug --version` printed a description but no version number,
so users and scripts had no way to tell which build of Hug they were running.
The repo had a 1.0.0 tag but no VERSION file to anchor the current state.

WHAT:
- Adds a `VERSION` file at the repo root, set to `1.1.0-dev` (the in-progress
  next minor; the [Unreleased] CHANGELOG accumulates toward it).
- Wires the dispatcher's `--version | version)` branch to print
  `Version: <contents of $HUG_HOME/VERSION>` after the existing description.
- Adds two test_dispatcher cases asserting `hug version` and `hug --version`
  report the VERSION file's contents.

HOW:
The version branch reads `$HUG_HOME/VERSION`. HUG_HOME is frozen and exported
at the top of bin/hug (before any -C/-S cd), so the lookup is CWD-independent.
A missing VERSION file degrades gracefully (the `[[ -f ]]` guard skips the line).

The tests invoke `"$PROJECT_ROOT/bin/hug"` explicitly rather than `run hug`:
test_helper puts only git-config/bin (the git-* command scripts) on PATH, NOT
bin/ (the dispatcher), so `run hug` would exercise the globally-installed
dispatcher instead of this checkout's. Asserting against `$(cat "$HUG_HOME/VERSION")`
keeps the test in sync with the file, so a future version bump can't silently
break it.

IMPACT:
`hug version` now identifies the build. The VERSION file becomes the single
source of truth a release process (or a script) can read.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…Unreleased

Adds two entries to CHANGELOG.md `## [Unreleased]` → `### Added`, following the
repo convention of a per-feature changelog line:

- `hug dd` visual side-by-side diff family (dd s/u/w + bare + ref), the difftool
  counterpart to ss/su/sw, with the net-vs-split caveat for `dd w` and a pointer
  to docs/commands/status-staging.md.
- `hug version` now reporting a version number from the new VERSION file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@elifarley elifarley merged commit 184d1a2 into main Jun 4, 2026
7 checks passed
@elifarley elifarley deleted the feat-dd-visual-diff branch June 4, 2026 16:09
elifarley pushed a commit that referenced this pull request Jun 4, 2026
WHY: Hug SCM has shipped continuously to main without a single tagged
release or version number — users had no way to answer "which Hug do I
have?" and there were no release notes to read. The recent `hug dd`
visual-diff family (#166) added a VERSION file (1.1.0-dev) and a
`hug version` command, which finally made a real release both possible
and worth doing. This commit turns the accumulated, never-released work
into v1.1.0: the first point on the project's version timeline and the
baseline every future bump compares against.

WHAT:
- VERSION: 1.1.0-dev → 1.1.0 (finalize the in-development marker).
- CHANGELOG: promote the unreleased work into a single, dated
  `## [1.1.0] - 2026-06-04` section and open a fresh empty `## [Unreleased]`.
  Consolidation only — no entry content was rewritten or dropped:
    * merged a duplicated `### Added` header (two adjacent blocks → one);
    * folded a malformed second `## [Unreleased] - 2025-01-13` (the older
      Makefile-canonical-targets batch) into [1.1.0] as a dated `### Changed`
      subsection — with no prior release, every "unreleased" pile belongs
      to this first one;
    * dropped a now-stale "(currently `1.1.0-dev`)" parenthetical so the
      CHANGELOG and the VERSION file no longer contradict each other.

HOW: Surgical header edits, not a rewrite. The ~110 lines of Makefile
migration tables were left physically in place rather than reproduced —
the safe way to restructure a CHANGELOG, because reordering big blocks by
hand is exactly how content gets silently lost. Every bullet was verified
present after the edits (header counts + per-feature grep probes).

IMPACT: `hug version` now reports a real number (1.1.0) that maps to a
dated, quotable release-notes section and a `v1.1.0` git tag / GitHub
release. Semver note: the CHANGELOG lists "breaking changes" (Makefile
target renames, worktree-indicator and stdout/stderr output-format shifts),
but those are contributor- and script-author-facing, not breaks in the core
CLI contract — and as the *first* tag there is no prior release to break
compatibility with, so a 1.1.0 minor baseline is correct rather than 2.0.0.

Known doc debt deferred out of this release (pre-existing, not introduced
here): CLAUDE.md still claims "21 modular library functions" (actual: 35
lib files), and git-config/lib/README.md documents a curated ~9-module
subset (both hug-git-diff and the new hug-git-difftool are absent). Cleaning
those is a focused docs pass, not release-cut scope.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants