Skip to content
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ All notable changes to the Hug SCM project will be documented in this file.

### Added

- **`hug dd` — visual side-by-side diff command family.** Opens a configured difftool (e.g. kitty diff) instead of a text patch: `hug dd s` (staged), `hug dd u` (unstaged), `hug dd w` / bare `hug dd` (all uncommitted — *net* worktree-vs-HEAD), and `hug dd <ref|range>`. The visual counterpart to `ss`/`su`/`sw`. `dd w` is a net view, so it intentionally differs from `sw`'s two-section split (a staged-then-reverted hunk cancels out) — see `docs/commands/status-staging.md` → "Visual diff". Productizes the former `dd` gitconfig alias into a real `git-dd` command with difftool preflight (friendly error when unconfigured), no-changes and non-TTY guards, `--no-prompt`, and an interactive `--` file picker. `--help` works without a TTY or a configured difftool.
- **`hug version` / `hug --version` now reports a version number.** Added a `VERSION` file at the repo root (currently `1.1.0-dev`) and wired the dispatcher to print it. Previously `hug version` printed only a description with no number. Scripts can read it via `hug version` or the `VERSION` file directly.
- **`hug s -r, --remote` query flag:** Outputs the fetch URL of the tracking remote (empty when no upstream is configured). Part of the `hug s` query flag system for scripting. Use `hug s -r` alone or combine: `hug s -b -r -u`.
- **Unified Selection Framework (`selection_core.py`).** Shared toolkit for all Python selection modules: `bash_escape`, `BashDeclareBuilder`, `parse_numbered_input`, `get_selection_input`, `add_common_cli_args`, and ANSI color constants. Adding a new selection domain now requires ~50 lines instead of ~200.
- **Branch single-select Python migration.** `print_interactive_branch_menu()` now delegates formatting and numbered-list interaction to Python via `branch_select.py prepare` and `single-select` commands. Eval output validated before execution.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,7 @@ hug sli # Status with list of ignored files
hug ss [file] # Status with staged changes patch
hug su [file] # Status with unstaged changes patch
hug sw [file] # Status with working dir changes patch (staged and unstaged)
hug dd [s|u|w] [file] # Visual side-by-side diff via difftool (bare/w = all uncommitted, net)

# Staging
hug a [files] # Stage tracked files (or all if no args)
Expand Down
1 change: 1 addition & 0 deletions VERSION
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
1.1.0-dev
6 changes: 6 additions & 0 deletions bin/hug
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,12 @@ help)
--version | version)
echo "Hug SCM - A humane interface for Git and Mercurial"
echo "Part of the Hug tool suite"
# Report the version from the VERSION file at the install/repo root.
# HUG_HOME is frozen + exported above, so this is CWD-independent and works
# even after -C/-S changed the working directory.
if [[ -f "$HUG_HOME/VERSION" ]]; then
echo "Version: $(cat "$HUG_HOME/VERSION")"
fi
exit 0
;;
esac
Expand Down
3 changes: 2 additions & 1 deletion docs/command-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ This table is the **authoritative source** for Hug's command organization. All c
| `h*` | HEAD Operations | Undo, rewind, and inspect commits without losing work | `hug h back`, `hug h undo`, `hug h files`, `hug h steps` | **H**EAD |
| `w*` | Working Directory | Manage local changes: discard, clean, restore, park/unpark work | `hug w get`, `hug w wip`, `hug w zap`, `hug w purge` | **W**orking dir |
| `wt*` | Worktree Management | Create, switch, list, remove worktrees for parallel development | `hug wt`, `hug wtc`, `hug wtl`, `hug wtll`, `hug wtdel` | **WT**orktree |
| `s*` | Status & Staging | View repo state: summaries, diffs, staged/unstaged changes | `hug ss`, `hug su`, `hug sw`, `hug sx` | **S**tatus |
| `s*` | Status & Staging | View repo state: summaries, diffs, staged/unstaged changes | `hug ss`, `hug su`, `hug sw`, `hug dd`, `hug sx` | **S**tatus |
| `a*` | Staging | Stage changes for commit: tracked, all, or interactive | `hug a`, `hug aa`, `hug ai`, `hug ap` | **A**dd/stage |
| `b*` | Branching | Create, switch, list, delete, sync branches | `hug b`, `hug bc`, `hug bl`, `hug br` | **B**ranch |
| `c*` | Commits | Create and amend commits | `hug c`, `hug ca`, `hug cmod`, `hug caa` | **C**ommit |
Expand Down Expand Up @@ -75,6 +75,7 @@ Hug Commands
│ ├── ss # Status + Staged diff
│ ├── su # Status + Unstaged diff
│ ├── sw # Status + Working dir diff (both unstaged and staged)
│ ├── dd # Visual side-by-side difftool (dd s/u/w; bare = all uncommitted, net)
│ └── sx # eXtended summary
├── a* (Staging: Prepare Commit)
│ ├── a # Add tracked
Expand Down
47 changes: 47 additions & 0 deletions docs/commands/status-staging.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ These enhance Git's `status` and `add` with colored summaries, patches, and smar
## On This Page
- [Quick Reference](#quick-reference)
- [Status Commands (s*)](#status-commands-s)
- [Visual diff (hug dd)](#visual-diff-hug-dd)
- [Staging Commands (a*)](#staging-commands-a)
- [Unstaging](#unstaging)
- [Stash Commands (s* overlap)](#stash-commands-s-overlap)
Expand All @@ -33,6 +34,7 @@ These enhance Git's `status` and `add` with colored summaries, patches, and smar
| `hug ss` | **S**tatus + **S**taged | Show staged diff |
| `hug su` | **S**tatus + **U**nstaged | Show unstaged diff |
| `hug sw` | **S**tatus + **W**orking | Combined staged and unstaged diff |
| `hug dd` | **D**ir-**D**iff (visual) | Visual side-by-side difftool: `dd s`/`u`/`w` — see [Visual diff](#visual-diff-hug-dd) |
| `hug a` | **A**dd tracked | Stage tracked changes |
| `hug aa` | **A**dd **A**ll | Stage tracked and untracked changes |
| `hug us` | **U**n**S**tage | Unstage specific files |
Expand Down Expand Up @@ -166,6 +168,51 @@ Show diffs inline for better inspection.
> **Task:** Review your commit before amending.
> **Flow:** Run `hug ss` to verify staged fixes, then `hug su` to ensure no leftovers remain before `hug caa`.

## Visual diff: `hug dd`

`hug dd` opens a **visual side-by-side difftool** (e.g. kitty diff) instead of printing a text patch. It's the visual counterpart to `ss`/`su`/`sw`, with matching `s`/`u`/`w` subcommands.

| Command | Shows | Compares |
| --- | --- | --- |
| `hug dd s` | Staged | index vs HEAD |
| `hug dd u` | Unstaged | worktree vs index |
| `hug dd w` (or bare `hug dd`) | All uncommitted (net) | worktree vs HEAD |
| `hug dd <ref\|range>` | A commit / range | as given (e.g. `hug dd HEAD~3`) |

```sh
hug dd s # staged changes, visual
hug dd u # unstaged changes, visual
hug dd w # ALL uncommitted changes (same as bare `hug dd`)
hug dd HEAD~3 # a commit / range
hug dd w -- src/ # scope to a path
hug dd -- # pick files interactively, then one difftool window
```

### Net view vs the two-section split

Git holds your work as a chain of three snapshots:

```
HEAD (last commit) → index (staging area) → working tree (files on disk)
```

`hug sw` (text) shows this chain as **two diffs**: a *staged* section (`HEAD → index`) and an *unstaged* section (`index → worktree`). `hug dd w` shows only the **endpoints** as a single diff (`HEAD → worktree`) — it must, because `git difftool --dir-diff` opens the tool once on two snapshots and can't render two sections without launching it twice (poor UX).

Collapsing the middle means the two steps can cancel out. Example — `config.txt` is `port = 80` at HEAD:

1. Change it to `port = 8080` and **stage** it (index = `8080`).
2. Then edit the working file **back** to `port = 80`.

| View | Shows |
| --- | --- |
| `hug sw` | **two** changes: staged `80 → 8080`, unstaged `8080 → 80` |
| `hug dd w` | **nothing** — HEAD (`80`) and worktree (`80`) are identical → `No changes.` |

This is intentional, not a bug. `dd w` answers *"what does my tree look like vs my last commit?"* (the common case). When you need the exact staged-vs-unstaged split, use **`hug dd s` + `hug dd u`** (each diffs one link of the chain) or the text view **`hug sw`**.

> [!TIP]
> `hug dd` needs a difftool configured in git (`diff.tool` + `difftool.<name>.cmd`). It is interactive/TTY-only and refuses to run in a pipe — for pipe-safe patch output use `hug ss` / `hug su` / `hug sw`.

## Staging Commands (a*)

- `hug a [files...]`: **A**dd tracked
Expand Down
4 changes: 4 additions & 0 deletions docs/plans/2026-06-04-visual-diff-flag-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ So `dd w` is documented as "all uncommitted changes, net working-vs-HEAD"; the s
in text `sw`. The original plan's `sw -d → git difftool --dir-diff` (no ref) was **unstaged-only**
and silently dropped staged changes — that defect is designed out here.

> **User-facing walkthrough lives elsewhere (single source of truth):** see
> `docs/commands/status-staging.md` → "Visual diff: `hug dd`" for the three-trees model and a
> worked cancellation example. This section records the *decision*; that doc explains it for users.

## Must-fix requirements (from review — baked in, not optional)

1. **Difftool configuration** *(taste decision on approach, below)*: never fall through to raw
Expand Down
101 changes: 101 additions & 0 deletions git-config/bin/git-dd
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
# hug dd — visual side-by-side diff via git difftool --dir-diff
#
# Productizes the `dd` gitconfig alias into a first-class hug command with:
# - Subcommands s/u/w mirroring the text-diff family ss/su/sw
# - Difftool configuration preflight (friendly error if unconfigured)
# - Non-TTY guard (refuses to hijack pipelines with a blocking GUI)
# - No-changes guard (exits cleanly instead of launching on empty diff)
# - Path scoping support
# - Strict-mode safety (set -euo pipefail + difftool non-zero wrapping)
#
# See docs/plans/2026-06-04-visual-diff-flag-design.md for the full design.
_hug_category='["show"]'
_hug_keywords='["visual","difftool","kitty","side-by-side","gui-diff","dir-diff","visual-diff"]'
test "${1:-}" = '--search-meta' && {
printf 'category = %s\nkeywords = %s\n' "$_hug_category" "$_hug_keywords"
exit 0
}
CMD_BASE="$(readlink -f "$0" 2> /dev/null || greadlink -f "$0")" || CMD_BASE="$0"
CMD_BASE="$(dirname "$CMD_BASE")"
for f in hug-common hug-git-kit hug-git-difftool; do . "$CMD_BASE/../lib/$f"; done
set -euo pipefail

# Part of the Hug tool suite

show_help() {
cat << 'EOF'
hug dd: Visual side-by-side diff via git difftool --dir-diff.

USAGE:
hug dd [s|u|w] [-- <path>...]
hug dd <ref|range> [-- <path>...]
hug dd [-h, --help]

SUBCOMMANDS:
s Staged changes only (index vs HEAD) — mirrors hug ss
u Unstaged changes only (worktree vs index) — mirrors hug su
w Net working changes (worktree vs HEAD, all uncommitted) — mirrors hug sw
(bare) Defaults to 'w' (all uncommitted changes)

<ref> Show diff of a specific commit or range (e.g. HEAD~3, v1.0..HEAD)

OPTIONS:
-h, --help Show this help

DESCRIPTION:
Opens a visual side-by-side difftool (e.g. kitty diff) instead of printing
a text patch to stdout. Requires a difftool to be configured in git config
(see FIX section of the error message if not set).

WHY 'dd w' uses HEAD (not bare git difftool):
Bare 'git difftool --dir-diff' with no ref compares worktree-vs-index,
which is UNSTAGED ONLY. 'dd w' uses HEAD to show ALL uncommitted changes
(staged + unstaged, net). This is the key semantic difference from the
original 'dd' gitconfig alias, which silently dropped staged changes.

NET vs SPLIT VIEW:
'dd w' is a single diff (HEAD vs worktree), so it collapses the
staged/unstaged boundary: a hunk staged then reverted cancels out, and
the result can differ from 'hug sw's two-section text view. For the
split, use 'hug dd s' + 'hug dd u', or the text view 'hug sw'.
Full walkthrough: docs/commands/status-staging.md ("Visual diff").

PATH FILTERING:
Append -- <path>... to restrict the diff to matching paths.
Globs must be quoted to prevent shell expansion.

hug dd w -- src/ tests/ # All uncommitted changes for two directories
hug dd s -- '*.java' # Staged changes for Java files only

EXAMPLES:
hug dd # All uncommitted changes (net), visual side-by-side
hug dd s # Staged changes only
hug dd u # Unstaged changes only
hug dd w # All uncommitted changes (same as bare dd)
hug dd HEAD~3 # Changes in last 3 commits
hug dd v1.0..HEAD # Changes between tag and HEAD
hug dd w -- file.txt # Scoped to a single file

REQUIREMENTS:
A difftool must be configured in git config. Example setup for kitty:
git config --global diff.tool kitty
git config --global difftool.kitty.cmd 'kitty +kitten diff "$LOCAL" "$REMOTE"'

CAPTURING OUTPUT:
hug dd is interactive and visual — it opens a blocking GUI in your
terminal. It is TTY-guarded and must not be piped or redirected.
For pipe-safe patch output, use: hug ss / hug su / hug sw

SEE ALSO:
hug ss : Show staged diff (text)
hug su : Show unstaged diff (text)
hug sw : Show all working changes (text, split view)
hug shp : Show a commit with its full patch
EOF
}

# Delegate all argument processing and dispatch to the shared library driver.
# The library handles: TTY guard, difftool preflight, no-changes guard,
# subcommand parsing, pathspec normalization, and invocation.
dd_dispatch show_help "$@"
1 change: 1 addition & 0 deletions git-config/bin/git-shp
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ SEE ALSO:
hug shc : Show changed files (stats only)
hug fcat : View file content at a commit
hug sl : Show status
hug dd : Visual directory diff (difftool)
EOF
}

Expand Down
1 change: 1 addition & 0 deletions git-config/bin/git-ss
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ SEE ALSO:
hug su : Show unstaged diff
hug sw : Show working directory changes
hug s : Show status
hug dd s : Visual staged diff (difftool)
EOF
}

Expand Down
1 change: 1 addition & 0 deletions git-config/bin/git-su
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ SEE ALSO:
hug ss : Show staged diff
hug sw : Show working directory changes
hug s : Show status
hug dd u : Visual unstaged diff (difftool)
EOF
}

Expand Down
1 change: 1 addition & 0 deletions git-config/bin/git-sw
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ SEE ALSO:
hug ss : Show staged diff
hug su : Show unstaged diff
hug s : Show status
hug dd w : Visual working-tree diff (difftool)
EOF
}

Expand Down
25 changes: 25 additions & 0 deletions git-config/lib/hug-git-diff
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
# - show_combined_diff: Display both unstaged and staged diffs with separator
# - diff_has_staged_changes: Check if staged changes exist
# - diff_has_unstaged_changes: Check if unstaged changes exist
# - diff_has_working_changes: Check if net working-tree changes exist (vs HEAD)

################################################################################
# Idempotent Guards
Expand Down Expand Up @@ -80,6 +81,30 @@ diff_has_unstaged_changes() {
return $((exit_code == 1 ? 0 : 1))
}

# Check if there are any working-tree changes relative to HEAD (net diff).
#
# WHY "working" means HEAD not index:
# `git diff HEAD` compares the entire working tree (both staged and unstaged
# changes) against the last commit. This is the "all uncommitted, net" view
# used by `hug dd w`. Contrast with `git diff` (unstaged only) and
# `git diff --cached` (staged only). Using HEAD ensures staged-and-then-
# reverted hunks cancel out correctly, reflecting what the commit would look
# like after `git commit -a`.
#
# Usage: diff_has_working_changes [-- path...]
# Parameters:
# -- Separator before pathspec args (optional)
# path... Optional pathspecs to restrict check scope
# Returns:
# 0 if working-tree differs from HEAD, 1 if identical (nothing to diff)
diff_has_working_changes() {
check_git_repo
git diff --quiet HEAD "$@" 2>/dev/null
local exit_code=$?
# git diff --quiet returns 1 when there ARE differences, 0 when none
return $((exit_code == 1 ? 0 : 1))
}

################################################################################
# Diff Display Functions
################################################################################
Expand Down
Loading