diff --git a/.claude-plugin/skills/revdiff/references/config.md b/.claude-plugin/skills/revdiff/references/config.md index 9f7cd6e..cb749b7 100644 --- a/.claude-plugin/skills/revdiff/references/config.md +++ b/.claude-plugin/skills/revdiff/references/config.md @@ -36,7 +36,7 @@ Then uncomment and edit the values you want to change. | `--init-themes` | | Write bundled theme files to themes dir and exit | | | `--init-all-themes` | | Write all gallery themes (bundled + community) to themes dir and exit | | | `--install-theme` | | Install theme(s) from gallery or local file (repeatable) | | -| `-A`, `--all-files` | | Browse all git-tracked files, not just diffs (CLI-only, not saved in config) | `false` | +| `-A`, `--all-files` | | Browse all tracked files (git and jj only), not just diffs (CLI-only, not saved in config) | `false` | | `-I`, `--include` | `REVDIFF_INCLUDE` | Include only files matching prefix (may be repeated; comma-separated in env) | | | `-X`, `--exclude` | `REVDIFF_EXCLUDE` | Exclude files matching prefix (may be repeated; comma-separated in env) | | | `-F`, `--only` | | Show only matching files (may be repeated, matches by path or suffix) | | diff --git a/.claude-plugin/skills/revdiff/references/usage.md b/.claude-plugin/skills/revdiff/references/usage.md index 318add7..b04cbbb 100644 --- a/.claude-plugin/skills/revdiff/references/usage.md +++ b/.claude-plugin/skills/revdiff/references/usage.md @@ -16,7 +16,7 @@ revdiff main..feature # same as above, git dot-dot syntax revdiff main...feature # changes since feature diverged from main revdiff --only=model.go # review only files matching model.go revdiff --only=ui/model.go --only=README.md # review specific files -revdiff --all-files # browse all git-tracked files in a project +revdiff --all-files # browse all tracked files (git or jj) revdiff --all-files --exclude vendor # browse all files, excluding vendor directory revdiff --include src # include only src/ files revdiff --include src --exclude src/vendor # include src/ but exclude src/vendor/ @@ -37,9 +37,9 @@ When reviewing a single markdown file in context-only mode (e.g., `revdiff --onl ## All-Files Mode -Use `--all-files` (`-A`) to browse all git-tracked files, not just diffs. Turns revdiff into a general-purpose code annotation tool. All files shown in context-only mode with full annotation and syntax highlighting support. +Use `--all-files` (`-A`) to browse all tracked files, not just diffs. Turns revdiff into a general-purpose code annotation tool. All files shown in context-only mode with full annotation and syntax highlighting support. -- Requires a git repository (uses `git ls-files` for file discovery) +- Requires a git or jj repository (uses `git ls-files` / `jj file list` for file discovery; Mercurial is not supported) - Mutually exclusive with refs, `--staged`, and `--only` - Combine with `--include` (`-I`) to narrow to specific paths and `--exclude` (`-X`) to filter out unwanted paths diff --git a/CLAUDE.md b/CLAUDE.md index d14e5ed..cad8d39 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ TUI for reviewing diffs, files, and documents with inline annotations, built wit ## Project Structure - `app/` - composition root (`package main`), split by concern: `main.go` (entrypoint + `run()`), `config.go` (options/parsing), `stdin.go` (stdin mode), `renderer_setup.go` (VCS wiring), `themes.go` (theme CLI + adapter), `history_save.go` (session save) -- `app/diff/` - VCS interaction (git + hg), unified diff parsing, VCS detection, Mercurial support +- `app/diff/` - VCS interaction (git + hg + jj), unified diff parsing, VCS detection, Mercurial + Jujutsu support - `app/ui/` - bubbletea TUI package. Single `Model` struct with state grouped into sub-structs (`cfg`, `layout`, `file`, `modes`, `nav`, `search`, `annot`), methods split across files by concern (~500 lines each). Each source file has a matching `_test.go`. See `app/ui/doc.go` for package docs, `docs/ARCHITECTURE.md` for file-by-file breakdown. Does not import `app/theme` or `app/fsutil` — theme operations go through the `ThemeCatalog` interface - `app/ui/style/` - color/style resolution: hex-to-ANSI, lipgloss styles, SGR tracking, HSL math. Types: `Resolver`, `Renderer`, `SGR` - `app/ui/sidepane/` - file tree + markdown TOC components with cursor/offset management @@ -78,7 +78,7 @@ TUI for reviewing diffs, files, and documents with inline annotations, built wit - Highlighted lines are pre-computed once per file load, stored parallel to `diffLines` - `DiffLine.Content` has no `+`/`-` prefix - prefix is re-added at render time - Tab replacement happens at render time in `renderDiffLine`, not in diff parsing -- `setupVCSRenderer()` (in `renderer_setup.go`) detects VCS via `diff.DetectVCS()` (walks up looking for `.git`/`.hg`); if no VCS is found and `--only` is set, uses `FileReader` for standalone file review. `--stdin` skips VCS lookup entirely, validates non-TTY stdin, reads payload before starting Bubble Tea, and reopens `/dev/tty` for interactive key input. `--all-files` is git-only (not supported in hg repos). +- `setupVCSRenderer()` (in `renderer_setup.go`) detects VCS via `diff.DetectVCS()` (walks up looking for `.jj`, `.git`, `.hg` — in that precedence order; `.jj` wins over `.git` in colocated repos); if no VCS is found and `--only` is set, uses `FileReader` for standalone file review. `--stdin` skips VCS lookup entirely, validates non-TTY stdin, reads payload before starting Bubble Tea, and reopens `/dev/tty` for interactive key input. `--all-files` is supported for git and jj; not supported in hg. - `--all-files` mode uses `DirectoryReader` (git ls-files) to list all tracked files; `--include` wraps any renderer with `IncludeFilter` for prefix-based inclusion, `--exclude` wraps with `ExcludeFilter` for prefix-based exclusion (include narrows first, then exclude removes). `--include` is mutually exclusive with `--only`. `--all-files` is mutually exclusive with refs, `--staged`, and `--only`. `--stdin` is mutually exclusive with refs, `--staged`, `--only`, `--all-files`, `--include`, and `--exclude`. - `diff.readReaderAsContext()` is the shared parser for file-backed and stdin-backed context-only views. Preserve its behavior if you change binary detection, line-length handling, or line numbering. - Overlay popups managed by `overlay.Manager`. `Compose()` uses ANSI-aware compositing via `charmbracelet/x/ansi.Cut`. `HandleKey()` returns `Outcome` — Model switches on `OutcomeKind` for side effects (file jumps, theme apply/persist) diff --git a/README.md b/README.md index c7c848a..7fc1ea4 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Built for a specific use case: reviewing code changes, plans, and documents with - Horizontal scroll overflow indicators: truncated diff lines show `«` / `»` markers at the edges to signal hidden content off-screen - Line numbers: side-by-side old/new line number gutter for diffs, single column for full-context files, toggle with `L` - Mercurial support: auto-detects hg repos, translates git-style refs (HEAD, HEAD~N) to Mercurial revsets +- Jujutsu support: auto-detects jj repos (including colocated git+jj), translates git-style refs to jj revsets (`HEAD` → `@-`, `HEAD~N` → `@` plus N+1 dashes); `--all-files` supported - Blame gutter: shows author name and commit age per line, toggle with `B` - Annotate any line in the diff (added, removed, or context) plus file-level notes - Single-file auto-detection: when a diff contains exactly one file, hides the tree pane and gives full terminal width to the diff view @@ -37,7 +38,7 @@ Built for a specific use case: reviewing code changes, plans, and documents with ## Requirements -- `git` or `hg` (used to generate diffs; optional when using `--only` or `--stdin`) +- `git`, `hg`, or `jj` (used to generate diffs; optional when using `--only` or `--stdin`) ## Installation diff --git a/app/config.go b/app/config.go index 59d419f..6e73819 100644 --- a/app/config.go +++ b/app/config.go @@ -30,7 +30,7 @@ type options struct { Blame bool `long:"blame" ini-name:"blame" env:"REVDIFF_BLAME" description:"show blame gutter on startup"` WordDiff bool `long:"word-diff" ini-name:"word-diff" env:"REVDIFF_WORD_DIFF" description:"highlight intra-line word-level changes in paired add/remove lines"` ChromaStyle string `long:"chroma-style" ini-name:"chroma-style" env:"REVDIFF_CHROMA_STYLE" default:"catppuccin-macchiato" description:"chroma style for syntax highlighting"` - AllFiles bool `long:"all-files" short:"A" no-ini:"true" description:"browse all tracked files, not just diffs (git only)"` + AllFiles bool `long:"all-files" short:"A" no-ini:"true" description:"browse all tracked files, not just diffs (git and jj only)"` Stdin bool `long:"stdin" no-ini:"true" description:"review stdin as a scratch buffer"` StdinName string `long:"stdin-name" no-ini:"true" description:"synthetic file name for stdin content"` Exclude []string `long:"exclude" short:"X" ini-name:"exclude" env:"REVDIFF_EXCLUDE" env-delim:"," description:"exclude files matching prefix (may be repeated)"` diff --git a/app/diff/directory.go b/app/diff/directory.go index e1bfd4f..4816b16 100644 --- a/app/diff/directory.go +++ b/app/diff/directory.go @@ -1,6 +1,7 @@ package diff import ( + "bytes" "context" "errors" "fmt" @@ -10,38 +11,67 @@ import ( "strings" ) -// DirectoryReader is a Renderer that lists all git-tracked files and reads them as context lines. +// DirectoryReader is a Renderer that lists all tracked files and reads them as context lines. // used for --all-files mode where every tracked file is browsable, not just changed files. +// the file-listing strategy is pluggable so git, jj, or any future VCS can plug in. type DirectoryReader struct { - workDir string + workDir string + listSource string // human label used in error messages ("git ls-files", "jj file list") + listFiles func() ([]byte, error) + splitSep byte // separator between entries in the listFiles output (NUL or newline) } // NewDirectoryReader creates a DirectoryReader rooted at the given working directory. // the directory must be inside a git repository. func NewDirectoryReader(workDir string) *DirectoryReader { - return &DirectoryReader{workDir: workDir} + dr := &DirectoryReader{ + workDir: workDir, + listSource: "git ls-files", + splitSep: '\x00', + } + dr.listFiles = func() ([]byte, error) { + // use -z for NUL-separated output to avoid C-quoting of paths with non-ASCII characters + cmd := exec.CommandContext(context.Background(), "git", "ls-files", "-z") + cmd.Dir = workDir + return cmd.Output() + } + return dr +} + +// NewJjDirectoryReader creates a DirectoryReader backed by `jj file list`. +// jj file list emits one path per line (no -z / NUL mode), and jj auto-tracks +// every file in the working copy so its output is equivalent to git ls-files. +func NewJjDirectoryReader(workDir string) *DirectoryReader { + dr := &DirectoryReader{ + workDir: workDir, + listSource: "jj file list", + splitSep: '\n', + } + dr.listFiles = func() ([]byte, error) { + cmd := exec.CommandContext(context.Background(), "jj", "file", "list") + cmd.Dir = workDir + return cmd.Output() + } + return dr } -// ChangedFiles returns all git-tracked files as sorted relative paths. +// ChangedFiles returns all tracked files as sorted relative paths. // ref and staged parameters are ignored since all tracked files are returned. func (dr *DirectoryReader) ChangedFiles(_ string, _ bool) ([]FileEntry, error) { - // use -z for NUL-separated output to avoid C-quoting of paths with non-ASCII characters - cmd := exec.CommandContext(context.Background(), "git", "ls-files", "-z") - cmd.Dir = dr.workDir - out, err := cmd.Output() + out, err := dr.listFiles() if err != nil { var exitErr *exec.ExitError if errors.As(err, &exitErr) { stderr := strings.TrimSpace(string(exitErr.Stderr)) if stderr != "" { - return nil, fmt.Errorf("git ls-files: %s", stderr) + return nil, fmt.Errorf("%s: %s", dr.listSource, stderr) } } - return nil, fmt.Errorf("git ls-files: %w", err) + return nil, fmt.Errorf("%s: %w", dr.listSource, err) } - entries := make([]FileEntry, 0, strings.Count(string(out), "\x00")) - for entry := range strings.SplitSeq(string(out), "\x00") { + entries := make([]FileEntry, 0, bytes.Count(out, []byte{dr.splitSep})) + for entry := range strings.SplitSeq(string(out), string(dr.splitSep)) { if entry == "" { continue } diff --git a/app/diff/jj.go b/app/diff/jj.go new file mode 100644 index 0000000..e4ffd77 --- /dev/null +++ b/app/diff/jj.go @@ -0,0 +1,275 @@ +package diff + +import ( + "bytes" + "fmt" + "regexp" + "strings" +) + +// jjFullContext is jj's equivalent of git's -U1000000 — request full-file diff context. +// jj only accepts --context (not -U). +const jjFullContext = "--context=1000000" + +// Jj provides methods to extract changed files and build full-file diff views for Jujutsu repos. +// ref semantics are translated from the git-flavored syntax revdiff accepts: +// - empty ref → working-copy vs parent (jj default) +// - single ref X → changes from X to the working copy (@) +// - range A..B / A...B → expanded to --from/--to for jj diff +// +// HEAD and friends are translated to jj revsets (@-, @--, …). Jujutsu auto-tracks +// every file in the working copy, so UntrackedFiles always returns an empty list. +type Jj struct { + workDir string // working directory for jj commands + extraArgs []string // leading args prepended to every jj invocation (e.g. --config overrides for tests) +} + +// NewJj creates a new Jj diff renderer rooted at the given working directory. +func NewJj(workDir string) *Jj { + return &Jj{workDir: workDir} +} + +// UntrackedFiles returns untracked files. Jujutsu auto-snapshots every file in the +// working copy (respecting .gitignore), so there is no "untracked" state — this +// always returns nil. +func (j *Jj) UntrackedFiles() ([]string, error) { + return nil, nil +} + +// jjSummaryRe matches lines from `jj diff --summary`, e.g. "M path/to/file". +// Rename summary lines ("R {old => new}") are handled separately in parseSummary. +var jjSummaryRe = regexp.MustCompile(`^([MAD]) (.+)$`) + +// jjRenameRe matches a jj rename summary line: "R {old => new}" or "R prefix/{old => new}/suffix". +var jjRenameRe = regexp.MustCompile(`^R (.+)$`) + +// jjRenameBraceRe extracts the "old => new" pair from a rename target, optionally +// surrounded by a static path prefix and suffix. +var jjRenameBraceRe = regexp.MustCompile(`^(.*)\{(.+) => (.+)\}(.*)$`) + +// jjStatusToFileStatus maps a jj summary status letter to a FileStatus. +// Renames ("R") are caller-handled — they expand to two entries. +func (j *Jj) jjStatusToFileStatus(status string) FileStatus { + switch FileStatus(status) { + case FileModified: + return FileModified + case FileAdded: + return FileAdded + case FileDeleted: + return FileDeleted + default: + return "" + } +} + +// ChangedFiles lists files changed for the given ref. +// If ref is empty, reports uncommitted working-copy changes. The staged flag is +// ignored — Jujutsu has no staging area. +func (j *Jj) ChangedFiles(ref string, _ bool) ([]FileEntry, error) { + rangeArgs := j.diffRangeFlags(ref) + args := make([]string, 0, 2+len(rangeArgs)) + args = append(args, "diff", "--summary") + args = append(args, rangeArgs...) + + out, err := j.runJj(args...) + if err != nil { + return nil, fmt.Errorf("get changed files: %w", err) + } + + return j.parseSummary(out), nil +} + +// parseSummary parses `jj diff --summary` output into file entries. +// Rename lines expand to a delete + add pair so the rest of the UI can treat +// each side independently (matching hg/git behavior in this codebase). +func (j *Jj) parseSummary(out string) []FileEntry { + var entries []FileEntry + for line := range strings.SplitSeq(strings.TrimRight(out, "\n"), "\n") { + if line == "" { + continue + } + // rename? + if m := jjRenameRe.FindStringSubmatch(line); m != nil && strings.HasPrefix(line, "R ") { + if oldPath, newPath, ok := expandJjRename(m[1]); ok { + entries = append(entries, + FileEntry{Path: oldPath, Status: FileDeleted}, + FileEntry{Path: newPath, Status: FileAdded}, + ) + continue + } + } + m := jjSummaryRe.FindStringSubmatch(line) + if m == nil { + continue + } + status, path := m[1], m[2] + fs := j.jjStatusToFileStatus(status) + if fs == "" { + continue + } + entries = append(entries, FileEntry{Path: path, Status: fs}) + } + return entries +} + +// expandJjRename decodes a jj rename summary target like "prefix/{old => new}/suffix" +// into the full old and new paths. Returns false when the target is not a brace form. +func expandJjRename(target string) (oldPath, newPath string, ok bool) { + m := jjRenameBraceRe.FindStringSubmatch(target) + if m == nil { + // no braces — jj printed "R old new" or similar; fall back to splitting on " => " + if left, right, cut := strings.Cut(target, " => "); cut { + return left, right, true + } + return "", "", false + } + prefix, oldMid, newMid, suffix := m[1], m[2], m[3], m[4] + return prefix + oldMid + suffix, prefix + newMid + suffix, true +} + +// FileDiff returns the full-file diff view for a single file. +// The staged flag is ignored — Jujutsu has no staging area. +func (j *Jj) FileDiff(ref, file string, _ bool) ([]DiffLine, error) { + rangeArgs := j.diffRangeFlags(ref) + args := make([]string, 0, 5+len(rangeArgs)) + args = append(args, "diff", "--git", jjFullContext) + args = append(args, rangeArgs...) + args = append(args, "--", file) + + out, err := j.runJj(args...) + if err != nil { + return nil, fmt.Errorf("get file diff for %s: %w", file, err) + } + + // jj emits raw bytes for binary files instead of git's "Binary files … differ" + // marker. Detect and rewrite so parseUnifiedDiff produces the binary placeholder. + normalized := jjSynthesizeBinaryDiff(out) + return parseUnifiedDiff(normalized) +} + +// diffRangeFlags builds the --from/--to arguments from a git-style ref. +// See Jj docs for the mapping from HEAD-style refs to jj revsets. +func (j *Jj) diffRangeFlags(ref string) []string { + if ref == "" { + return nil + } + + // triple-dot first so "A...B" isn't mis-split on ".." + if left, right, ok := strings.Cut(ref, "..."); ok { + l := translateJjRef(left) + r := translateJjRef(right) + if r == "" { + r = "@" + } + if l == "" { + return []string{"--from", "root()", "--to", r} + } + // common ancestor revset: jj's equivalent of git's merge-base + merge := fmt.Sprintf("ancestors(%s) & ancestors(%s) & ~root()", l, r) + return []string{"--from", merge, "--to", r} + } + + if left, right, ok := strings.Cut(ref, ".."); ok { + l := translateJjRef(left) + r := translateJjRef(right) + if l == "" { + l = "root()" + } + if r == "" { + r = "@" + } + return []string{"--from", l, "--to", r} + } + + return []string{"--from", translateJjRef(ref), "--to", "@"} +} + +// translateJjRef converts git-style refs to jj revset syntax. +// - HEAD → @- (parent of working copy; jj's "last committed" equivalent) +// - HEAD~N → @ followed by (N+1) dashes +// - HEAD^ → @-- (same as HEAD~1) +// - HEAD^1 → @-- +// - HEAD^N>1 → parents(@-) (Nth-parent for merges; we can't target a specific parent cleanly) +// +// Anything else (bookmarks, change/commit IDs, the bare "@" / "@-" forms) passes through unchanged. +func translateJjRef(ref string) string { + switch { + case ref == "": + return "" + case ref == "HEAD": + return "@-" + case strings.HasPrefix(ref, "HEAD~"): + n := ref[5:] + count, ok := parseSmallPositive(n) + if !ok { + return ref + } + return "@" + strings.Repeat("-", count+1) + case ref == "HEAD^" || ref == "HEAD^1": + return "@--" + case strings.HasPrefix(ref, "HEAD^"): + // HEAD^N for N>1 means "Nth parent" — jj can reach parents via parents(@-), + // but can't single out an individual parent in one revset step; this is a + // best-effort approximation. + return "parents(@-)" + default: + return ref + } +} + +// parseSmallPositive parses a non-negative decimal integer. Returns false on +// failure. Used for HEAD~N parsing where N is a small positive integer. +func parseSmallPositive(s string) (int, bool) { + if s == "" { + return 0, false + } + n := 0 + for _, c := range s { + if c < '0' || c > '9' { + return 0, false + } + n = n*10 + int(c-'0') + if n > 1_000_000 { + return 0, false + } + } + return n, true +} + +// jjSynthesizeBinaryDiff replaces the hunk body of a jj diff with a git-style +// "Binary files … differ" marker when the hunk contains NUL bytes. parseUnifiedDiff +// already recognizes the marker and returns a binary placeholder DiffLine. +// Returns the input unchanged when no NUL bytes are present in the hunk body. +func jjSynthesizeBinaryDiff(raw string) string { + if !bytes.ContainsRune([]byte(raw), 0) { + return raw + } + + lines := strings.Split(raw, "\n") + var oldPath, newPath string + var headerEnd int + for i, line := range lines { + switch { + case strings.HasPrefix(line, "--- "): + oldPath = strings.TrimPrefix(line, "--- ") + case strings.HasPrefix(line, "+++ "): + newPath = strings.TrimPrefix(line, "+++ ") + headerEnd = i + 1 // inclusive of the "+++" line + } + } + if oldPath == "" || newPath == "" || headerEnd == 0 { + return raw + } + + marker := fmt.Sprintf("Binary files %s and %s differ", oldPath, newPath) + return strings.Join(append(lines[:headerEnd], marker), "\n") + "\n" +} + +// runJj executes a jj command in the working directory and returns its output. +// Prepends extraArgs (e.g. --no-pager) then delegates to the shared runVCS helper. +func (j *Jj) runJj(args ...string) (string, error) { + full := make([]string, 0, len(j.extraArgs)+len(args)) + full = append(full, j.extraArgs...) + full = append(full, args...) + return runVCS(j.workDir, "jj", full...) +} diff --git a/app/diff/jj_test.go b/app/diff/jj_test.go new file mode 100644 index 0000000..da004f7 --- /dev/null +++ b/app/diff/jj_test.go @@ -0,0 +1,336 @@ +package diff + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupJjRepo(t *testing.T) string { + t.Helper() + if _, err := exec.LookPath("jj"); err != nil { + t.Skip("jj not available") + } + dir := t.TempDir() + jjCmd(t, dir, "git", "init", "--quiet") + // user config for commits (stored in per-repo config path after recent jj versions) + // we pass --config inline instead, which avoids the config migration warning + return dir +} + +func jjCmd(t *testing.T, dir string, args ...string) { + t.Helper() + full := make([]string, 0, 2+len(args)) + full = append(full, + "--config=user.name=Test User", + "--config=user.email=test@test.com", + ) + full = append(full, args...) + cmd := exec.Command("jj", full...) //nolint:gosec // args constructed internally + cmd.Dir = dir + out, err := cmd.CombinedOutput() + require.NoError(t, err, "jj %v failed: %s", args, string(out)) +} + +// newJjForTest returns a Jj renderer with test-only config overrides so commits +// get a deterministic author and don't depend on the host jj config. +func newJjForTest(dir string) *Jj { + j := NewJj(dir) + j.extraArgs = []string{ + "--config=user.name=Test User", + "--config=user.email=test@test.com", + } + return j +} + +func TestTranslateJjRef(t *testing.T) { + tests := []struct { + name string + ref string + want string + }{ + {name: "empty", ref: "", want: ""}, + {name: "HEAD", ref: "HEAD", want: "@-"}, + {name: "HEAD~1", ref: "HEAD~1", want: "@--"}, + {name: "HEAD~3", ref: "HEAD~3", want: "@----"}, + {name: "HEAD^", ref: "HEAD^", want: "@--"}, + {name: "HEAD^1", ref: "HEAD^1", want: "@--"}, + {name: "HEAD^2", ref: "HEAD^2", want: "parents(@-)"}, + {name: "HEAD^3", ref: "HEAD^3", want: "parents(@-)"}, + {name: "bare hash", ref: "abc123", want: "abc123"}, + {name: "bookmark", ref: "main", want: "main"}, + {name: "jj working copy", ref: "@", want: "@"}, + {name: "jj parent", ref: "@-", want: "@-"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, translateJjRef(tt.ref)) + }) + } +} + +func TestJj_DiffRangeFlags(t *testing.T) { + j := &Jj{} + + tests := []struct { + name string + ref string + want []string + }{ + {name: "empty", ref: "", want: nil}, + {name: "single ref HEAD", ref: "HEAD", want: []string{"--from", "@-", "--to", "@"}}, + {name: "single ref bookmark", ref: "main", want: []string{"--from", "main", "--to", "@"}}, + {name: "range", ref: "main..feature", want: []string{"--from", "main", "--to", "feature"}}, + {name: "HEAD range", ref: "HEAD~3..HEAD", want: []string{"--from", "@----", "--to", "@-"}}, + {name: "left empty", ref: "..HEAD", want: []string{"--from", "root()", "--to", "@-"}}, + {name: "right empty", ref: "main..", want: []string{"--from", "main", "--to", "@"}}, + {name: "triple dot", ref: "main...feature", want: []string{"--from", "ancestors(main) & ancestors(feature) & ~root()", "--to", "feature"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, j.diffRangeFlags(tt.ref)) + }) + } +} + +func TestJj_ParseStatus(t *testing.T) { + j := &Jj{} + tests := []struct { + name string + input string + want []FileEntry + }{ + { + name: "modified file", + input: "M hello.txt\n", + want: []FileEntry{{Path: "hello.txt", Status: FileModified}}, + }, + { + name: "multiple statuses", + input: "M a.txt\nA b.txt\nD c.txt\n", + want: []FileEntry{ + {Path: "a.txt", Status: FileModified}, + {Path: "b.txt", Status: FileAdded}, + {Path: "c.txt", Status: FileDeleted}, + }, + }, + { + name: "rename expands to delete + add", + input: "R {old.txt => new.txt}\n", + want: []FileEntry{ + {Path: "old.txt", Status: FileDeleted}, + {Path: "new.txt", Status: FileAdded}, + }, + }, + { + name: "empty", + input: "", + want: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, j.parseSummary(tt.input)) + }) + } +} + +func TestJj_SynthesizeBinaryDiff(t *testing.T) { + // when jj emits raw bytes for a binary file, swap the hunk with the + // git-style "Binary files ... differ" marker so parseUnifiedDiff picks it up. + raw := "diff --git a/bin.dat b/bin.dat\n" + + "new file mode 100644\n" + + "index 0000000000..b78dd4eaf7\n" + + "--- /dev/null\n" + + "+++ b/bin.dat\n" + + "@@ -0,0 +1,1 @@\n" + + "+\x00\x01\x02binary\n" + + got := jjSynthesizeBinaryDiff(raw) + assert.Contains(t, got, "Binary files /dev/null and b/bin.dat differ") + assert.NotContains(t, got, "\x00", "null bytes should be stripped") + assert.NotContains(t, got, "+\x01\x02binary", "binary hunk body should be removed") +} + +func TestJj_SynthesizeBinaryDiff_NoBinary(t *testing.T) { + raw := "diff --git a/a.txt b/a.txt\n" + + "--- a/a.txt\n" + + "+++ b/a.txt\n" + + "@@ -1,1 +1,1 @@\n" + + "-old\n" + + "+new\n" + assert.Equal(t, raw, jjSynthesizeBinaryDiff(raw), "non-binary diff should be returned unchanged") +} + +// e2e tests below + +func TestJj_ChangedFiles_Uncommitted(t *testing.T) { + dir := setupJjRepo(t) + j := newJjForTest(dir) + + writeFile(t, dir, "hello.txt", "hello\n") + jjCmd(t, dir, "describe", "-m", "init", "--quiet") + jjCmd(t, dir, "new", "-m", "modify", "--quiet") + writeFile(t, dir, "hello.txt", "hello world\n") + + entries, err := j.ChangedFiles("", false) + require.NoError(t, err) + require.Len(t, entries, 1) + assert.Equal(t, "hello.txt", entries[0].Path) + assert.Equal(t, FileModified, entries[0].Status) +} + +func TestJj_ChangedFiles_Added(t *testing.T) { + dir := setupJjRepo(t) + j := newJjForTest(dir) + + writeFile(t, dir, "hello.txt", "hello\n") + jjCmd(t, dir, "describe", "-m", "init", "--quiet") + jjCmd(t, dir, "new", "-m", "second", "--quiet") + writeFile(t, dir, "new.txt", "new file\n") + + entries, err := j.ChangedFiles("", false) + require.NoError(t, err) + require.Len(t, entries, 1) + assert.Equal(t, "new.txt", entries[0].Path) + assert.Equal(t, FileAdded, entries[0].Status) +} + +func TestJj_ChangedFiles_NoChanges(t *testing.T) { + dir := setupJjRepo(t) + j := newJjForTest(dir) + + writeFile(t, dir, "hello.txt", "hello\n") + jjCmd(t, dir, "describe", "-m", "init", "--quiet") + jjCmd(t, dir, "new", "-m", "empty", "--quiet") + + entries, err := j.ChangedFiles("", false) + require.NoError(t, err) + assert.Empty(t, entries) +} + +func TestJj_ChangedFiles_Deleted(t *testing.T) { + dir := setupJjRepo(t) + j := newJjForTest(dir) + + writeFile(t, dir, "hello.txt", "hello\n") + jjCmd(t, dir, "describe", "-m", "init", "--quiet") + jjCmd(t, dir, "new", "-m", "rm", "--quiet") + require.NoError(t, os.Remove(filepath.Join(dir, "hello.txt"))) + + entries, err := j.ChangedFiles("", false) + require.NoError(t, err) + require.Len(t, entries, 1) + assert.Equal(t, "hello.txt", entries[0].Path) + assert.Equal(t, FileDeleted, entries[0].Status) +} + +func TestJj_FileDiff_Uncommitted(t *testing.T) { + dir := setupJjRepo(t) + j := newJjForTest(dir) + + writeFile(t, dir, "hello.txt", "line one\nline two\n") + jjCmd(t, dir, "describe", "-m", "init", "--quiet") + jjCmd(t, dir, "new", "-m", "modify", "--quiet") + writeFile(t, dir, "hello.txt", "line one\nline modified\nline three\n") + + lines, err := j.FileDiff("", "hello.txt", false) + require.NoError(t, err) + require.NotEmpty(t, lines) + + var adds, removes, ctx int + for _, l := range lines { + switch l.ChangeType { //nolint:exhaustive // only counting relevant types + case ChangeAdd: + adds++ + case ChangeRemove: + removes++ + case ChangeContext: + ctx++ + } + } + assert.Positive(t, adds, "expected some added lines") + assert.Positive(t, removes, "expected some removed lines") + assert.Positive(t, ctx, "expected some context lines") +} + +func TestJj_FileDiff_NewFile(t *testing.T) { + dir := setupJjRepo(t) + j := newJjForTest(dir) + + writeFile(t, dir, "hello.txt", "line one\n") + jjCmd(t, dir, "describe", "-m", "init", "--quiet") + jjCmd(t, dir, "new", "-m", "add", "--quiet") + writeFile(t, dir, "new.txt", "new content\n") + + lines, err := j.FileDiff("", "new.txt", false) + require.NoError(t, err) + require.NotEmpty(t, lines) + + for _, l := range lines { + if l.ChangeType == ChangeDivider { + continue + } + assert.Equal(t, ChangeAdd, l.ChangeType, "new file lines should be additions") + } +} + +func TestJj_FileDiff_Binary(t *testing.T) { + dir := setupJjRepo(t) + j := newJjForTest(dir) + + writeFile(t, dir, "placeholder.txt", "x\n") + jjCmd(t, dir, "describe", "-m", "init", "--quiet") + jjCmd(t, dir, "new", "-m", "add binary", "--quiet") + require.NoError(t, os.WriteFile(filepath.Join(dir, "bin.dat"), []byte{0x00, 0x01, 0x02, 0xff}, 0o600)) + + lines, err := j.FileDiff("", "bin.dat", false) + require.NoError(t, err) + require.Len(t, lines, 1) + assert.True(t, lines[0].IsBinary) +} + +func TestJj_DirectoryReader_ChangedFiles(t *testing.T) { + dir := setupJjRepo(t) + + writeFile(t, dir, "a.go", "package a\n") + writeFile(t, dir, "z.go", "package z\n") + writeFile(t, dir, "m.go", "package m\n") + jjCmd(t, dir, "describe", "-m", "init", "--quiet") + + dr := NewJjDirectoryReader(dir) + files, err := dr.ChangedFiles("", false) + require.NoError(t, err) + assert.Equal(t, []FileEntry{{Path: "a.go"}, {Path: "m.go"}, {Path: "z.go"}}, files) +} + +func TestJj_DirectoryReader_FileDiff(t *testing.T) { + dir := setupJjRepo(t) + + writeFile(t, dir, "main.go", "package main\n\nfunc main() {}\n") + jjCmd(t, dir, "describe", "-m", "init", "--quiet") + + dr := NewJjDirectoryReader(dir) + lines, err := dr.FileDiff("", "main.go", false) + require.NoError(t, err) + require.Len(t, lines, 3) + for _, l := range lines { + assert.Equal(t, ChangeContext, l.ChangeType) + } +} + +func TestJj_UntrackedFiles(t *testing.T) { + dir := setupJjRepo(t) + j := newJjForTest(dir) + + // jj auto-tracks everything in the working copy, so there are no "untracked" files + // in the git sense. We expect UntrackedFiles to always return an empty result. + writeFile(t, dir, "a.txt", "a\n") + files, err := j.UntrackedFiles() + require.NoError(t, err) + assert.Empty(t, files) +} diff --git a/app/diff/jjblame.go b/app/diff/jjblame.go new file mode 100644 index 0000000..4fbc09f --- /dev/null +++ b/app/diff/jjblame.go @@ -0,0 +1,89 @@ +package diff + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +// jjAnnotateTemplate is the template for `jj file annotate` that produces +// tab-separated output: change_id_short\tauthor_name\tepoch_seconds\tline_content. +// content already includes the source line's trailing newline (when present), so we +// don't append one here — doing so would introduce spurious blank lines between rows. +const jjAnnotateTemplate = `commit.change_id().short() ++ "\t" ++ commit.author().name() ++ "\t" ++ commit.author().timestamp().format("%s") ++ "\t" ++ content` + +// FileBlame returns blame information for each line of a file. +// For Jujutsu the staged flag is ignored — there is no staging area. +func (j *Jj) FileBlame(ref, file string, _ bool) (map[int]BlameLine, error) { + args := []string{"file", "annotate", "-T", jjAnnotateTemplate} + if targetRef := j.blameTargetRef(ref); targetRef != "" { + args = append(args, "-r", targetRef) + } + args = append(args, file) + + out, err := j.runJj(args...) + if err != nil { + return nil, fmt.Errorf("annotate %s: %w", file, err) + } + return j.parseAnnotate(out) +} + +// blameTargetRef picks the revision to blame at. +// Unlike git, jj can't implicitly blame the working copy — we always need to name +// a revision. Empty and single-sided ranges fall back to "@" (working copy). +func (j *Jj) blameTargetRef(ref string) string { + // check triple-dot first so "A...B" isn't mis-split on ".." + if left, right, ok := strings.Cut(ref, "..."); ok { + if left == "" || right == "" { + return "" + } + return translateJjRef(right) + } + if left, right, ok := strings.Cut(ref, ".."); ok { + if left == "" || right == "" { + return "" + } + return translateJjRef(right) + } + if ref != "" { + return translateJjRef(ref) + } + return "" +} + +// parseAnnotate turns `jj file annotate` template output into a map of 1-based +// line number to BlameLine. Each row looks like: +// +// change_id\tauthor\tepoch\tcontent +// +// content ends with \n (except possibly the final line). We rely on a 5-way +// Split (max 4 separators) so embedded tabs in content don't confuse us. +func (j *Jj) parseAnnotate(raw string) (map[int]BlameLine, error) { + result := make(map[int]BlameLine) + // strip exactly one trailing newline so we don't produce a spurious final empty row + raw = strings.TrimSuffix(raw, "\n") + if raw == "" { + return result, nil + } + + lineNum := 0 + for row := range strings.SplitSeq(raw, "\n") { + lineNum++ + + parts := strings.SplitN(row, "\t", 4) + if len(parts) < 4 { // malformed — still advance lineNum so downstream alignment holds + continue + } + + author := parts[1] + var authorTime time.Time + if epoch, err := strconv.ParseInt(parts[2], 10, 64); err == nil { + authorTime = time.Unix(epoch, 0) + } + + result[lineNum] = BlameLine{Author: author, Time: authorTime} + } + + return result, nil +} diff --git a/app/diff/jjblame_test.go b/app/diff/jjblame_test.go new file mode 100644 index 0000000..208cd1a --- /dev/null +++ b/app/diff/jjblame_test.go @@ -0,0 +1,120 @@ +package diff + +import ( + "os/exec" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestJj_ParseAnnotate(t *testing.T) { + j := &Jj{} + + raw := "abc12345\tTest User\t1775860468\tline one\n" + + "def67890\tAnother Dev\t1775860469\tline two\n" + + result, err := j.parseAnnotate(raw) + require.NoError(t, err) + require.Len(t, result, 2) + + assert.Equal(t, "Test User", result[1].Author) + assert.Equal(t, int64(1775860468), result[1].Time.Unix()) + + assert.Equal(t, "Another Dev", result[2].Author) + assert.Equal(t, int64(1775860469), result[2].Time.Unix()) +} + +func TestJj_ParseAnnotate_Empty(t *testing.T) { + j := &Jj{} + result, err := j.parseAnnotate("") + require.NoError(t, err) + assert.Empty(t, result) +} + +func TestJj_ParseAnnotate_LastLineNoNewline(t *testing.T) { + j := &Jj{} + + raw := "abc12345\tTest User\t1775860468\tline one\n" + + "abc12345\tTest User\t1775860468\tline two" + + result, err := j.parseAnnotate(raw) + require.NoError(t, err) + require.Len(t, result, 2) + assert.Equal(t, "Test User", result[2].Author) +} + +func TestJj_ParseAnnotate_BlankContent(t *testing.T) { + j := &Jj{} + + // blank source line produces empty content field + raw := "abc12345\tTest User\t1775860468\t\n" + + "abc12345\tTest User\t1775860468\thello\n" + + result, err := j.parseAnnotate(raw) + require.NoError(t, err) + require.Len(t, result, 2) + assert.Equal(t, "Test User", result[1].Author) + assert.Equal(t, "Test User", result[2].Author) +} + +func TestJj_ParseAnnotate_MalformedLine(t *testing.T) { + j := &Jj{} + + raw := "bad\tdata\n" + + "abc12345\tTest User\t1775860468\tline one\n" + + result, err := j.parseAnnotate(raw) + require.NoError(t, err) + // line 1 is malformed (skipped), line 2 is valid at lineNum=2 + require.Len(t, result, 1) + assert.Equal(t, "Test User", result[2].Author) +} + +func TestJj_BlameTargetRef(t *testing.T) { + j := &Jj{} + + tests := []struct { + name string + ref string + want string + }{ + {name: "empty", ref: "", want: ""}, + {name: "single ref", ref: "HEAD", want: "@-"}, + {name: "single hash", ref: "abc123", want: "abc123"}, + {name: "bookmark", ref: "main", want: "main"}, + {name: "double dot", ref: "main..feature", want: "feature"}, + {name: "triple dot", ref: "main...feature", want: "feature"}, + {name: "HEAD range", ref: "HEAD~3..HEAD", want: "@-"}, + {name: "left empty double dot", ref: "..feature", want: ""}, + {name: "right empty double dot", ref: "main..", want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, j.blameTargetRef(tt.ref)) + }) + } +} + +func TestJj_FileBlame_Integration(t *testing.T) { + if _, err := exec.LookPath("jj"); err != nil { + t.Skip("jj not available") + } + + dir := setupJjRepo(t) + j := newJjForTest(dir) + + writeFile(t, dir, "hello.txt", "line one\n") + jjCmd(t, dir, "describe", "-m", "first", "--quiet") + jjCmd(t, dir, "new", "-m", "second", "--quiet") + writeFile(t, dir, "hello.txt", "line one\nline two\n") + + blame, err := j.FileBlame("", "hello.txt", false) + require.NoError(t, err) + require.Len(t, blame, 2) + + assert.Equal(t, "Test User", blame[1].Author) + assert.Equal(t, "Test User", blame[2].Author) + + assert.False(t, blame[2].Time.Before(blame[1].Time)) +} diff --git a/app/diff/vcs.go b/app/diff/vcs.go index 8c54a72..0307e02 100644 --- a/app/diff/vcs.go +++ b/app/diff/vcs.go @@ -11,12 +11,15 @@ type VCSType string const ( VCSGit VCSType = "git" VCSHg VCSType = "hg" + VCSJJ VCSType = "jj" VCSNone VCSType = "" ) -// DetectVCS walks up from startDir looking for .git or .hg directories. +// DetectVCS walks up from startDir looking for .jj, .git, or .hg directories. // returns the VCS type and repo root path. If no VCS is found, returns VCSNone and empty string. -// when both .git and .hg exist in the same directory, git takes precedence. +// precedence (checked in order at each directory): jj, git, hg. Jujutsu is commonly +// colocated with a .git directory; in that case jj wins so operations target the jj +// working-copy model rather than bypassing it through git. func DetectVCS(startDir string) (VCSType, string) { dir, err := filepath.Abs(startDir) if err != nil { @@ -24,7 +27,11 @@ func DetectVCS(startDir string) (VCSType, string) { } for { - // check .git first (takes precedence); .git can be a file in worktrees/submodules + // check .jj first — jj often colocates with .git; jj wins so we don't bypass the jj model + if info, err := os.Stat(filepath.Join(dir, ".jj")); err == nil && info.IsDir() { + return VCSJJ, dir + } + // .git can be a file in worktrees/submodules if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil { return VCSGit, dir } diff --git a/app/diff/vcs_test.go b/app/diff/vcs_test.go index d3b74ff..72afb67 100644 --- a/app/diff/vcs_test.go +++ b/app/diff/vcs_test.go @@ -29,7 +29,17 @@ func TestDetectVCS_Hg(t *testing.T) { assert.Equal(t, dir, root) } -func TestDetectVCS_GitTakesPrecedence(t *testing.T) { +func TestDetectVCS_Jj(t *testing.T) { + dir := t.TempDir() + err := os.Mkdir(filepath.Join(dir, ".jj"), 0o750) + require.NoError(t, err) + + vcs, root := DetectVCS(dir) + assert.Equal(t, VCSJJ, vcs) + assert.Equal(t, dir, root) +} + +func TestDetectVCS_GitTakesPrecedenceOverHg(t *testing.T) { dir := t.TempDir() require.NoError(t, os.Mkdir(filepath.Join(dir, ".git"), 0o750)) require.NoError(t, os.Mkdir(filepath.Join(dir, ".hg"), 0o750)) @@ -39,6 +49,19 @@ func TestDetectVCS_GitTakesPrecedence(t *testing.T) { assert.Equal(t, dir, root) } +// TestDetectVCS_JjTakesPrecedenceOverGit covers colocated jj+git repos. +// Jujutsu often coexists with .git (colocated mode); treat it as jj to avoid +// jj-unsafe git diff operations and to surface the actual working-copy model. +func TestDetectVCS_JjTakesPrecedenceOverGit(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(dir, ".jj"), 0o750)) + require.NoError(t, os.Mkdir(filepath.Join(dir, ".git"), 0o750)) + + vcs, root := DetectVCS(dir) + assert.Equal(t, VCSJJ, vcs) + assert.Equal(t, dir, root) +} + func TestDetectVCS_WalksUp(t *testing.T) { dir := t.TempDir() require.NoError(t, os.Mkdir(filepath.Join(dir, ".hg"), 0o750)) diff --git a/app/renderer_setup.go b/app/renderer_setup.go index b8c5821..f4d1222 100644 --- a/app/renderer_setup.go +++ b/app/renderer_setup.go @@ -43,6 +43,16 @@ func setupVCSRenderer(opts options) (vcsSetup, error) { return vcsSetup{}, err } return vcsSetup{renderer: r, workDir: workDir, blamer: h, untrackedFn: h.UntrackedFiles}, nil + case diff.VCSJJ: + if opts.Staged { + fmt.Fprintln(os.Stderr, "warning: --staged ignored in jujutsu repository (no staging area)") + } + jj := diff.NewJj(vcsRoot) + r, workDir, err := makeJjRenderer(jj, opts, vcsRoot) + if err != nil { + return vcsSetup{}, err + } + return vcsSetup{renderer: r, workDir: workDir, blamer: jj, untrackedFn: jj.UntrackedFiles}, nil default: r, workDir, err := makeNoVCSRenderer(opts.Only, cwd) if err != nil { @@ -82,6 +92,21 @@ func makeHgRenderer(h *diff.Hg, opts options, repoRoot string) (ui.Renderer, str return wrapFilters(r, opts), repoRoot, nil } +// makeJjRenderer selects the appropriate jujutsu renderer based on flags. +// reuses the provided *Jj instance as the default renderer to avoid double allocation. +func makeJjRenderer(j *diff.Jj, opts options, repoRoot string) (ui.Renderer, string, error) { //nolint:unparam // error kept for consistency with makeGitRenderer/makeHgRenderer + var r ui.Renderer + switch { + case opts.AllFiles: + r = diff.NewJjDirectoryReader(repoRoot) + case len(opts.Only) > 0: + r = diff.NewFallbackRenderer(j, opts.Only, repoRoot) + default: + r = j + } + return wrapFilters(r, opts), repoRoot, nil +} + // wrapFilters applies include/exclude filters to a renderer based on opts. func wrapFilters(r ui.Renderer, opts options) ui.Renderer { if len(opts.Include) > 0 { @@ -98,7 +123,7 @@ func wrapFilters(r ui.Renderer, opts options) ui.Renderer { // --exclude is a no-op here (FileReader only returns the --only files). func makeNoVCSRenderer(only []string, cwd string) (ui.Renderer, string, error) { if len(only) == 0 { - return nil, "", errors.New("no git or mercurial repository found (use --only to review standalone files)") + return nil, "", errors.New("no git, mercurial, or jujutsu repository found (use --only to review standalone files)") } return diff.NewFileReader(only, cwd), cwd, nil } diff --git a/app/renderer_setup_test.go b/app/renderer_setup_test.go index 3cf0ad0..21076b6 100644 --- a/app/renderer_setup_test.go +++ b/app/renderer_setup_test.go @@ -46,7 +46,7 @@ func TestMakeNoVCSRenderer_NoOnly(t *testing.T) { require.Error(t, err) assert.Nil(t, renderer) assert.Empty(t, workDir) - assert.Contains(t, err.Error(), "no git or mercurial repository found") + assert.Contains(t, err.Error(), "no git, mercurial, or jujutsu repository found") } func TestMakeGitRenderer_AllFiles(t *testing.T) { @@ -136,6 +136,56 @@ func TestMakeHgRenderer_WithInclude(t *testing.T) { assert.Equal(t, dir, workDir) } +func TestMakeJjRenderer_Default(t *testing.T) { + dir := t.TempDir() + j := diff.NewJj(dir) + renderer, workDir, err := makeJjRenderer(j, options{}, dir) + require.NoError(t, err) + require.NotNil(t, renderer) + assert.IsType(t, &diff.Jj{}, renderer) + assert.Equal(t, dir, workDir) +} + +func TestMakeJjRenderer_WithOnly(t *testing.T) { + dir := t.TempDir() + j := diff.NewJj(dir) + renderer, workDir, err := makeJjRenderer(j, options{Only: []string{"file.go"}}, dir) + require.NoError(t, err) + require.NotNil(t, renderer) + assert.IsType(t, &diff.FallbackRenderer{}, renderer) + assert.Equal(t, dir, workDir) +} + +func TestMakeJjRenderer_AllFiles(t *testing.T) { + dir := t.TempDir() + j := diff.NewJj(dir) + renderer, workDir, err := makeJjRenderer(j, options{AllFiles: true}, dir) + require.NoError(t, err) + require.NotNil(t, renderer) + assert.IsType(t, &diff.DirectoryReader{}, renderer) + assert.Equal(t, dir, workDir) +} + +func TestMakeJjRenderer_WithExclude(t *testing.T) { + dir := t.TempDir() + j := diff.NewJj(dir) + renderer, workDir, err := makeJjRenderer(j, options{Exclude: []string{"vendor"}}, dir) + require.NoError(t, err) + require.NotNil(t, renderer) + assert.IsType(t, &diff.ExcludeFilter{}, renderer) + assert.Equal(t, dir, workDir) +} + +func TestMakeJjRenderer_WithInclude(t *testing.T) { + dir := t.TempDir() + j := diff.NewJj(dir) + renderer, workDir, err := makeJjRenderer(j, options{Include: []string{"src"}}, dir) + require.NoError(t, err) + require.NotNil(t, renderer) + assert.IsType(t, &diff.IncludeFilter{}, renderer) + assert.Equal(t, dir, workDir) +} + func TestMakeGitRenderer_AllFilesWithInclude(t *testing.T) { dir := t.TempDir() g := diff.NewGit(dir) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ac5f024..d0f4cc7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -42,7 +42,7 @@ TUI for reviewing diffs, files, and documents with inline annotations, built wit | `main.go` | `main()`, early-exit commands (version, dump-config, dump-keys), `run()` orchestration | | `config.go` | `options` struct, `parseArgs`, `dumpConfig`, `loadConfigFile`, config-path helpers | | `stdin.go` | stdin validation, `/dev/tty` reopen, stdin renderer prep | -| `renderer_setup.go` | `DetectVCS` wiring, `setupVCSRenderer` (git/hg/no-VCS/all-files) | +| `renderer_setup.go` | `DetectVCS` wiring, `setupVCSRenderer` (git/hg/jj/no-VCS/all-files) | | `themes.go` | theme CLI commands (`--init-themes`, `--install-theme`, `--list-themes`, `--theme`), `applyTheme()`, `themeCatalog` adapter (composes `theme.Catalog` + config persistence for `ui.ThemeCatalog` interface) | | `history_save.go` | `histReq` struct and `saveHistory` | @@ -68,13 +68,14 @@ ModelConfig{ Handles all interaction with version control systems and diff parsing. -**VCS detection** (`vcs.go`): `DetectVCS()` walks up directory tree looking for `.git`/`.hg` markers, returns `VCSGit`, `VCSHg`, or `VCSNone`. +**VCS detection** (`vcs.go`): `DetectVCS()` walks up directory tree looking for `.jj`/`.git`/`.hg` markers, returns `VCSJJ`, `VCSGit`, `VCSHg`, or `VCSNone`. `.jj` is checked before `.git` so colocated jj+git repositories resolve as jj (reads go through the jj working-copy model instead of bypassing it via git). **Renderer implementations** — all implement the `ui.Renderer` interface (`ChangedFiles()` + `FileDiff()`): - `Git` — runs `git diff`, parses unified diff output - `Hg` — runs `hg diff --git`, parses unified diff output +- `Jj` — runs `jj diff --git`, parses unified diff output; git-style refs (HEAD, HEAD~N, A..B) translate to jj revsets via `--from`/`--to`. jj emits raw bytes for binary files, so `jjSynthesizeBinaryDiff` rewrites such diffs with the git-style "Binary files … differ" marker so `parseUnifiedDiff` produces a binary placeholder. - `FileReader` — reads standalone files as full-context (no VCS needed) -- `DirectoryReader` — lists all tracked files via `git ls-files` (for `--all-files` mode) +- `DirectoryReader` — lists all tracked files via a pluggable lister (`git ls-files` by default; `NewJjDirectoryReader` uses `jj file list`) for `--all-files` mode - `StdinReader` — reads from stdin as scratch buffer - `FallbackRenderer` — wraps a primary renderer with fallback for files not in diff - `ExcludeFilter` / `IncludeFilter` — decorators for prefix-based file filtering @@ -84,7 +85,7 @@ Handles all interaction with version control systems and diff parsing. - `Change` — `ChangeAdd`, `ChangeRemove`, `ChangeContext`, or `ChangeDivider` - `OldNum` / `NewNum` — original and new line numbers (0 for non-applicable) -**Blame** (`blame.go`, `hgblame.go`): `Blamer` interface provides `FileBlame()` returning `map[int]BlameLine` keyed by new line number. +**Blame** (`blame.go`, `hgblame.go`, `jjblame.go`): `Blamer` interface provides `FileBlame()` returning `map[int]BlameLine` keyed by new line number. jj blame uses `jj file annotate -T