From 6355377311b2b6f47cda8b6454955b7ec6d76123 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 12 Nov 2026 02:17:29 +0000 Subject: [PATCH 1/5] fix(cwd): find a repository whose folder name has a space, a comma or other path characters cwd.git_root has two steps that took a path as a pattern: - The fallback for a host where git cannot run called vim.fn.finddir with `start_dir .. ";"`. finddir reads that as a list in 'path' syntax, so a space or a comma split it and a `;` ended it, and the lookup returned nil for a repository in such a folder. - normalize_dir passed every path through vim.fn.expand, which reads `[`, `{`, `*`, `?`, `$` and `\` as patterns or variables. A folder named `[x] {y}` came back with a trailing slash, and one named `$HOME-like` or `back\slash` came back nil, also when git answered. normalize_dir now takes a directory that exists as written and expands only a string that is not a directory (`~`, `$VAR`). The fallback walks up from the full path of the start folder and tests each folder for a `.git` folder, so no path goes through a pattern. A relative start folder now reaches the folders above the current one too. --- .../skills/verify-diff-lifecycle/covered.lock | 2 +- lua/claudecode/README.md | 1 + lua/claudecode/cwd.lua | 38 +-- .../integration/cwd_path_characters_spec.lua | 221 ++++++++++++++++++ 4 files changed, 248 insertions(+), 14 deletions(-) create mode 100644 tests/integration/cwd_path_characters_spec.lua diff --git a/.agents/skills/verify-diff-lifecycle/covered.lock b/.agents/skills/verify-diff-lifecycle/covered.lock index 834236432..b7c8d8cde 100644 --- a/.agents/skills/verify-diff-lifecycle/covered.lock +++ b/.agents/skills/verify-diff-lifecycle/covered.lock @@ -1,7 +1,7 @@ == lua/claudecode/config.lua file lua/claudecode/config.lua 787fa0432d1d77d7 == lua/claudecode/cwd.lua -file lua/claudecode/cwd.lua 55d26ea362173afa +file lua/claudecode/cwd.lua 5db7383290d4145f == lua/claudecode/diff.lua file lua/claudecode/diff.lua a21129cdc802e417 block resolve_saved 46d5f3f732711fe1 diff --git a/lua/claudecode/README.md b/lua/claudecode/README.md index 2a8703860..8a73a1bfe 100644 --- a/lua/claudecode/README.md +++ b/lua/claudecode/README.md @@ -104,6 +104,7 @@ Module map (direct files): - Provider selection: `auto` uses snacks if available, else native. `external` requires `external_terminal_cmd` as a function or as a string containing `%s`. Every other failure falls back to `native` [terminal.lua:get_provider] - The Claude command is `terminal_cmd`, or `claude` when it is unset. `no_proxy` and `NO_PROXY` always include `localhost`, `127.0.0.1`, and `::1` [terminal.lua:get_claude_command_and_env] - Terminal cwd precedence: `cwd_provider`, then static `cwd`, then the git root when `git_repo_cwd` is set [terminal.lua:build_config] +- The cwd lookups take a directory path as written: a folder name with `[`, `{`, `*`, `?`, `$`, `\`, a space or a comma stays that folder, and only a string that is not a directory is expanded (`~`, `$VAR`). When `git rev-parse` cannot run or finds no repository, `git_root` walks up from the full path of `start_dir` to the nearest folder that holds a `.git` folder [cwd.lua:M.git_root] [cwd.lua:normalize_dir] - `send_to_terminal` never opens a terminal. It refuses the `none` and `external` providers. It wraps multi-line text in bracketed-paste markers [terminal.lua:M.send_to_terminal] - `utils.parse_command` does no globbing or variable expansion, so model aliases such as `opus[1m]` stay intact [utils.lua:M.parse_command] diff --git a/lua/claudecode/cwd.lua b/lua/claudecode/cwd.lua index 27f4dc16b..7e87960d4 100644 --- a/lua/claudecode/cwd.lua +++ b/lua/claudecode/cwd.lua @@ -3,6 +3,13 @@ local M = {} +---Tell whether a path names an existing directory, as written: no expansion. +---@param path string +---@return boolean +local function is_directory(path) + return vim.fn.isdirectory(path) == 1 +end + ---Normalize and validate a directory path ---@param dir string|nil ---@return string|nil @@ -10,13 +17,14 @@ local function normalize_dir(dir) if type(dir) ~= "string" or dir == "" then return nil end + -- A directory that exists is taken as written. `vim.fn.expand` reads `[`, `{`, `*`, `?`, `$` and `\` in a name as + -- patterns or variables, so it would change the path of a folder that has them, or find none. + if is_directory(dir) then + return dir + end -- Expand ~ and similar local expanded = vim.fn.expand(dir) - local isdir = 1 - if vim.fn.isdirectory then - isdir = vim.fn.isdirectory(expanded) - end - if isdir == 1 then + if is_directory(expanded) then return expanded end return nil @@ -53,16 +61,20 @@ function M.git_root(start_dir) end end - -- Fallback: search for .git directory upward - if vim.fn.finddir then - local git_dir = vim.fn.finddir(".git", start_dir .. ";") - if type(git_dir) == "string" and git_dir ~= "" then - local parent = vim.fn.fnamemodify(git_dir, ":h") - return normalize_dir(parent) + -- Fallback: search for .git directory upward. The walk tests each folder as written. `vim.fn.finddir` reads its path + -- as a list ('path' option syntax), so a space or a comma in the path split it, and a `;` ended it. It starts from the + -- full path, so a relative `start_dir` also reaches the folders above the current one. + local dir = vim.fn.fnamemodify(start_dir, ":p:h") + while true do + if vim.fn.isdirectory(dir .. "/.git") == 1 then + return dir end + local parent = vim.fn.fnamemodify(dir, ":h") + if parent == dir then + return nil + end + dir = parent end - - return nil end ---Resolve the effective working directory based on terminal config and context diff --git a/tests/integration/cwd_path_characters_spec.lua b/tests/integration/cwd_path_characters_spec.lua new file mode 100644 index 000000000..e602bb0a3 --- /dev/null +++ b/tests/integration/cwd_path_characters_spec.lua @@ -0,0 +1,221 @@ +if vim == nil or vim.fn.has("nvim") ~= 1 then + describe("the git root of a folder whose name holds unusual characters", function() + pending("needs a real Neovim") + end) + return +end + +if vim.fn.has("win32") == 1 then + describe("the git root of a folder whose name holds unusual characters", function() + pending("these names are not legal on Windows") + end) + return +end + +-- cwd.git_root finds the repository of a folder, so a repository in a folder with a space, a comma or a quote in its +-- name must be found like any other. `git rev-parse` is the first try. When git cannot run (it is not installed, or +-- not on PATH), a walk up to the nearest `.git` folder answers. Both took the path as a pattern: the walk read it as a +-- list of folders ('path' option syntax), and every lookup passed it to `vim.fn.expand`, which reads `[`, `{`, `*`, `?`, +-- `$` and `\` as patterns or variables. +describe("the git root of a folder whose name holds unusual characters", function() + local cwd = require("claudecode.cwd") + local root, empty_bin, saved + + -- Each name is legal in a path on Linux and macOS. The folder is the repository. + local names = { + { name = "no unusual character", folder = "repo" }, + { name = "a space", folder = "with space" }, + { name = "a comma", folder = "comma,dir" }, + { name = "a semicolon", folder = "semi;colon" }, + { name = "a single quote", folder = "it's" }, + { name = "double quotes", folder = 'say "hi"' }, + { name = "brackets and braces", folder = "[x] {y}" }, + { name = "parentheses", folder = "(a) (b)" }, + { name = "non-ASCII letters", folder = "ünï-cødé-日本語" }, + { name = "a star and a question mark", folder = "star*mark?" }, + { name = "a percent sign and a hash", folder = "percent%d#hash" }, + { name = "a backslash", folder = "back\\slash" }, + { name = "a dollar sign", folder = "$HOME-like" }, + { name = "a leading tilde", folder = "~tilde" }, + } + + --- Whether a folder above the test folders belongs to a repository, which would answer for a folder that has none. + local function repository_above(dir) + while true do + if vim.fn.isdirectory(dir .. "/.git") == 1 then + return true + end + local parent = vim.fn.fnamemodify(dir, ":h") + if parent == dir then + return false + end + dir = parent + end + end + + local function realpath(path) + return vim.loop.fs_realpath(path) or path + end + + --- Makes `//` with a `sub/deeper` folder in it. + ---@param make_repo fun(repo: string) Makes the folder a repository + local function repo_in(mode, folder, make_repo) + local repo = root .. "/" .. mode .. "/" .. folder + vim.fn.mkdir(repo .. "/sub/deeper", "p") + make_repo(repo) + return repo + end + + before_each(function() + root = vim.fn.tempname() + empty_bin = root .. "/empty-bin" + vim.fn.mkdir(empty_bin, "p") + saved = { path = vim.env.PATH, shell = vim.o.shell } + end) + + after_each(function() + vim.env.PATH, vim.o.shell = saved.path, saved.shell + vim.fn.delete(root, "rf") + end) + + --- Runs `fn` with git off PATH, as on a host that has no git. The shell keeps its absolute path, so a command that + --- names git ends with "not found" and shell_error 127, and a list command raises E475, as they do there. + local function without_git(fn) + local shell = vim.fn.exepath("sh") + assert.is_true(shell ~= "", "the test needs sh") + vim.o.shell, vim.env.PATH = shell, empty_bin + local ok, result = pcall(fn) + vim.o.shell, vim.env.PATH = saved.shell, saved.path + assert(ok, result) + return result + end + + describe("when git cannot run", function() + it("really cannot run git here", function() + without_git(function() + assert.equals(0, vim.fn.executable("git")) + end) + end) + + for _, case in ipairs(names) do + it("finds a repository whose folder has " .. case.name, function() + local repo = repo_in("no-git", case.folder, function(path) + vim.fn.mkdir(path .. "/.git", "p") + end) + + local from_root = without_git(function() + return cwd.git_root(repo) + end) + local from_subfolder = without_git(function() + return cwd.git_root(repo .. "/sub/deeper") + end) + + assert.equals(realpath(repo), from_root and realpath(from_root), "from the repository folder") + assert.equals(realpath(repo), from_subfolder and realpath(from_subfolder), "from a folder inside it") + -- The answer is the folder itself, not another spelling of it. + assert.is_nil(from_root and from_root:find("/$"), "no trailing slash") + end) + end + + -- A folder above the test folders can hold a repository, for example a TMPDIR inside a checkout. + local make_non_repo_test = repository_above(vim.fn.fnamemodify(vim.fn.tempname(), ":h")) and pending or it + make_non_repo_test("finds no repository for a folder that has none, whatever its name", function() + for _, case in ipairs(names) do + local folder = root .. "/non-repo/" .. case.folder + vim.fn.mkdir(folder, "p") + + local found = without_git(function() + return cwd.git_root(folder) + end) + + assert.is_nil(found, "a non-repository folder with " .. case.name) + end + end) + end) + + describe("when git runs", function() + if vim.fn.executable("git") ~= 1 then + pending("needs git") + return + end + + for _, case in ipairs(names) do + it("finds a repository whose folder has " .. case.name, function() + local repo = repo_in("git", case.folder, function(path) + vim.fn.system({ "git", "init", "-q", path }) + assert.equals(0, vim.v.shell_error, "git init") + end) + + local from_root = cwd.git_root(repo) + local from_subfolder = cwd.git_root(repo .. "/sub/deeper") + + assert.equals(realpath(repo), from_root and realpath(from_root), "from the repository folder") + assert.equals(realpath(repo), from_subfolder and realpath(from_subfolder), "from a folder inside it") + -- The answer is the folder itself, not another spelling of it. + assert.is_nil(from_root and from_root:find("/$"), "no trailing slash") + end) + end + end) + + describe("a start folder given relative to the current folder", function() + it("finds the repository above the current folder, when git cannot run", function() + local repo = repo_in("relative", "my repo", function(path) + vim.fn.mkdir(path .. "/.git", "p") + end) + local saved_cwd = vim.fn.getcwd() + vim.cmd("cd " .. vim.fn.fnameescape(repo .. "/sub")) + + local from_dot = without_git(function() + return cwd.git_root(".") + end) + local from_child = without_git(function() + return cwd.git_root("deeper") + end) + vim.cmd("cd " .. vim.fn.fnameescape(saved_cwd)) + + assert.equals(realpath(repo), from_dot and realpath(from_dot), "from .") + assert.equals(realpath(repo), from_child and realpath(from_child), "from a folder inside the current one") + end) + end) + + describe("resolve with git_repo_cwd", function() + it("returns the repository of a file folder whose name has a space and a bracket, when git cannot run", function() + local repo = repo_in("resolve", "my [repo]", function(path) + vim.fn.mkdir(path .. "/.git", "p") + end) + + local resolved = without_git(function() + return cwd.resolve({ git_repo_cwd = true }, { file_dir = repo .. "/sub", cwd = repo }) + end) + + assert.equals(realpath(repo), resolved and realpath(resolved)) + end) + end) + + describe("a folder given as a setting", function() + it("still expands ~ and $VAR when the text is not a folder", function() + local repo = repo_in("setting", "proj", function(path) + vim.fn.mkdir(path .. "/.git", "p") + end) + local saved_home, saved_var = vim.env.HOME, vim.env.CLAUDECODE_TEST_ROOT + vim.env.HOME, vim.env.CLAUDECODE_TEST_ROOT = root .. "/setting", root .. "/setting" + + local tilde = cwd.resolve({ cwd = "~/proj" }, {}) + local variable = cwd.resolve({ cwd = "$CLAUDECODE_TEST_ROOT/proj" }, {}) + vim.env.HOME, vim.env.CLAUDECODE_TEST_ROOT = saved_home, saved_var + + assert.equals(realpath(repo), tilde and realpath(tilde)) + assert.equals(realpath(repo), variable and realpath(variable)) + end) + + it("takes a static cwd with brackets and a star as the folder it names", function() + local repo = repo_in("setting", "[a] {b} *", function(path) + vim.fn.mkdir(path .. "/.git", "p") + end) + + local resolved = cwd.resolve({ cwd = repo }, {}) + + assert.equals(repo, resolved) + end) + end) +end) From 3a575c3c596fd9d7850a8689005782532987db2a Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 12 Nov 2026 09:20:50 +0000 Subject: [PATCH 2/5] fix(cwd): go up from a start folder that ends in .. and scope the docs to what changed Fixes from the verification of this branch: - Before Neovim 0.11, fnamemodify(path, ":p") keeps a last `..`, and `:h` of `a/b/..` is `a/b`. So with git off PATH, git_root("..") from a folder with its own `.git` returned that folder, not the parent's repository. The fallback now calls simplify() on the full path before the walk. The mock gets simplify(), and the integration spec gets a case for a start folder that ends in `..`. It fails on Neovim 0.8.0 and 0.10.0 without the fix. - The README bullet said the cwd lookups keep a folder name with `$` or a backslash as written. The terminal does not use cwd.resolve: build_config passes a static cwd and a cwd_provider result through vim.fn.expand. The bullet now names git_root and resolve and says what the terminal does. - The unit test of the walk found a `.git` folder above TMPDIR when TMPDIR sat inside a checkout, and asserted nil. It now asserts that no repository inside the test folder answers. - The spec's cwd.resolve cases are named and commented as cwd.resolve cases, not terminal cases. --- .../skills/verify-diff-lifecycle/covered.lock | 2 +- lua/claudecode/README.md | 2 +- lua/claudecode/cwd.lua | 3 ++- .../integration/cwd_path_characters_spec.lua | 23 +++++++++++++++++-- tests/mocks/vim.lua | 4 ++++ tests/unit/cwd_spec.lua | 8 ++++++- 6 files changed, 36 insertions(+), 6 deletions(-) diff --git a/.agents/skills/verify-diff-lifecycle/covered.lock b/.agents/skills/verify-diff-lifecycle/covered.lock index 6177790d4..0eb4422e8 100644 --- a/.agents/skills/verify-diff-lifecycle/covered.lock +++ b/.agents/skills/verify-diff-lifecycle/covered.lock @@ -1,7 +1,7 @@ == lua/claudecode/config.lua file lua/claudecode/config.lua 787fa0432d1d77d7 == lua/claudecode/cwd.lua -file lua/claudecode/cwd.lua 1bc1e1cafb346847 +file lua/claudecode/cwd.lua eea1bfab1f40eb51 == lua/claudecode/diff.lua file lua/claudecode/diff.lua eea6b4bcdd655787 block resolve_saved 46d5f3f732711fe1 diff --git a/lua/claudecode/README.md b/lua/claudecode/README.md index 7c630c59c..c82b0eae4 100644 --- a/lua/claudecode/README.md +++ b/lua/claudecode/README.md @@ -127,7 +127,7 @@ Module map (direct files): - A per-call `opts_override` changes only a key that has a validator (`split_side`, `split_width_percentage`, `snacks_win_opts`, `cwd`, `git_repo_cwd`, `cwd_provider`) and a non-nil current value. So a per-call `cwd` or `cwd_provider` is ignored unless `setup` set one. Providers get only `split_side`, `split_width_percentage`, `auto_close`, `auto_insert`, `snacks_win_opts`, and the resolved `cwd` [terminal.lua:build_config] - The Claude command is `terminal_cmd`, or `claude` when it is unset. `no_proxy` and `NO_PROXY` always include `localhost`, `127.0.0.1`, and `::1` [terminal.lua:get_claude_command_and_env] - Terminal cwd precedence: `cwd_provider`, then static `cwd`, then the git root when `git_repo_cwd` is set [terminal.lua:build_config] -- The cwd lookups take a directory path as written: a folder name with `[`, `{`, `*`, `?`, `$`, `\`, a space or a comma stays that folder, and only a string that is not a directory is expanded (`~`, `$VAR`). When `git rev-parse` cannot run or finds no repository, `git_root` walks up from the full path of `start_dir` to the nearest folder that holds a `.git` folder [cwd.lua:M.git_root] [cwd.lua:normalize_dir] +- `cwd.git_root` and `cwd.resolve` take a directory path as written: a folder name with `[`, `{`, `*`, `?`, `$`, `\`, a space or a comma stays that folder, and only a string that is not a directory is expanded (`~`, `$VAR`). The terminal does not use `cwd.resolve`: `terminal.lua:build_config` passes a static `cwd` and a `cwd_provider` result through `vim.fn.expand` itself, so there a folder name with `$` or `\` still changes. When `git rev-parse` cannot run or finds no repository, `git_root` walks up from the full path of `start_dir`, with its `..` parts simplified, to the nearest folder that holds a `.git` folder [cwd.lua:M.git_root] [cwd.lua:normalize_dir] [terminal.lua:build_config] - `send_to_terminal` never starts a terminal: without a valid terminal buffer it returns `false`. With `focus = true` it calls `M.open()` after a successful send, which can show a hidden terminal. It refuses the `none` and `external` providers, because they have no buffer. It turns CRLF and CR into LF and wraps multi-line text in bracketed-paste markers. It returns `false` when the buffer has no job channel or `chansend` fails [terminal.lua:M.send_to_terminal] - `utils.parse_command` does no globbing or variable expansion, so model aliases such as `opus[1m]` stay intact [utils.lua:M.parse_command] diff --git a/lua/claudecode/cwd.lua b/lua/claudecode/cwd.lua index 9e542f936..6806117fd 100644 --- a/lua/claudecode/cwd.lua +++ b/lua/claudecode/cwd.lua @@ -62,7 +62,8 @@ function M.git_root(start_dir) -- Fallback: search for .git directory upward. The walk tests each folder as written. `vim.fn.finddir` reads its path -- as a list ('path' option syntax), so a space or a comma in the path split it, and a `;` ended it. It starts from the -- full path, so a relative `start_dir` also reaches the folders above the current one. - local dir = vim.fn.fnamemodify(start_dir, ":p:h") + -- Before Neovim 0.11, `:p` keeps a last `..`, and `:h` of `a/b/..` is `a/b`: simplify first. + local dir = vim.fn.fnamemodify(vim.fn.simplify(vim.fn.fnamemodify(start_dir, ":p")), ":h") while true do if vim.fn.isdirectory(dir .. "/.git") == 1 then return dir diff --git a/tests/integration/cwd_path_characters_spec.lua b/tests/integration/cwd_path_characters_spec.lua index e602bb0a3..b7b1e92c1 100644 --- a/tests/integration/cwd_path_characters_spec.lua +++ b/tests/integration/cwd_path_characters_spec.lua @@ -176,9 +176,28 @@ describe("the git root of a folder whose name holds unusual characters", functio assert.equals(realpath(repo), from_dot and realpath(from_dot), "from .") assert.equals(realpath(repo), from_child and realpath(from_child), "from a folder inside the current one") end) + it("goes up from a start folder that ends in .., when git cannot run", function() + local outer = repo_in("dotdot", "outer", function(path) + vim.fn.mkdir(path .. "/.git", "p") + end) + vim.fn.mkdir(outer .. "/a/b/.git", "p") + local saved_cwd = vim.fn.getcwd() + vim.cmd("cd " .. vim.fn.fnameescape(outer .. "/a/b")) + + local from_parent = without_git(function() + return cwd.git_root("..") + end) + vim.cmd("cd " .. vim.fn.fnameescape(saved_cwd)) + + -- ".." is outer/a, which has no .git folder: its repository is outer, not outer/a/b below it. + assert.equals(realpath(outer), from_parent and realpath(from_parent)) + end) end) - describe("resolve with git_repo_cwd", function() + -- The next two blocks test `cwd.resolve`, not the terminal. `terminal.lua:build_config` does not call it: it expands + -- a static `cwd` and a `cwd_provider` result with `vim.fn.expand` itself, so these cases say nothing about what the + -- terminal does with a folder name that holds `$` or a backslash. + describe("cwd.resolve with git_repo_cwd", function() it("returns the repository of a file folder whose name has a space and a bracket, when git cannot run", function() local repo = repo_in("resolve", "my [repo]", function(path) vim.fn.mkdir(path .. "/.git", "p") @@ -192,7 +211,7 @@ describe("the git root of a folder whose name holds unusual characters", functio end) end) - describe("a folder given as a setting", function() + describe("cwd.resolve with a folder given as a setting", function() it("still expands ~ and $VAR when the text is not a folder", function() local repo = repo_in("setting", "proj", function(path) vim.fn.mkdir(path .. "/.git", "p") diff --git a/tests/mocks/vim.lua b/tests/mocks/vim.lua index e84dcf81e..8ae728df9 100644 --- a/tests/mocks/vim.lua +++ b/tests/mocks/vim.lua @@ -698,6 +698,10 @@ local vim = { return "/home/user/project" end, + simplify = function(path) + return path + end, + fnamemodify = function(path, modifier) if modifier == ":t" then return path:match("([^/]+)$") or path diff --git a/tests/unit/cwd_spec.lua b/tests/unit/cwd_spec.lua index b576f0255..a288dc19c 100644 --- a/tests/unit/cwd_spec.lua +++ b/tests/unit/cwd_spec.lua @@ -45,7 +45,13 @@ describe("The git root lookup", function() stub_git({ lines = { "fatal: not a git repository" }, exit_code = 128 }) assert(os.execute("mkdir -p " .. temp_dir.quote(dir .. "/sub/deeper"))) - assert.is_nil(cwd.git_root(dir .. "/sub/deeper")) + -- A TMPDIR that sits inside a repository (a checkout, say) has a `.git` folder above `dir`, and the walk finds it. + -- So the lookup may find that repository, but never one inside `dir`. + local found = cwd.git_root(dir .. "/sub/deeper") + assert.is_true( + found == nil or found:sub(1, #dir) ~= dir, + "no repository inside " .. dir .. ", found " .. tostring(found) + ) assert.equals(1, git_runs, vim.inspect(git_calls)) assert(os.execute("mkdir -p " .. temp_dir.quote(dir .. "/.git"))) From 620d26f5321942032ced4fcc5b6018ab9cd89278 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 12 Nov 2026 11:46:45 +0000 Subject: [PATCH 3/5] docs(cwd): say where the walk starts for a relative start that begins with ~ --- lua/claudecode/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/claudecode/README.md b/lua/claudecode/README.md index 9ef0ef0d9..6015a8e7b 100644 --- a/lua/claudecode/README.md +++ b/lua/claudecode/README.md @@ -127,7 +127,7 @@ Module map (direct files): - A per-call `opts_override` changes only a key that has a validator (`split_side`, `split_width_percentage`, `snacks_win_opts`, `cwd`, `git_repo_cwd`, `cwd_provider`) and a non-nil current value. So a per-call `cwd` or `cwd_provider` is ignored unless `setup` set one. Providers get only `split_side`, `split_width_percentage`, `auto_close`, `auto_insert`, `snacks_win_opts`, and the resolved `cwd` [terminal.lua:build_config] - The Claude command is `terminal_cmd`, or `claude` when it is unset. `no_proxy` and `NO_PROXY` always include `localhost`, `127.0.0.1`, and `::1` [terminal.lua:get_claude_command_and_env] - Terminal cwd precedence: `cwd_provider`, then static `cwd`, then the git root when `git_repo_cwd` is set [terminal.lua:build_config] -- `cwd.git_root` and `cwd.resolve` take a directory path as written: a folder name with `[`, `{`, `*`, `?`, `$`, `\`, a space or a comma stays that folder, and only a string that is not a directory is expanded (`~`, `$VAR`). The terminal does not use `cwd.resolve`: `terminal.lua:build_config` passes a static `cwd` and a `cwd_provider` result through `vim.fn.expand` itself, so there a folder name with `$` or `\` still changes. When `git rev-parse` cannot run or finds no repository, `git_root` walks up from the full path of `start_dir`, with its `..` parts simplified, to the nearest folder that holds a `.git` folder [cwd.lua:M.git_root] [cwd.lua:normalize_dir] [terminal.lua:build_config] +- `cwd.git_root` and `cwd.resolve` take a directory path as written: a folder name with `[`, `{`, `*`, `?`, `$`, `\`, a space or a comma stays that folder, and only a string that is not a directory is expanded (`~`, `$VAR`). The terminal does not use `cwd.resolve`: `terminal.lua:build_config` passes a static `cwd` and a `cwd_provider` result through `vim.fn.expand` itself, so there a folder name with `$` or `\` still changes. When `git rev-parse` cannot run or finds no repository, `git_root` walks up from `start_dir` made absolute with `:p`, with its `..` parts simplified, to the nearest folder that holds a `.git` folder. A relative `start_dir` that begins with `~` is not made absolute, because Vim reads `~` as the home folder and `~name` as a user's home folder, so the walk starts from there [cwd.lua:M.git_root] [cwd.lua:normalize_dir] [terminal.lua:build_config] - `send_to_terminal` never starts a terminal: without a valid terminal buffer it returns `false`. With `focus = true` it calls `M.open()` after a successful send, which can show a hidden terminal. It refuses the `none` and `external` providers, because they have no buffer. It turns CRLF and CR into LF and wraps multi-line text in bracketed-paste markers. It returns `false` when the buffer has no job channel or `chansend` fails [terminal.lua:M.send_to_terminal] - `utils.parse_command` does no globbing or variable expansion, so model aliases such as `opus[1m]` stay intact [utils.lua:M.parse_command] From ce6a97e1fbd8ede404c20b0aace1074938625372 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 12 Nov 2026 12:47:24 +0000 Subject: [PATCH 4/5] fix(cwd): expand text that starts with ~ or $ before looking for a folder normalize_dir took a path that names an existing folder as written, and only expanded other text. isdirectory does not expand, so with `cwd = "~"` or `"$HOME"` and a stray folder of that literal name in Neovim's current folder (for example one that an unexpanded `mkdir "~/x"` made), git_root and resolve returned the stray folder where main returned the home folder. Text that starts with `~` or `$` now always goes through expand first, as in a shell and in `:cd`. Two spec cases are red without the guard: a stray `~` folder and a stray `$HOME` folder. The README bullet states the rule. --- lua/claudecode/README.md | 2 +- lua/claudecode/cwd.lua | 6 ++-- .../integration/cwd_path_characters_spec.lua | 34 +++++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/lua/claudecode/README.md b/lua/claudecode/README.md index 6015a8e7b..2696eea90 100644 --- a/lua/claudecode/README.md +++ b/lua/claudecode/README.md @@ -127,7 +127,7 @@ Module map (direct files): - A per-call `opts_override` changes only a key that has a validator (`split_side`, `split_width_percentage`, `snacks_win_opts`, `cwd`, `git_repo_cwd`, `cwd_provider`) and a non-nil current value. So a per-call `cwd` or `cwd_provider` is ignored unless `setup` set one. Providers get only `split_side`, `split_width_percentage`, `auto_close`, `auto_insert`, `snacks_win_opts`, and the resolved `cwd` [terminal.lua:build_config] - The Claude command is `terminal_cmd`, or `claude` when it is unset. `no_proxy` and `NO_PROXY` always include `localhost`, `127.0.0.1`, and `::1` [terminal.lua:get_claude_command_and_env] - Terminal cwd precedence: `cwd_provider`, then static `cwd`, then the git root when `git_repo_cwd` is set [terminal.lua:build_config] -- `cwd.git_root` and `cwd.resolve` take a directory path as written: a folder name with `[`, `{`, `*`, `?`, `$`, `\`, a space or a comma stays that folder, and only a string that is not a directory is expanded (`~`, `$VAR`). The terminal does not use `cwd.resolve`: `terminal.lua:build_config` passes a static `cwd` and a `cwd_provider` result through `vim.fn.expand` itself, so there a folder name with `$` or `\` still changes. When `git rev-parse` cannot run or finds no repository, `git_root` walks up from `start_dir` made absolute with `:p`, with its `..` parts simplified, to the nearest folder that holds a `.git` folder. A relative `start_dir` that begins with `~` is not made absolute, because Vim reads `~` as the home folder and `~name` as a user's home folder, so the walk starts from there [cwd.lua:M.git_root] [cwd.lua:normalize_dir] [terminal.lua:build_config] +- `cwd.git_root` and `cwd.resolve` take a directory path as written: a folder name with `[`, `{`, `*`, `?`, `$`, `\`, a space or a comma stays that folder. A string that is not a directory is expanded (`~`, `$VAR`), and a string that starts with `~` or `$` is always expanded first, even when Neovim's current folder holds a folder with that literal name. The terminal does not use `cwd.resolve`: `terminal.lua:build_config` passes a static `cwd` and a `cwd_provider` result through `vim.fn.expand` itself, so there a folder name with `$` or `\` still changes. When `git rev-parse` cannot run or finds no repository, `git_root` walks up from `start_dir` made absolute with `:p`, with its `..` parts simplified, to the nearest folder that holds a `.git` folder. A relative `start_dir` that begins with `~` is not made absolute, because Vim reads `~` as the home folder and `~name` as a user's home folder, so the walk starts from there [cwd.lua:M.git_root] [cwd.lua:normalize_dir] [terminal.lua:build_config] - `send_to_terminal` never starts a terminal: without a valid terminal buffer it returns `false`. With `focus = true` it calls `M.open()` after a successful send, which can show a hidden terminal. It refuses the `none` and `external` providers, because they have no buffer. It turns CRLF and CR into LF and wraps multi-line text in bracketed-paste markers. It returns `false` when the buffer has no job channel or `chansend` fails [terminal.lua:M.send_to_terminal] - `utils.parse_command` does no globbing or variable expansion, so model aliases such as `opus[1m]` stay intact [utils.lua:M.parse_command] diff --git a/lua/claudecode/cwd.lua b/lua/claudecode/cwd.lua index 6806117fd..7451631ef 100644 --- a/lua/claudecode/cwd.lua +++ b/lua/claudecode/cwd.lua @@ -18,8 +18,10 @@ local function normalize_dir(dir) return nil end -- A directory that exists is taken as written. `vim.fn.expand` reads `[`, `{`, `*`, `?`, `$` and `\` in a name as - -- patterns or variables, so it would change the path of a folder that has them, or find none. - if is_directory(dir) then + -- patterns or variables, so it would change the path of a folder that has them, or find none. Text that starts with + -- `~` or `$` asks for expansion, as in a shell and in `:cd`, so it goes through `expand` first: `is_directory` does not + -- expand, and a stray folder named `~` in Neovim's current folder must not take the place of the home folder. + if not dir:match("^[~$]") and is_directory(dir) then return dir end -- Expand ~ and similar diff --git a/tests/integration/cwd_path_characters_spec.lua b/tests/integration/cwd_path_characters_spec.lua index b7b1e92c1..50885c5a0 100644 --- a/tests/integration/cwd_path_characters_spec.lua +++ b/tests/integration/cwd_path_characters_spec.lua @@ -227,6 +227,40 @@ describe("the git root of a folder whose name holds unusual characters", functio assert.equals(realpath(repo), variable and realpath(variable)) end) + --- Runs `fn` with Neovim's current folder set to `dir` and HOME set to `home`, then puts both back. + local function with_cwd_and_home(dir, home, fn) + local saved_cwd, saved_home = vim.fn.getcwd(), vim.env.HOME + vim.cmd("cd " .. vim.fn.fnameescape(dir)) + vim.env.HOME = home + local ok, result = pcall(fn) + vim.env.HOME = saved_home + vim.cmd("cd " .. vim.fn.fnameescape(saved_cwd)) + assert.is_true(ok, result) + return result + end + + it("expands a leading ~ even when Neovim's cwd holds a folder named ~", function() + vim.fn.mkdir(root .. "/stray/home/sub", "p") + vim.fn.mkdir(root .. "/stray/work/~/sub", "p") + + local resolved = with_cwd_and_home(root .. "/stray/work", root .. "/stray/home", function() + return cwd.resolve({ cwd = "~/sub" }, {}) + end) + + assert.equals(realpath(root .. "/stray/home/sub"), resolved and realpath(resolved)) + end) + + it("expands a leading $ even when Neovim's cwd holds a folder named $HOME", function() + vim.fn.mkdir(root .. "/stray/home", "p") + vim.fn.mkdir(root .. "/stray/work/$HOME", "p") + + local resolved = with_cwd_and_home(root .. "/stray/work", root .. "/stray/home", function() + return cwd.resolve({ cwd = "$HOME" }, {}) + end) + + assert.equals(realpath(root .. "/stray/home"), resolved and realpath(resolved)) + end) + it("takes a static cwd with brackets and a star as the folder it names", function() local repo = repo_in("setting", "[a] {b} *", function(path) vim.fn.mkdir(path .. "/.git", "p") From f12368bd7deee7f4734a56177f6e6f7e0d37a4f9 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 12 Nov 2026 12:49:00 +0000 Subject: [PATCH 5/5] chore(skills): record covered.lock for the leading ~ and $ rule in cwd.lua --- .agents/skills/verify-diff-lifecycle/covered.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/verify-diff-lifecycle/covered.lock b/.agents/skills/verify-diff-lifecycle/covered.lock index e6925830c..1824dfe47 100644 --- a/.agents/skills/verify-diff-lifecycle/covered.lock +++ b/.agents/skills/verify-diff-lifecycle/covered.lock @@ -1,7 +1,7 @@ == lua/claudecode/config.lua file lua/claudecode/config.lua 3afb1bdeff27bfb6 == lua/claudecode/cwd.lua -file lua/claudecode/cwd.lua eea1bfab1f40eb51 +file lua/claudecode/cwd.lua b034e82c76e8dee4 == lua/claudecode/diff.lua file lua/claudecode/diff.lua ac01bc69a313e411 block resolve_saved 4d34b0c7e34d561f