diff --git a/dot_bash_profile.tmpl b/dot_bash_profile.tmpl index f6f00e3..1f4c4c0 100644 --- a/dot_bash_profile.tmpl +++ b/dot_bash_profile.tmpl @@ -224,7 +224,8 @@ gh () { local gh_personal_repo_root="$HOME/dev/repos/github.com/aguil" if [ -z "${GH_CONFIG_DIR:-}" ]; then - if route_config_dir="$(_chez_gh_route_config_dir)"; then + if declare -F _chez_gh_route_config_dir >/dev/null 2>&1 \ + && route_config_dir="$(_chez_gh_route_config_dir)"; then GH_CONFIG_DIR="$route_config_dir" command gh "$@" return $? fi @@ -249,6 +250,10 @@ gh () { command gh "$@" } +# Agent subprocesses (Claude Code, Cursor) rehydrate shell state from export -p +# and inherit BASH_FUNC_gh without private helpers. Export the helper too. +export -f _chez_gh_route_config_dir gh 2>/dev/null || true + tmuxdev () { local chezmoi_source local script_path diff --git a/dot_config/nvim/init.lua b/dot_config/nvim/init.lua index 2eea20a..5d42ae8 100644 --- a/dot_config/nvim/init.lua +++ b/dot_config/nvim/init.lua @@ -411,14 +411,11 @@ require('lazy').setup({ -- [[ Configure Telescope ]] -- See `:help telescope` and `:help telescope.setup()` require('telescope').setup { - -- You can put your default mappings / updates / etc. in here - -- All the info you're looking for is in `:help telescope.setup()` - -- - -- defaults = { - -- mappings = { - -- i = { [''] = 'to_fuzzy_refine' }, - -- }, - -- }, + defaults = { + dynamic_preview_title = true, + qflist_previewer = require('custom.telescope_delta').qflist_previewer, + grep_previewer = require('custom.telescope_delta').grep_previewer, + }, pickers = { find_files = { hidden = true }, }, @@ -426,6 +423,7 @@ require('lazy').setup({ ['ui-select'] = { require('telescope.themes').get_dropdown() }, }, } + require('custom.telescope_delta').apply_git_previewers() -- Enable Telescope extensions if they are installed pcall(require('telescope').load_extension, 'fzf') diff --git a/dot_config/nvim/lua/custom/plugins/git.lua b/dot_config/nvim/lua/custom/plugins/git.lua index 9040e76..4867983 100644 --- a/dot_config/nvim/lua/custom/plugins/git.lua +++ b/dot_config/nvim/lua/custom/plugins/git.lua @@ -54,64 +54,6 @@ local function system_async(cmd, cwd, callback) end) end -local function system_ok(cmd, cwd) - local _, code = system_result(cmd, cwd) - return code == 0 -end - -local function shell_join(cmd) - return table.concat(vim.tbl_map(vim.fn.shellescape, cmd), ' ') -end - -local function default_branch_candidates() - return vim.g.dot_vcs_default_branches or { 'master', 'main' } -end - -local function find_git_default_branch(root) - local lines, code = system_result({ - 'git', - 'symbolic-ref', - '--quiet', - '--short', - 'refs/remotes/origin/HEAD', - }, root) - local origin_head = lines[1] - if code == 0 and origin_head and origin_head ~= '' then - return origin_head - end - - for _, branch in ipairs(default_branch_candidates()) do - if system_ok({ 'git', 'rev-parse', '--verify', '--quiet', branch }, root) then - return branch - end - if system_ok({ 'git', 'rev-parse', '--verify', '--quiet', 'origin/' .. branch }, root) then - return 'origin/' .. branch - end - end -end - -local function find_jj_default_branch(root) - for _, branch in ipairs(default_branch_candidates()) do - if system_ok({ 'jj', 'log', '--no-graph', '--limit', '1', '-r', branch, '-T', 'commit_id' }, root) then - return branch - end - local remote_branch = branch .. '@origin' - if system_ok({ - 'jj', - 'log', - '--no-graph', - '--limit', - '1', - '-r', - remote_branch, - '-T', - 'commit_id', - }, root) then - return remote_branch - end - end -end - local function stat_path(line) line = line:gsub('\27%[[%d;]*m', '') local path = line:match '^%s*(.-)%s*|' @@ -144,21 +86,22 @@ local function open_branch_changes() return end - local base = kind == 'jj' and find_jj_default_branch(root) or find_git_default_branch(root) - if not base then - vim.notify('Could not find default branch. Set vim.g.dot_vcs_default_branches.', vim.log.levels.WARN) + local range = vcs.resolve_diff_range(kind, root) + if not range then + vim.notify('Could not resolve diff range. Set vim.g.dot_vcs_default_branches.', vim.log.levels.WARN) return end - local jj_target = kind == 'jj' and '@' or nil - local range = kind == 'jj' and base .. '..' .. jj_target or base .. '...HEAD' - local name_cmd = kind == 'jj' and { 'jj', '--color', 'never', 'diff', '--name-only', '-r', range } - or { 'git', 'diff', '--no-color', '--name-only', base .. '...HEAD' } + local name_cmd = vcs.branch_name_only_cmd(kind, root) + if not name_cmd then + vim.notify('Could not resolve diff range. Set vim.g.dot_vcs_default_branches.', vim.log.levels.WARN) + return + end system_async(name_cmd, root, function(paths, code, stderr) if code ~= 0 then local detail = stderr ~= '' and (': ' .. stderr) or '.' - vim.notify('Could not load branch changes for ' .. base .. detail, vim.log.levels.WARN) + vim.notify('Could not load changes for ' .. range .. detail, vim.log.levels.WARN) return end @@ -170,7 +113,7 @@ local function open_branch_changes() end if vim.tbl_isempty(entries) then - vim.notify('No branch change files found for ' .. base .. '.', vim.log.levels.INFO) + vim.notify('No changed files for ' .. range .. '.', vim.log.levels.INFO) return end @@ -181,20 +124,20 @@ local function open_branch_changes() local conf = require('telescope.config').values local actions = require 'telescope.actions' local action_state = require 'telescope.actions.state' + local telescope_delta = require 'custom.telescope_delta' local file_entry_maker = make_entry.gen_from_file { cwd = root } - local delta_available = vim.fn.executable 'delta' == 1 local diff_previewer = previewers.new_buffer_previewer { title = 'Branch diff', define_preview = function(self, entry) local path = entry.value - local diff_cmd = kind == 'jj' and { 'jj', '--color', 'never', 'diff', '--git', '-r', range, '--', path } - or { 'git', 'diff', '--no-color', range, '--', path } + local diff_cmd = vcs.file_branch_diff_cmd(kind, root, path) + if not diff_cmd then + vim.api.nvim_buf_set_lines(self.state.bufnr, 0, -1, false, { 'No diff for ' .. path }) + return + end - if delta_available then - local command = shell_join(diff_cmd) .. ' | ' .. shell_join { 'delta', '--default-language', 'bash' } - vim.api.nvim_buf_call(self.state.bufnr, function() - vim.fn.termopen(command, { cwd = root }) - end) + if telescope_delta.available() then + telescope_delta.termopen_diff(self.state.bufnr, root, diff_cmd) return end @@ -219,8 +162,7 @@ local function open_branch_changes() pickers .new({}, { - prompt_title = kind == 'jj' and 'jj: branch changes ' .. range - or 'git: branch changes vs ' .. base, + prompt_title = kind == 'jj' and ('jj: changes ' .. range) or ('git: changes ' .. range), finder = finders.new_table { results = entries, entry_maker = function(path) @@ -366,60 +308,29 @@ return { return vim.fs.normalize(path:sub(1, #path - #suffix - 1)) end - local jj_signs_base_mode = 'default_branch' - local jj_blame_enabled = true - local jj_blame_ns = vim.api.nvim_create_namespace 'dot-jj-current-line-blame' - local jj_blame_group = vim.api.nvim_create_augroup('dot-jj-current-line-blame', { clear = true }) - - local function resolve_jj_signs_base(root) - if jj_signs_base_mode == 'working_copy' then - return '@-' - end - return find_jj_default_branch(root) or '@-' - end - local function set_jj_signs_base(root) - local base = resolve_jj_signs_base(root) + local base = vcs.resolve_jj_signs_base(root) require('jjsigns.config').config.base = base return base end - local function toggle_jj_signs_base() - local bufname = vim.api.nvim_buf_get_name(0) - local path = bufname ~= '' and vim.fs.normalize(bufname) or vim.fn.getcwd(0) - local root = vcs.find_root(path) - if not root or not vim.uv.fs_stat(root .. '/.jj') then - vim.notify('Not inside a jj repository.', vim.log.levels.WARN) - return - end - - if jj_signs_base_mode == 'default_branch' then - jj_signs_base_mode = 'working_copy' - else - jj_signs_base_mode = 'default_branch' - if not find_jj_default_branch(root) then - vim.notify('Could not find default branch. Falling back to @-. Set vim.g.dot_vcs_default_branches.', vim.log.levels.WARN) - end - end - - local base = set_jj_signs_base(root) - require('jjsigns.attach').refresh_all() - vim.notify('jjsigns base: ' .. base) - end - local function default_jj_signs_base() local root = vcs.find_root(vim.fn.getcwd(0)) if not root or not vim.uv.fs_stat(root .. '/.jj') then return '@-' end - local base = find_jj_default_branch(root) + local base = vcs.find_jj_default_branch(root) if not base then return end return base end + local jj_blame_enabled = true + local jj_blame_ns = vim.api.nvim_create_namespace 'dot-jj-current-line-blame' + local jj_blame_group = vim.api.nvim_create_augroup('dot-jj-current-line-blame', { clear = true }) + local function clear_jj_blame(bufnr) vim.api.nvim_buf_clear_namespace(bufnr, jj_blame_ns, 0, -1) end @@ -597,7 +508,6 @@ return { end, }) - vim.keymap.set('n', 'tJ', toggle_jj_signs_base, { desc = 'Toggle jj signs base' }) end, }, @@ -623,6 +533,7 @@ return { 'NeogitOrg/neogit', init = function() vim.keymap.set('n', 'gg', open_vcs_ui, { desc = 'VCS: Neogit or jj status' }) + vim.keymap.set('n', 'tJ', vcs.toggle_base_mode, { desc = 'Toggle diff base (default branch / working copy)' }) end, dependencies = { 'nvim-lua/plenary.nvim', diff --git a/dot_config/nvim/lua/custom/telescope_delta.lua b/dot_config/nvim/lua/custom/telescope_delta.lua new file mode 100644 index 0000000..4eb76b7 --- /dev/null +++ b/dot_config/nvim/lua/custom/telescope_delta.lua @@ -0,0 +1,683 @@ +--- Delta-backed diff previews for Telescope git pickers. +local M = {} + +local vcs = require 'custom.vcs' + +local delta_args = { 'delta', '--paging=never', '--default-language', 'bash' } +local delta_location_args = { + 'delta', + '--paging=never', + '--default-language', + 'bash', + '--line-numbers', +} + +local function delta_args_for(profile) + if profile == 'location' then + return delta_location_args + end + return delta_args +end + +-- Highlight the reference row after delta renders (location previews only). +local ref_line_highlight_awk = [[ +function strip_ansi(s) { + gsub(/\033\[[0-9;]*[A-Za-z]/, "", s) + return s +} +function delta_lnum(s, p, rest) { + p = strip_ansi(s) + if (match(p, /⋮[ ]+[0-9]+[ ]+[0-9]+[ ]*│/)) { + rest = substr(p, RSTART, RLENGTH) + sub(/^.*⋮[ ]+[0-9]+[ ]+/, "", rest) + sub(/[ ]*│.*/, "", rest) + return rest + 0 + } + if (match(p, /⋮[ ]+[0-9]+[ ]*│/)) { + rest = substr(p, RSTART, RLENGTH) + sub(/^.*⋮[ ]+/, "", rest) + sub(/[ ]*│.*/, "", rest) + return rest + 0 + } + return -1 +} +function ref_open() { + if (has_fg > 0) + return sprintf("\033[48;2;%d;%d;%dm\033[38;2;%d;%d;%dm", bg_r, bg_g, bg_b, fg_r, fg_g, fg_b) + return sprintf("\033[48;2;%d;%d;%dm", bg_r, bg_g, bg_b) +} +{ + n = delta_lnum($0) + if (n == target) + printf "%s%s\033[0m\n", ref_open(), $0 + else + print $0 +} +]] + +local function color_to_rgb(color) + if not color then + return nil + end + return math.floor(color / 65536) % 256, math.floor(color / 256) % 256, color % 256 +end + +local function resolve_hl(name) + local seen = {} + while name and not seen[name] do + seen[name] = true + local hl = vim.api.nvim_get_hl(0, { name = name, link = false }) + if hl.link then + name = hl.link + else + return hl + end + end + return {} +end + +local function reference_line_highlight_args() + local hl = resolve_hl 'TelescopePreviewLine' + local bg_r, bg_g, bg_b = color_to_rgb(hl.bg) + local fg_r, fg_g, fg_b = color_to_rgb(hl.fg) + + if not bg_r then + hl = resolve_hl 'Visual' + bg_r, bg_g, bg_b = color_to_rgb(hl.bg) + fg_r, fg_g, fg_b = color_to_rgb(hl.fg) + end + + if not bg_r then + bg_r, bg_g, bg_b = 40, 40, 40 + end + + local args = string.format('-v bg_r=%d -v bg_g=%d -v bg_b=%d -v has_fg=0', bg_r, bg_g, bg_b) + if fg_r then + args = string.format( + '-v bg_r=%d -v bg_g=%d -v bg_b=%d -v fg_r=%d -v fg_g=%d -v fg_b=%d -v has_fg=1', + bg_r, + bg_g, + bg_b, + fg_r, + fg_g, + fg_b + ) + end + return args +end + +local function delta_pipeline_command(diff_cmd, opts) + local delta = delta_args_for(opts.profile) + local command = M.shell_join(diff_cmd) .. ' | ' .. M.shell_join(delta) + if opts.profile == 'location' and opts.lnum and opts.lnum > 0 then + command = string.format( + '%s | awk -v target=%d %s %s', + command, + opts.lnum, + reference_line_highlight_args(), + vim.fn.shellescape(ref_line_highlight_awk) + ) + end + return command +end + +local function strip_ansi(text) + return text:gsub('\27%[[0-9;]*[A-Za-z]', '') +end + +local function delta_line_number(plain) + local old_num, new_num = plain:match('⋮%s+(%d+)%s+(%d+)%s*│') + if new_num then + return tonumber(new_num) + end + + local single = plain:match('⋮%s+(%d+)%s*│') + if single then + return tonumber(single) + end + + return nil +end + +local function find_reference_line(lines, lnum) + for i, line in ipairs(lines) do + if delta_line_number(strip_ansi(line)) == lnum then + return i + end + end + return nil +end + +local location_scroll_epoch = 0 +local last_terminal_scroll = {} +local diff_covers_line = {} + +local function bump_scroll_epoch() + location_scroll_epoch = location_scroll_epoch + 1 + return location_scroll_epoch +end + +local function clear_terminal_scroll_state() + last_terminal_scroll = {} +end + +local function clear_location_preview_state() + clear_terminal_scroll_state() + diff_covers_line = {} +end + +local function line_preview_key(path, lnum) + return (path or '') .. ':' .. tostring(lnum or '') .. ':' .. vcs.base_mode +end + +local function reset_terminal_preview_buffer(bufnr) + if not vim.api.nvim_buf_is_valid(bufnr) or vim.bo[bufnr].buftype ~= 'terminal' then + return + end + + vim.bo[bufnr].modifiable = true + vim.bo[bufnr].buftype = '' + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, {}) + vim.bo[bufnr].modified = false +end + +local function diff_buffer_has_line(bufnr, lnum) + if not lnum or lnum <= 0 or not vim.api.nvim_buf_is_valid(bufnr) then + return false + end + return find_reference_line(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), lnum) ~= nil +end + +local function preview_win_execute(winid, command) + if not winid or not vim.api.nvim_win_is_valid(winid) then + return + end + pcall(vim.fn.win_execute, winid, command) +end + +local function scroll_terminal_to_line(bufnr, winid, lnum, opts) + opts = opts or {} + if not lnum or lnum <= 0 or not vim.api.nvim_win_is_valid(winid) then + return + end + + local epoch = opts.epoch + local scroll_key = bufnr .. ':' .. lnum + if last_terminal_scroll[scroll_key] and not opts.force then + return + end + + local function preview_stale() + return opts.preview_key and opts.get_preview_key and opts.get_preview_key() ~= opts.preview_key + end + + local function step(attempt) + if preview_stale() then + return + end + if epoch and epoch ~= location_scroll_epoch then + return + end + if not vim.api.nvim_buf_is_valid(bufnr) then + return + end + + local target = find_reference_line(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), lnum) + if not target then + if attempt == 0 then + vim.defer_fn(function() + step(1) + end, 100) + return + end + if opts.on_missing_line then + opts.on_missing_line() + end + return + end + + preview_win_execute(winid, 'normal! ' .. target .. 'Gzz') + last_terminal_scroll[scroll_key] = true + if opts.path then + diff_covers_line[line_preview_key(opts.path, lnum)] = true + end + end + + if opts.immediate then + vim.schedule(function() + step(0) + end) + else + vim.defer_fn(function() + step(0) + end, 30) + end +end + +--- @param opts? { profile?: 'location', lnum?: integer, winid?: integer } +--- Render `diff_cmd` piped through delta in a preview terminal buffer. +function M.termopen_diff(bufnr, cwd, diff_cmd, opts) + opts = opts or {} + + if not vim.api.nvim_buf_is_valid(bufnr) then + return + end + + -- Reused preview buffers stay as terminals; delta output is already rendered. + if vim.bo[bufnr].buftype == 'terminal' then + if opts.lnum and opts.winid then + if not diff_buffer_has_line(bufnr, opts.lnum) then + if opts.on_missing_line then + opts.on_missing_line() + end + return + end + scroll_terminal_to_line(bufnr, opts.winid, opts.lnum, opts) + end + return + end + + local epoch = bump_scroll_epoch() + clear_terminal_scroll_state() + + vim.bo[bufnr].modifiable = true + vim.bo[bufnr].buftype = '' + + local first_line = vim.api.nvim_buf_get_lines(bufnr, 0, 1, false)[1] or '' + if vim.api.nvim_buf_line_count(bufnr) > 1 or first_line ~= '' then + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, {}) + end + + -- termopen/jobstart({term=true}) requires an unmodified buffer. + vim.bo[bufnr].modified = false + + local command = delta_pipeline_command(diff_cmd, opts) + vim.api.nvim_buf_call(bufnr, function() + vim.fn.jobstart({ 'bash', '-lc', command }, { + cwd = cwd, + term = true, + env = vim.fn.environ(), + on_exit = function() + vim.schedule(function() + if opts.lnum and opts.winid then + scroll_terminal_to_line(bufnr, opts.winid, opts.lnum, vim.tbl_extend('force', opts, { epoch = epoch })) + end + end) + end, + }) + end) +end + +function M.available() + return vim.fn.executable 'delta' == 1 +end + +function M.shell_join(cmd) + return table.concat(vim.tbl_map(vim.fn.shellescape, cmd), ' ') +end + +--- @param opts? { context?: integer } +function M.file_diff_spec(filepath, cwd, opts) + return vcs.file_diff_spec(filepath, { cwd = cwd, context = opts and opts.context }) +end + +function M.file_has_diff(filepath, cwd) + return vcs.file_has_diff(filepath, { cwd = cwd }) +end + +local function vimgrep_fallback_preview(self, entry, opts, cwd, jump_to_line) + local api = vim.api + local from_entry = require 'telescope.from_entry' + local conf = require('telescope.config').values + + local has_buftype = entry.bufnr and api.nvim_buf_is_valid(entry.bufnr) and vim.bo[entry.bufnr].buftype ~= '' + or false + local path + if not has_buftype then + path = from_entry.path(entry, true, false) + if path == nil or path == '' then + return + end + end + + if entry.bufnr and (path == '[No Name]' or has_buftype) then + local lines = api.nvim_buf_get_lines(entry.bufnr, 0, -1, false) + api.nvim_buf_set_lines(self.state.bufnr, 0, -1, false, lines) + vim.schedule(function() + jump_to_line(self, self.state.bufnr, entry) + end) + return + end + + conf.buffer_previewer_maker(path, self.state.bufnr, { + bufname = self.state.bufname, + winid = self.state.winid, + preview = opts.preview, + callback = function(bufnr) + jump_to_line(self, bufnr, entry) + end, + file_encoding = opts.file_encoding, + }) +end + +local function make_location_previewer(title, opts) + local previewers = require 'telescope.previewers' + local from_entry = require 'telescope.from_entry' + local Path = require 'plenary.path' + local api = vim.api + local hl = vim.hl + local ns_previewer = api.nvim_create_namespace 'telescope.previewers' + + opts = opts or {} + local cwd = opts.cwd or vim.uv.cwd() + + local function entry_path(entry, validate) + return from_entry.path(entry, validate, false) + end + + local function entry_lnum(entry) + if entry.lnum and entry.lnum > 0 then + return entry.lnum + end + local value = entry.value + if type(value) == 'table' and value.lnum and value.lnum > 0 then + return value.lnum + end + return nil + end + + local function entry_col(entry) + if entry.col and entry.col > 0 then + return entry.col + end + local value = entry.value + if type(value) == 'table' and value.col and value.col > 0 then + return value.col + end + return nil + end + + local function location_suffix(entry) + local lnum = entry_lnum(entry) + if not lnum then + return '' + end + local col = entry_col(entry) + if col then + return ':' .. lnum .. ':' .. col + end + return ':' .. lnum + end + + local function preview_label(entry) + local path = entry_path(entry, false) + if not path or path == '' then + return title + end + path = Path:new(path):normalize(cwd) + local suffix = location_suffix(entry) + if M.available() and M.file_has_diff(path, cwd) then + local spec = M.file_diff_spec(path, cwd) + if spec then + return 'Diff (' .. spec.label .. '): ' .. path .. suffix + end + end + return path .. suffix + end + + local jump_to_line = function(self, bufnr, entry) + pcall(api.nvim_buf_clear_namespace, bufnr, ns_previewer, 0, -1) + + if entry.lnum and entry.lnum > 0 then + local lnum, lnend = entry.lnum - 1, (entry.lnend or entry.lnum) - 1 + local col, colend = 0, -1 + if entry.col and entry.colend then + col, colend = entry.col - 1, entry.colend - 1 + end + + for i = lnum, lnend do + pcall( + hl.range, + bufnr, + ns_previewer, + 'TelescopePreviewLine', + { i, i == lnum and col or 0 }, + { i, i == lnend and colend or -1 } + ) + end + + local middle_ln = math.floor(lnum + (lnend - lnum) / 2) + pcall(api.nvim_win_set_cursor, self.state.winid, { middle_ln + 1, 0 }) + preview_win_execute(self.state.winid, 'normal! zz') + end + end + + return previewers.new_buffer_previewer { + title = title, + dyn_title = function(_, entry) + return preview_label(entry) + end, + teardown = function() + bump_scroll_epoch() + clear_location_preview_state() + vcs.clear_file_diff_cache() + end, + get_buffer_by_name = function(_, entry) + local path = entry_path(entry, false) + if not path or path == '' then + return nil + end + local lnum = entry_lnum(entry) + if lnum and diff_covers_line[line_preview_key(path, lnum)] == false then + return path + end + if M.available() and M.file_has_diff(path, cwd) then + local suffix = lnum and ('::' .. lnum) or '' + return path .. '::diff::' .. vcs.base_mode .. suffix + end + return path + end, + define_preview = function(self, entry, status) + local preview_winid = status and status.layout and status.layout.preview and status.layout.preview.winid + if preview_winid and vim.api.nvim_win_is_valid(preview_winid) then + self.state.winid = preview_winid + end + + local path = entry_path(entry, true) + local lnum = entry_lnum(entry) + local col = entry_col(entry) + local preview_key = table.concat({ + path or '', + tostring(lnum or ''), + tostring(col or ''), + vcs.base_mode, + }, ':') + + if self._delta_preview_key == preview_key then + return + end + self._delta_preview_key = preview_key + clear_terminal_scroll_state() + + local function show_file_at_line() + if path and lnum then + diff_covers_line[line_preview_key(path, lnum)] = false + end + reset_terminal_preview_buffer(self.state.bufnr) + vimgrep_fallback_preview(self, entry, opts, cwd, jump_to_line) + end + + if path and M.available() and lnum and diff_covers_line[line_preview_key(path, lnum)] ~= false then + local preview_height = self.state.winid and vim.api.nvim_win_get_height(self.state.winid) or 20 + local context = math.max(6, math.floor(preview_height / 3)) + local spec = M.file_diff_spec(path, cwd, { context = context }) + if spec then + local scroll_opts = { + profile = 'location', + lnum = lnum, + winid = self.state.winid, + path = path, + immediate = true, + force = true, + preview_key = preview_key, + get_preview_key = function() + return self._delta_preview_key + end, + on_missing_line = show_file_at_line, + } + M.termopen_diff(self.state.bufnr, spec.cwd or cwd, spec.cmd, scroll_opts) + return + end + end + + show_file_at_line() + end, + } +end + +--- Quickfix/LSP previewer: delta git diff when the file has local changes, else file-at-line. +function M.qflist_previewer(opts) + return make_location_previewer('Location Preview', opts) +end + +--- Grep previewer: same git-diff-first behavior as qflist (used by live_grep, etc.). +function M.grep_previewer(opts) + return make_location_previewer('Grep Preview', opts) +end + +local function git_file_diff_previewer(opts) + local previewers = require 'telescope.previewers' + local conf = require('telescope.config').values + local from_entry = require 'telescope.from_entry' + local git_command = require('telescope.utils').__git_command + + return previewers.new_buffer_previewer { + title = 'Git File Diff Preview', + get_buffer_by_name = function(_, entry) + return entry.value + end, + define_preview = function(self, entry) + if entry.status and (entry.status == '??' or entry.status == 'A ') then + local p = from_entry.path(entry, true, false) + if p == nil or p == '' then + return + end + conf.buffer_previewer_maker(p, self.state.bufnr, { + bufname = self.state.bufname, + winid = self.state.winid, + preview = opts.preview, + file_encoding = opts.file_encoding, + }) + return + end + + local diff_cmd = git_command({ '--no-pager', 'diff', 'HEAD', '--', entry.value }, opts) + M.termopen_diff(self.state.bufnr, opts.cwd, diff_cmd) + end, + } +end + +local function git_commit_diff_previewer(title, build_args, opts) + local previewers = require 'telescope.previewers' + local git_command = require('telescope.utils').__git_command + + return previewers.new_buffer_previewer { + title = title, + get_buffer_by_name = function(_, entry) + return entry.value + end, + define_preview = function(self, entry) + local diff_cmd = git_command(build_args(entry, opts), opts) + M.termopen_diff(self.state.bufnr, opts.cwd, diff_cmd) + end, + } +end + +local function git_commit_diff_to_parent_previewer(opts) + return git_commit_diff_previewer('Git Diff to Parent Preview', function(entry, picker_opts) + local args = { '--no-pager', 'diff', entry.value .. '^!' } + if picker_opts.current_file then + table.insert(args, '--') + table.insert(args, picker_opts.current_file) + end + return args + end, opts) +end + +local function git_commit_diff_to_head_previewer(opts) + return git_commit_diff_previewer('Git Diff to Head Preview', function(entry, picker_opts) + local args = { '--no-pager', 'diff', '--cached', entry.value } + if picker_opts.current_file then + table.insert(args, '--') + table.insert(args, picker_opts.current_file) + end + return args + end, opts) +end + +local function git_commit_diff_as_was_previewer(opts) + local Path = require 'plenary.path' + local previewers = require 'telescope.previewers' + local git_command = require('telescope.utils').__git_command + + return previewers.new_buffer_previewer { + title = 'Git Show Preview', + get_buffer_by_name = function(_, entry) + return entry.value + end, + define_preview = function(self, entry) + local cf = opts.current_file and Path:new(opts.current_file):make_relative(opts.cwd) + local value = cf and (entry.value .. ':' .. cf) or entry.value + local diff_cmd = git_command({ '--no-pager', 'show', value }, opts) + M.termopen_diff(self.state.bufnr, opts.cwd, diff_cmd) + end, + } +end + +local function git_stash_diff_previewer(opts) + local previewers = require 'telescope.previewers' + local git_command = require('telescope.utils').__git_command + + return previewers.new_buffer_previewer { + title = 'Git Stash Preview', + get_buffer_by_name = function(_, entry) + return entry.value + end, + define_preview = function(self, entry) + local diff_cmd = git_command({ '--no-pager', 'stash', 'show', '-p', entry.value }, opts) + M.termopen_diff(self.state.bufnr, opts.cwd, diff_cmd) + end, + } +end + +local function wrap_previewer_new(name, factory) + local previewers = require 'telescope.previewers' + local defaulter = previewers[name] + if not defaulter or type(defaulter.new) ~= 'function' then + return + end + + local orig_new = defaulter.new + defaulter.new = function(opts) + if M.available() then + return factory(opts) + end + return orig_new(opts) + end +end + +--- Replace Telescope git diff previewers with delta term previews when available. +function M.apply_git_previewers() + if M._applied or not M.available() then + return + end + M._applied = true + + wrap_previewer_new('git_file_diff', git_file_diff_previewer) + wrap_previewer_new('git_commit_diff_to_parent', git_commit_diff_to_parent_previewer) + wrap_previewer_new('git_commit_diff_to_head', git_commit_diff_to_head_previewer) + wrap_previewer_new('git_commit_diff_as_was', git_commit_diff_as_was_previewer) + wrap_previewer_new('git_stash_diff', git_stash_diff_previewer) +end + +return M diff --git a/dot_config/nvim/lua/custom/vcs.lua b/dot_config/nvim/lua/custom/vcs.lua index 2592016..8383222 100644 --- a/dot_config/nvim/lua/custom/vcs.lua +++ b/dot_config/nvim/lua/custom/vcs.lua @@ -1,6 +1,9 @@ ---- Detect Git vs jj-only repository roots for keymap dispatch. +--- Git/jj workspace helpers and shared diff-base toggle state. local M = {} +---@type 'default_branch'|'working_copy' +M.base_mode = 'default_branch' + local function parent_dir(p) local next_parent = vim.fs.dirname(p) if next_parent == p then @@ -9,6 +12,24 @@ local function parent_dir(p) return next_parent end +local function system_result(cmd, cwd) + local result = vim.system(cmd, { cwd = cwd, text = true }):wait() + local stdout = result.stdout or '' + if stdout:sub(-1) == '\n' then + stdout = stdout:sub(1, -2) + end + return stdout, result.code +end + +function M.system_ok(cmd, cwd) + local _, code = system_result(cmd, cwd) + return code == 0 +end + +function M.default_branch_candidates() + return vim.g.dot_vcs_default_branches or { 'master', 'main' } +end + --- Walk upward from `path` to the first directory containing `.git` or `.jj`. --- @param path string file or directory path --- @return string|nil @@ -27,8 +48,23 @@ function M.find_root(path) return nil end ---- When both `.jj` and `.git` exist (colocated), prefer `jj` so keymaps match your primary workflow. ---- Pure Git trees (`.git` only) still return `git`. +--- @param path string|nil +--- @return 'git'|'jj'|'none' +function M.workspace_kind_for_path(path) + local root = M.find_root(path or vim.fn.getcwd(0)) + if not root then + return 'none' + end + if vim.uv.fs_stat(root .. '/.jj') then + return 'jj' + end + if vim.uv.fs_stat(root .. '/.git') then + return 'git' + end + return 'none' +end + +--- When both `.jj` and `.git` exist (colocated), prefer `jj`. --- @param bufnr integer --- @return 'git'|'jj'|'none' function M.workspace_kind(bufnr) @@ -39,17 +75,402 @@ function M.workspace_kind(bufnr) else path = vim.fn.getcwd(0) end - local root = M.find_root(path) + return M.workspace_kind_for_path(path) +end + +function M.find_git_default_branch(root) + local stdout, code = system_result({ + 'git', + 'symbolic-ref', + '--quiet', + '--short', + 'refs/remotes/origin/HEAD', + }, root) + if code == 0 and stdout ~= '' then + return stdout + end + + for _, branch in ipairs(M.default_branch_candidates()) do + if M.system_ok({ 'git', 'rev-parse', '--verify', '--quiet', branch }, root) then + return branch + end + if M.system_ok({ 'git', 'rev-parse', '--verify', '--quiet', 'origin/' .. branch }, root) then + return 'origin/' .. branch + end + end +end + +function M.find_jj_default_branch(root) + for _, branch in ipairs(M.default_branch_candidates()) do + if M.system_ok({ 'jj', 'log', '--no-graph', '--limit', '1', '-r', branch, '-T', 'commit_id' }, root) then + return branch + end + local remote_branch = branch .. '@origin' + if M.system_ok({ + 'jj', + 'log', + '--no-graph', + '--limit', + '1', + '-r', + remote_branch, + '-T', + 'commit_id', + }, root) then + return remote_branch + end + end +end + +function M.base_mode_label() + return M.base_mode == 'working_copy' and 'working copy' or 'default branch' +end + +function M.resolve_jj_signs_base(root) + if M.base_mode == 'working_copy' then + return '@-' + end + return M.find_jj_default_branch(root) or '@-' +end + +--- Revision range / ref label for diffs in the current base mode. +--- @return string|nil +function M.diff_range_label(kind, root) + if kind == 'jj' then + if M.base_mode == 'working_copy' then + return '@-..@' + end + local base = M.find_jj_default_branch(root) or '@-' + return base .. '..@' + end + + if kind == 'git' then + if M.base_mode == 'working_copy' then + return 'HEAD (working tree)' + end + local base = M.find_git_default_branch(root) + if not base then + return nil + end + return base .. '...HEAD' + end + + return nil +end + +--- Range string for listing or diffing branch/working-copy changes. +--- @return string|nil +function M.resolve_diff_range(kind, root) + return M.diff_range_label(kind, root) +end + +function M.relative_path(filepath, root) + filepath = vim.fs.normalize(filepath) + root = vim.fs.normalize(root) + if vim.startswith(filepath, root .. '/') then + return filepath:sub(#root + 2) + end + return nil +end + +local function git_untracked_diff_cmd(filepath) + if not vim.uv.fs_stat(filepath) then + return nil + end + return { 'git', '--no-pager', 'diff', '--no-index', '/dev/null', filepath } +end + +local function git_tracked_has_diff(root, rel, range_or_head) + if M.base_mode == 'working_copy' then + return not M.system_ok({ 'git', '-C', root, 'diff', '--quiet', 'HEAD', '--', rel }, root) + end + return not M.system_ok({ 'git', '-C', root, 'diff', '--quiet', range_or_head, '--', rel }, root) +end + +local function jj_has_diff(root, range, relpath) + local stdout, code = system_result({ + 'jj', + '--color', + 'never', + 'diff', + '--name-only', + '-r', + range, + '--', + relpath, + }, root) + return code == 0 and stdout ~= '' +end + +--- @class vcs.FileDiffSpec +--- @field cmd string[] +--- @field cwd string +--- @field label string + +local function jj_diff_cmd(range, relpath, context) + local cmd = { 'jj', '--color', 'never', 'diff', '--git' } + if context then + vim.list_extend(cmd, { '--context', tostring(context) }) + end + vim.list_extend(cmd, { '-r', range, '--', relpath }) + return cmd +end + +local function git_diff_cmd(diff_args, relpath, context) + local cmd = { 'git', '--no-pager', 'diff', '--no-color' } + if context then + vim.list_extend(cmd, { '-U', tostring(context) }) + end + vim.list_extend(cmd, diff_args) + table.insert(cmd, '--') + table.insert(cmd, relpath) + return cmd +end + +local function git_diff_cmd(diff_args, relpath, context) + local cmd = { 'git', '--no-pager', 'diff', '--no-color' } + if context then + vim.list_extend(cmd, { '-U', tostring(context) }) + end + vim.list_extend(cmd, diff_args) + table.insert(cmd, '--') + table.insert(cmd, relpath) + return cmd +end + +local diff_cache = { + has = {}, + spec = {}, +} + +function M.clear_file_diff_cache() + diff_cache.has = {} + diff_cache.spec = {} +end + +--- @class vcs.FileRef +--- @field filepath string +--- @field root string +--- @field kind 'git'|'jj' +--- @field relpath string +--- @field label string + +--- @return vcs.FileRef|nil +local function resolve_file(filepath) + if not filepath or filepath == '' then + return nil + end + + filepath = vim.fs.normalize(filepath) + local root = M.find_root(filepath) if not root then - return 'none' + return nil end - if vim.uv.fs_stat(root .. '/.jj') then - return 'jj' + + local kind = M.workspace_kind_for_path(filepath) + if kind == 'none' then + return nil end - if vim.uv.fs_stat(root .. '/.git') then - return 'git' + + local relpath = M.relative_path(filepath, root) + if not relpath then + return nil end - return 'none' + + local label = M.diff_range_label(kind, root) + if not label then + return nil + end + + return { + filepath = filepath, + root = root, + kind = kind, + relpath = relpath, + label = label, + } +end + +local function has_cache_key(ref) + return ref.root .. '\0' .. ref.relpath .. '\0' .. ref.kind .. '\0' .. M.base_mode +end + +--- @param ref vcs.FileRef +local function file_has_diff_ref(ref) + local key = has_cache_key(ref) + if diff_cache.has[key] ~= nil then + return diff_cache.has[key] + end + + local has = false + if ref.kind == 'jj' then + if vim.fn.executable 'jj' == 1 then + local range = M.resolve_diff_range('jj', ref.root) + has = range ~= nil and jj_has_diff(ref.root, range, ref.relpath) + end + elseif vim.fn.executable 'git' == 1 then + local tracked = M.system_ok({ 'git', '-C', ref.root, 'ls-files', '--error-unmatch', '--', ref.relpath }, ref.root) + if tracked then + local range = M.resolve_diff_range('git', ref.root) + if range then + local diff_ref = M.base_mode == 'working_copy' and 'HEAD' or range + has = git_tracked_has_diff(ref.root, ref.relpath, diff_ref) + end + elseif M.base_mode == 'working_copy' then + has = git_untracked_diff_cmd(ref.filepath) ~= nil + end + end + + diff_cache.has[key] = has + return has +end + +--- Whether `filepath` has a diff for the active base mode. +--- @param filepath string +--- @param opts? { cwd?: string } +function M.file_has_diff(filepath, opts) + opts = opts or {} + local ref = resolve_file(filepath) + if not ref then + return false + end + return file_has_diff_ref(ref) +end + +--- Build a per-file diff command when the file differs for the active base mode. +--- @param filepath string +--- @param opts? { cwd?: string, context?: integer } +--- @return vcs.FileDiffSpec|nil +function M.file_diff_spec(filepath, opts) + opts = opts or {} + local ref = resolve_file(filepath) + if not ref or not file_has_diff_ref(ref) then + return nil + end + + local context = opts.context + local spec_key = has_cache_key(ref) .. '\0' .. tostring(context or '') + local cached = diff_cache.spec[spec_key] + if cached ~= nil then + return cached == false and nil or cached + end + + local spec + if ref.kind == 'jj' then + local range = M.resolve_diff_range('jj', ref.root) + spec = { + cmd = jj_diff_cmd(range, ref.relpath, context), + cwd = ref.root, + label = ref.label, + } + elseif M.base_mode == 'working_copy' then + local tracked = M.system_ok({ 'git', '-C', ref.root, 'ls-files', '--error-unmatch', '--', ref.relpath }, ref.root) + if tracked then + spec = { + cmd = git_diff_cmd({ 'HEAD' }, ref.relpath, context), + cwd = ref.root, + label = ref.label, + } + else + spec = { + cmd = git_untracked_diff_cmd(ref.filepath), + cwd = ref.root, + label = ref.label, + } + end + else + local range = M.resolve_diff_range('git', ref.root) + spec = { + cmd = git_diff_cmd({ range }, ref.relpath, context), + cwd = ref.root, + label = ref.label, + } + end + + diff_cache.spec[spec_key] = spec or false + return spec +end + +--- Command to list changed paths for the branch-changes picker. +--- @return string[]|nil +function M.branch_name_only_cmd(kind, root) + if kind == 'jj' then + local range = M.resolve_diff_range('jj', root) + if not range then + return nil + end + return { 'jj', '--color', 'never', 'diff', '--name-only', '-r', range } + end + + if kind == 'git' then + if M.base_mode == 'working_copy' then + return { 'git', 'diff', '--no-color', '--name-only', 'HEAD' } + end + local base = M.find_git_default_branch(root) + if not base then + return nil + end + return { 'git', 'diff', '--no-color', '--name-only', base .. '...HEAD' } + end + + return nil +end + +--- Per-file diff command for the branch-changes picker preview. +--- @return string[]|nil +function M.file_branch_diff_cmd(kind, root, relpath) + if kind == 'jj' then + local range = M.resolve_diff_range('jj', root) + if not range then + return nil + end + return { 'jj', '--color', 'never', 'diff', '--git', '-r', range, '--', relpath } + end + + if kind == 'git' then + if M.base_mode == 'working_copy' then + return { 'git', 'diff', '--no-color', 'HEAD', '--', relpath } + end + local range = M.resolve_diff_range('git', root) + if not range then + return nil + end + return { 'git', 'diff', '--no-color', range, '--', relpath } + end + + return nil +end + +function M.toggle_base_mode() + M.base_mode = M.base_mode == 'default_branch' and 'working_copy' or 'default_branch' + M.clear_file_diff_cache() + + local bufname = vim.api.nvim_buf_get_name(0) + local path = bufname ~= '' and vim.fs.normalize(bufname) or vim.fn.getcwd(0) + local root = M.find_root(path) + local kind = root and M.workspace_kind_for_path(path) or 'none' + + if M.base_mode == 'default_branch' and root then + if kind == 'jj' and not M.find_jj_default_branch(root) then + vim.notify('Could not find default branch. Falling back to @-. Set vim.g.dot_vcs_default_branches.', vim.log.levels.WARN) + elseif kind == 'git' and not M.find_git_default_branch(root) then + vim.notify('Could not find default branch. Set vim.g.dot_vcs_default_branches.', vim.log.levels.WARN) + end + end + + if kind == 'jj' then + local ok = pcall(function() + require('jjsigns.config').config.base = M.resolve_jj_signs_base(root) + require('jjsigns.attach').refresh_all() + end) + if not ok then + -- jjsigns not loaded yet; telescope previews still honor base_mode. + end + end + + local range = root and M.diff_range_label(kind, root) or nil + local detail = range and (' (' .. range .. ')') or '' + vim.notify('Diff base: ' .. M.base_mode_label() .. detail) end return M diff --git a/dot_config/vale/styles/config/vocabularies/Dotfiles/accept.base.txt b/dot_config/vale/styles/config/vocabularies/Dotfiles/accept.base.txt index 9747ddc..6cbe59f 100644 --- a/dot_config/vale/styles/config/vocabularies/Dotfiles/accept.base.txt +++ b/dot_config/vale/styles/config/vocabularies/Dotfiles/accept.base.txt @@ -77,3 +77,4 @@ (?i)\bboolean\b (?i)\bbooleans\b (?i)\bshuffleable\b +(?i)\bgithub\b diff --git a/dot_gitignore_global b/dot_gitignore_global index 867d55c..8461e8d 100644 --- a/dot_gitignore_global +++ b/dot_gitignore_global @@ -121,3 +121,5 @@ Session.vim tags # Persistent undo [._]*.un~ + +**/.claude/settings.local.json diff --git a/dot_zshrc.tmpl b/dot_zshrc.tmpl index b8dcfb9..34d04dd 100644 --- a/dot_zshrc.tmpl +++ b/dot_zshrc.tmpl @@ -115,7 +115,8 @@ gh () { local gh_personal_repo_root="$HOME/dev/repos/github.com/aguil" if [ -z "${GH_CONFIG_DIR:-}" ]; then - if route_config_dir="$(_chez_gh_route_config_dir)"; then + if whence -w _chez_gh_route_config_dir >/dev/null 2>&1 \ + && route_config_dir="$(_chez_gh_route_config_dir)"; then GH_CONFIG_DIR="$route_config_dir" command gh "$@" return $? fi