local t = require("test") local helpers = require("test.git.helpers") local cmd = require("git.cmd") require("git").init() local git = helpers.git ---@param files table? ---@return string dir local function make_repo(files) return helpers.make_repo(files, { cd = true }) end ---@param actual string[] ---@param expected string[] local function eq_sorted(actual, expected, msg) table.sort(actual) table.sort(expected) t.eq(actual, expected, msg) end t.test("parse_args splits on whitespace", function() t.eq( cmd.parse_args("config user.name value"), { "config", "user.name", "value" } ) end) t.test("parse_args preserves spaces inside double quotes", function() t.eq( cmd.parse_args([[config user.name "Oscar Wallberg"]]), { "config", "user.name", "Oscar Wallberg" } ) end) t.test("parse_args preserves spaces inside single quotes", function() t.eq( cmd.parse_args([[log --grep='bug fix' --author=Oscar]]), { "log", "--grep=bug fix", "--author=Oscar" } ) end) t.test("parse_args handles backslash-escaped space", function() t.eq(cmd.parse_args([[a\ b c]]), { "a b", "c" }) end) t.test("parse_args handles escaped quote inside double quotes", function() t.eq(cmd.parse_args([["a\"b" c]]), { 'a"b', "c" }) end) t.test( "parse_args treats backslash literally inside single quotes", function() t.eq(cmd.parse_args([['a\b' c]]), { "a\\b", "c" }) end ) t.test("parse_args concatenates adjacent quoted segments", function() t.eq(cmd.parse_args([[foo"bar"baz]]), { "foobarbaz" }) end) t.test("parse_args handles tabs as separators", function() t.eq(cmd.parse_args("a\tb\tc"), { "a", "b", "c" }) end) t.test("parse_args returns empty list for empty or whitespace input", function() t.eq(cmd.parse_args(""), {}) t.eq(cmd.parse_args(" \t "), {}) end) t.test("parse_args preserves empty quoted token", function() t.eq(cmd.parse_args([[a "" b]]), { "a", "", "b" }) end) t.test("parse_args expands %% on unquoted token", function() local buf = vim.api.nvim_create_buf(false, false) t.defer(function() pcall(vim.api.nvim_buf_delete, buf, { force = true }) end) vim.api.nvim_buf_set_name(buf, vim.fn.getcwd() .. "/some-file.lua") vim.api.nvim_set_current_buf(buf) t.eq( cmd.parse_args("add %"), { "add", vim.fn.getcwd() .. "/some-file.lua" } ) end) t.test("parse_args does not expand %% inside double quotes", function() local buf = vim.api.nvim_create_buf(false, false) t.defer(function() pcall(vim.api.nvim_buf_delete, buf, { force = true }) end) vim.api.nvim_buf_set_name(buf, vim.fn.getcwd() .. "/some-file.lua") vim.api.nvim_set_current_buf(buf) t.eq(cmd.parse_args([[log -- "%"]]), { "log", "--", "%" }) end) t.test("parse_args does not expand %% inside single quotes", function() local buf = vim.api.nvim_create_buf(false, false) t.defer(function() pcall(vim.api.nvim_buf_delete, buf, { force = true }) end) vim.api.nvim_buf_set_name(buf, vim.fn.getcwd() .. "/some-file.lua") vim.api.nvim_set_current_buf(buf) t.eq(cmd.parse_args([[log -- '%']]), { "log", "--", "%" }) end) t.test("parse_args does not treat mid-token tilde as expansion", function() t.eq(cmd.parse_args("checkout HEAD~3"), { "checkout", "HEAD~3" }) end) t.test("parse_args expands leading ~/ to home", function() t.eq(cmd.parse_args("add ~/foo"), { "add", vim.fn.expand("~/foo") }) end) t.test("parse_complete_state with trailing space", function() local s = cmd._parse_complete_state("G push origin ") t.eq(s.prior, { "push", "origin" }) t.falsy(s.after_separator) end) t.test("parse_complete_state mid-token", function() local s = cmd._parse_complete_state("G push or") t.eq(s.prior, { "push" }) t.falsy(s.after_separator) end) t.test("parse_complete_state empty after command", function() local s = cmd._parse_complete_state("G ") t.eq(s.prior, {}) t.falsy(s.after_separator) end) t.test("parse_complete_state detects -- separator", function() local s = cmd._parse_complete_state("G log -- foo") t.eq(s.prior, { "log", "--" }) t.truthy(s.after_separator) end) t.test("positional_index ignores flags", function() t.eq(cmd._positional_index({ "push" }), 1) t.eq(cmd._positional_index({ "push", "origin" }), 2) t.eq(cmd._positional_index({ "push", "--force" }), 1) t.eq(cmd._positional_index({ "push", "--force", "origin" }), 2) t.eq(cmd._positional_index({ "checkout", "-b", "feature" }), 2) end) t.test("complete returns subcommands at first position", function() local matches = cmd.complete("ch", "G ch", 4) t.truthy(vim.tbl_contains(matches, "checkout")) t.truthy(vim.tbl_contains(matches, "cherry-pick")) end) t.test("complete returns flags when arg starts with -", function() local matches = cmd.complete("--am", "G commit --am", 13) t.eq(matches, { "--amend" }) end) t.test("complete branch returns plain refs (no pseudo, no stash)", function() local dir = make_repo({ a = "x" }) git(dir, "branch", "feature") git(dir, "tag", "v1") t.write(dir, "a", "modified") git(dir, "stash") local matches = cmd.complete("", "G branch ", 9) eq_sorted(matches, { "feature", "main", "v1" }) end) t.test("complete merge returns refs + pseudo + stash", function() local dir = make_repo({ a = "x" }) git(dir, "branch", "feature") t.write(dir, "a", "y") git(dir, "stash") local matches = cmd.complete("", "G merge ", 8) eq_sorted( matches, { "HEAD", "ORIG_HEAD", "feature", "main", "stash", "stash@{0}" } ) end) t.test("complete push first positional returns remotes", function() local dir = make_repo({ a = "x" }) git(dir, "remote", "add", "origin", "/tmp/nope") git(dir, "remote", "add", "upstream", "/tmp/nope") local matches = cmd.complete("", "G push ", 7) eq_sorted(matches, { "origin", "upstream" }) end) t.test("complete push second positional returns refs", function() local dir = make_repo({ a = "x" }) git(dir, "branch", "feature") local matches = cmd.complete("", "G push origin ", 14) eq_sorted(matches, { "HEAD", "feature", "main" }) end) t.test("complete add returns only unstaged/untracked paths", function() local dir = make_repo({ tracked = "x" }) t.write(dir, "tracked", "modified") t.write(dir, "newfile", "new") local r = assert(require("git.repo").resolve(dir)) r:refresh() vim.wait(500, function() return r.status and #vim.tbl_keys(r.status.entries) > 0 end) local matches = cmd.complete("", "G add ", 6) eq_sorted(matches, { "newfile", "tracked" }) end) t.test("complete after `--` returns tracked paths only", function() local dir = make_repo({ a = "x", b = "y" }) t.write(dir, "untracked", "z") local matches = cmd.complete("", "G log -- ", 9) eq_sorted(matches, { "a", "b" }) end) t.test("complete stash returns subsubcommands", function() local dir = make_repo({ a = "x" }) local matches = cmd.complete("p", "G stash p", 9) eq_sorted(matches, { "pop", "push" }) end) t.test("complete show with : returns tree paths", function() local dir = make_repo({ a = "x", ["sub/b"] = "y" }) local matches = cmd.complete("HEAD:", "G show HEAD:", 12) eq_sorted(matches, { "HEAD:a", "HEAD:sub/" }) end) t.test("complete unknown subcommand falls back to tracked paths", function() local dir = make_repo({ a = "x", b = "y" }) local matches = cmd.complete("", "G nonexistent ", 14) eq_sorted(matches, { "a", "b" }) end) t.test("compute_diff_refs default is index vs worktree", function() local dir = make_repo({ a = "x" }) local r = assert(require("git.repo").resolve(dir)) local left, right = cmd._compute_diff_refs(r, { "diff" }) t.eq(left, ":") t.eq(right, nil) end) t.test("compute_diff_refs --cached is HEAD vs index", function() local dir = make_repo({ a = "x" }) local r = assert(require("git.repo").resolve(dir)) local left, right = cmd._compute_diff_refs(r, { "diff", "--cached" }) t.eq(left, "HEAD") t.eq(right, ":") end) t.test("compute_diff_refs --staged is HEAD vs index", function() local dir = make_repo({ a = "x" }) local r = assert(require("git.repo").resolve(dir)) local left, right = cmd._compute_diff_refs(r, { "diff", "--staged" }) t.eq(left, "HEAD") t.eq(right, ":") end) t.test("compute_diff_refs single rev is rev vs worktree", function() local dir = make_repo({ a = "x" }) local r = assert(require("git.repo").resolve(dir)) local left, right = cmd._compute_diff_refs(r, { "diff", "HEAD" }) t.eq(left, "HEAD") t.eq(right, nil) end) t.test("compute_diff_refs single rev with --cached is rev vs index", function() local dir = make_repo({ a = "x" }) local r = assert(require("git.repo").resolve(dir)) local left, right = cmd._compute_diff_refs(r, { "diff", "--cached", "HEAD" }) t.eq(left, "HEAD") t.eq(right, ":") end) t.test("compute_diff_refs two revs", function() local dir = make_repo({ a = "x" }) git(dir, "commit", "--allow-empty", "-m", "second") local r = assert(require("git.repo").resolve(dir)) local left, right = cmd._compute_diff_refs(r, { "diff", "HEAD~1", "HEAD" }) t.eq(left, "HEAD~1") t.eq(right, "HEAD") end) t.test("compute_diff_refs double-dot range", function() local dir = make_repo({ a = "x" }) git(dir, "commit", "--allow-empty", "-m", "second") local r = assert(require("git.repo").resolve(dir)) local left, right = cmd._compute_diff_refs(r, { "diff", "HEAD~1..HEAD" }) t.eq(left, "HEAD~1") t.eq(right, "HEAD") end) t.test("compute_diff_refs triple-dot range", function() local dir = make_repo({ a = "x" }) git(dir, "commit", "--allow-empty", "-m", "second") local r = assert(require("git.repo").resolve(dir)) local left, right = cmd._compute_diff_refs(r, { "diff", "HEAD~1...HEAD" }) t.eq(left, "HEAD~1") t.eq(right, "HEAD") end) t.test("compute_diff_refs path-only falls back to defaults", function() local dir = make_repo({ a = "x" }) local r = assert(require("git.repo").resolve(dir)) local left, right = cmd._compute_diff_refs(r, { "diff", "a" }) t.eq(left, ":") t.eq(right, nil) end) t.test("compute_diff_refs ignores args after --", function() local dir = make_repo({ a = "x" }) local r = assert(require("git.repo").resolve(dir)) local left, right = cmd._compute_diff_refs(r, { "diff", "--", "HEAD" }) t.eq(left, ":") t.eq(right, nil) end)