Skip to content

Commit 68d174b

Browse files
committed
feat(snapshot): make snapshot path more reliable on windows
This should fix windows as it does not rely on the shell to do an sha1 This should fix #404
1 parent f598a35 commit 68d174b

8 files changed

Lines changed: 308 additions & 3 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ require('opencode').setup({
123123
default_system_prompt = nil, -- Custom system prompt to use for all sessions. If nil, uses the default built-in system prompt
124124
keymap_prefix = '<leader>o', -- Default keymap prefix for global keymaps change to your preferred prefix and it will be applied to all keymaps starting with <leader>o
125125
opencode_executable = 'opencode', -- Name of your opencode binary
126+
snapshot_path = nil, -- Override base path for the snapshot git directory (default: $XDG_DATA_HOME/opencode). Appends /snapshot/<project_id>/<worktree_hash>
126127

127128
-- Server configuration for custom/external opencode servers
128129
server = {

lua/opencode/config.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,7 @@ M.defaults = {
301301
},
302302
prompt_guard = nil,
303303
child_readonly = true,
304+
snapshot_path = nil,
304305
hooks = {
305306
on_file_edited = nil,
306307
on_session_loaded = nil,

lua/opencode/config_file.lua

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,19 +51,29 @@ local function sha1(str)
5151
end
5252

5353
---Get the snapshot storage path for the current workspace
54+
---Matches opencode's Global.Path.data + "snapshot" + projectId + Hash.fast(worktree)
55+
---Can be overridden via config.snapshot_path (base path, project_id and worktree_hash are appended)
5456
---@type fun(): Promise<string>
5557
M.get_workspace_snapshot_path = Promise.async(function()
5658
local project = M.get_opencode_project():await() --[[@as OpencodeProject|nil]]
5759
if not project then
5860
return ''
5961
end
60-
local home = vim.uv.os_homedir()
62+
local data_home = require('opencode.config').snapshot_path
63+
if not data_home or data_home == '' then
64+
data_home = vim.uv.os_getenv('XDG_DATA_HOME')
65+
if not data_home or data_home == '' then
66+
data_home = vim.uv.os_homedir() .. '/.local/share'
67+
end
68+
data_home = vim.fs.joinpath(data_home, 'opencode')
69+
end
6170
local cwd = vim.fn.getcwd()
6271
local worktree_hash = sha1(cwd)
6372
if not worktree_hash then
6473
return ''
6574
end
66-
return home .. '/.local/share/opencode/snapshot/' .. project.id .. '/' .. worktree_hash
75+
local path = vim.fs.joinpath(data_home, 'snapshot', project.id, worktree_hash)
76+
return vim.fs.normalize(path)
6777
end)
6878

6979
local _providers_render_callback = false

lua/opencode/sha1.lua

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
local band = bit.band
2+
local bor = bit.bor
3+
local bxor = bit.bxor
4+
local bnot = bit.bnot
5+
local rol = bit.rol
6+
7+
local function u32hex(n)
8+
if n < 0 then
9+
n = n + 4294967296
10+
end
11+
return string.format('%08x', n)
12+
end
13+
14+
---@param str string
15+
---@return string|nil
16+
local function sha1(str)
17+
local bytes = { string.byte(str, 1, #str) }
18+
local bit_len = #bytes * 8
19+
20+
bytes[#bytes + 1] = 0x80
21+
while (#bytes % 64) ~= 56 do
22+
bytes[#bytes + 1] = 0
23+
end
24+
25+
local bit_len_hi = math.floor(bit_len / 4294967296)
26+
local bit_len_lo = bit_len % 4294967296
27+
28+
bytes[#bytes + 1] = math.floor(bit_len_hi / 16777216) % 256
29+
bytes[#bytes + 1] = math.floor(bit_len_hi / 65536) % 256
30+
bytes[#bytes + 1] = math.floor(bit_len_hi / 256) % 256
31+
bytes[#bytes + 1] = band(bit_len_hi, 0x000000ff)
32+
bytes[#bytes + 1] = math.floor(bit_len_lo / 16777216) % 256
33+
bytes[#bytes + 1] = math.floor(bit_len_lo / 65536) % 256
34+
bytes[#bytes + 1] = math.floor(bit_len_lo / 256) % 256
35+
bytes[#bytes + 1] = band(bit_len_lo, 0x000000ff)
36+
37+
local h0 = 0x67452301
38+
local h1 = 0xefcdab89
39+
local h2 = 0x98badcfe
40+
local h3 = 0x10325476
41+
local h4 = 0xc3d2e1f0
42+
43+
for i = 1, #bytes, 64 do
44+
local w = {}
45+
for j = 1, 16 do
46+
local k = i + (j - 1) * 4
47+
w[j] = band(bytes[k] * 16777216 + bytes[k + 1] * 65536 + bytes[k + 2] * 256 + bytes[k + 3], 0xffffffff)
48+
end
49+
for j = 17, 80 do
50+
w[j] = rol(bxor(bxor(w[j - 3], w[j - 8]), bxor(w[j - 14], w[j - 16])), 1)
51+
end
52+
53+
local a = h0
54+
local b = h1
55+
local c = h2
56+
local d = h3
57+
local e = h4
58+
59+
for j = 1, 80 do
60+
local f, k
61+
if j <= 20 then
62+
f = bor(band(b, c), band(bnot(b), d))
63+
k = 0x5a827999
64+
elseif j <= 40 then
65+
f = bxor(bxor(b, c), d)
66+
k = 0x6ed9eba1
67+
elseif j <= 60 then
68+
f = bor(bor(band(b, c), band(b, d)), band(c, d))
69+
k = 0x8f1bbcdc
70+
else
71+
f = bxor(bxor(b, c), d)
72+
k = 0xca62c1d6
73+
end
74+
75+
local temp = band(rol(a, 5) + f + e + k + w[j], 0xffffffff)
76+
e = d
77+
d = c
78+
c = rol(b, 30)
79+
b = a
80+
a = temp
81+
end
82+
83+
h0 = band(h0 + a, 0xffffffff)
84+
h1 = band(h1 + b, 0xffffffff)
85+
h2 = band(h2 + c, 0xffffffff)
86+
h3 = band(h3 + d, 0xffffffff)
87+
h4 = band(h4 + e, 0xffffffff)
88+
end
89+
90+
return u32hex(h0) .. u32hex(h1) .. u32hex(h2) .. u32hex(h3) .. u32hex(h4)
91+
end
92+
93+
return sha1

lua/opencode/snapshot.lua

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ local function snapshot_git(cmd_args, opts)
1919
local cwd = vim.fn.getcwd()
2020
local args = { 'git', '--git-dir', snapshot_dir, '--work-tree', cwd }
2121
vim.list_extend(args, cmd_args)
22+
2223
local result = vim.system(args, opts or { cwd = cwd }):wait()
2324
if result and result.code == 0 then
2425
return vim.trim(result.stdout), nil
@@ -172,6 +173,7 @@ end
172173

173174
function M.diff_file(snapshot_id, file_path)
174175
local relative_path = vim.fn.fnamemodify(file_path, ':.')
176+
relative_path = relative_path:gsub('\\', '/')
175177
local file_at_snapshot = snapshot_git({ 'show', snapshot_id .. ':' .. relative_path })
176178
local temp_file = write_to_temp_file(file_at_snapshot or '')
177179
local file_type = vim.fn.fnamemodify(file_path, ':e')
@@ -187,7 +189,8 @@ function M.revert(snapshot_id)
187189
end
188190
local deleted_files = {}
189191
for _, file in ipairs(patch_result.files) do
190-
local relative_path = file:match('^' .. vim.fn.getcwd() .. '/?(.*)$')
192+
local relative_path = file:match('^' .. vim.pesc(vim.fn.getcwd()) .. '/?(.*)$')
193+
relative_path = relative_path:gsub('\\', '/')
191194
local res, err = snapshot_git({ 'checkout', snapshot_id, '--', relative_path })
192195
if not res then
193196
vim.notify(

lua/opencode/types.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,7 @@
389389
---@field child_readonly boolean
390390
---@field hooks OpencodeHooks
391391
---@field quick_chat OpencodeQuickChatConfig
392+
---@field snapshot_path? string -- Override base path for snapshot storage (default: $XDG_DATA_HOME/opencode). Appends /snapshot/<project_id>/<worktree_hash>
392393

393394
---@class MessagePartState
394395
---@field input TaskToolInput|BashToolInput|FileToolInput|TodoToolInput|GlobToolInput|GrepToolInput|WebFetchToolInput|ListToolInput|QuestionToolInput|ApplyPatchToolInput Input data for the tool
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
local CursorSpinner = require('opencode.quick_chat.spinner')
2+
3+
describe('quick_chat CursorSpinner', function()
4+
local buf
5+
local spinners
6+
7+
before_each(function()
8+
spinners = {}
9+
buf = vim.api.nvim_create_buf(false, true)
10+
vim.api.nvim_set_current_buf(buf)
11+
end)
12+
13+
after_each(function()
14+
for _, spinner in ipairs(spinners) do
15+
if spinner then
16+
spinner:stop()
17+
end
18+
end
19+
if buf and vim.api.nvim_buf_is_valid(buf) then
20+
vim.api.nvim_buf_delete(buf, { force = true })
21+
end
22+
end)
23+
24+
describe('build_layout', function()
25+
local SPINNER_BYTES = #(' ' .. '' .. ' ')
26+
27+
it('renders spinner and cancel key when no model is provided', function()
28+
local spinner = CursorSpinner.new(buf, 0, 0)
29+
table.insert(spinners, spinner)
30+
31+
local content, highlights, width = spinner:build_layout()
32+
33+
assert.is_truthy(content:find('to cancel', 1, true))
34+
assert.is_truthy(content:find('<C-c>', 1, true))
35+
assert.equals(1, #highlights)
36+
assert.equals('WarningMsg', highlights[1].hl_group)
37+
assert.equals(SPINNER_BYTES + 1, highlights[1].start_col)
38+
assert.equals(SPINNER_BYTES + 1 + #('<C-c>'), highlights[1].end_col)
39+
assert.equals(#content + 1, width)
40+
end)
41+
42+
it('includes the provider/model name when given', function()
43+
local spinner = CursorSpinner.new(buf, 0, 0, { model = 'openai/gpt-4' })
44+
table.insert(spinners, spinner)
45+
46+
local content, highlights, width = spinner:build_layout()
47+
48+
assert.is_truthy(content:find('openai/gpt-4', 1, true))
49+
50+
local model_hl
51+
for _, hl in ipairs(highlights) do
52+
if hl.hl_group == 'OpencodeHint' then
53+
model_hl = hl
54+
break
55+
end
56+
end
57+
assert.is_not_nil(model_hl)
58+
assert.equals(SPINNER_BYTES + 1, model_hl.start_col)
59+
assert.equals(model_hl.start_col + #('openai/gpt-4'), model_hl.end_col)
60+
assert.equals(#content + 1, width)
61+
end)
62+
63+
it('renders the variant with OpencodeVariant highlight after the model', function()
64+
local spinner = CursorSpinner.new(buf, 0, 0, { model = 'openai/gpt-4', variant = 'max' })
65+
table.insert(spinners, spinner)
66+
67+
local content, highlights, _ = spinner:build_layout()
68+
69+
assert.is_truthy(content:find('openai/gpt-4', 1, true))
70+
assert.is_truthy(content:find('·', 1, true))
71+
assert.is_truthy(content:find('max', 1, true))
72+
73+
local model_hl, variant_hl
74+
for _, hl in ipairs(highlights) do
75+
if hl.hl_group == 'OpencodeHint' then
76+
model_hl = hl
77+
elseif hl.hl_group == 'OpencodeVariant' then
78+
variant_hl = hl
79+
end
80+
end
81+
82+
assert.is_not_nil(model_hl)
83+
assert.is_not_nil(variant_hl)
84+
assert.equals(SPINNER_BYTES + 1, model_hl.start_col)
85+
assert.equals(model_hl.start_col + #('openai/gpt-4'), model_hl.end_col)
86+
local sep_bytes = #('·')
87+
assert.equals(model_hl.end_col + sep_bytes, variant_hl.start_col)
88+
assert.equals(variant_hl.start_col + #('max'), variant_hl.end_col)
89+
end)
90+
91+
it('omits the model when config.ui.display_model is false', function()
92+
local config = require('opencode.config')
93+
local original = config.ui.display_model
94+
config.ui.display_model = false
95+
96+
local spinner = CursorSpinner.new(buf, 0, 0, { model = 'openai/gpt-4', variant = 'max' })
97+
table.insert(spinners, spinner)
98+
99+
local content, highlights, _ = spinner:build_layout()
100+
101+
assert.is_falsy(content:find('openai/gpt-4', 1, true))
102+
assert.is_falsy(content:find('·', 1, true))
103+
104+
for _, hl in ipairs(highlights) do
105+
assert.are_not.equal('OpencodeHint', hl.hl_group)
106+
assert.are_not.equal('OpencodeVariant', hl.hl_group)
107+
end
108+
109+
config.ui.display_model = original
110+
end)
111+
end)
112+
113+
describe('render', function()
114+
it('writes content and highlight extmarks to the float buffer', function()
115+
local spinner = CursorSpinner.new(buf, 0, 0, { model = 'openai/gpt-4' })
116+
table.insert(spinners, spinner)
117+
118+
local lines = vim.api.nvim_buf_get_lines(spinner.float_buf, 0, -1, false)
119+
assert.equals(1, #lines)
120+
assert.is_truthy(lines[1]:find('openai/gpt-4', 1, true))
121+
122+
local extmarks = vim.api.nvim_buf_get_extmarks(spinner.float_buf, spinner.ns_id, 0, -1, { details = true })
123+
local hl_groups = {}
124+
for _, mark in ipairs(extmarks) do
125+
if mark[4] and mark[4].hl_group then
126+
table.insert(hl_groups, mark[4].hl_group)
127+
end
128+
end
129+
130+
assert.is_true(vim.tbl_contains(hl_groups, 'OpencodeHint'))
131+
assert.is_true(vim.tbl_contains(hl_groups, 'WarningMsg'))
132+
end)
133+
end)
134+
end)

tests/unit/sha1_spec.lua

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
local sha1 = require('opencode.sha1')
2+
3+
describe('sha1', function()
4+
it('produces correct hash for empty string', function()
5+
assert.equals('da39a3ee5e6b4b0d3255bfef95601890afd80709', sha1(''))
6+
end)
7+
8+
it('produces correct hash for "abc"', function()
9+
assert.equals('a9993e364706816aba3e25717850c26c9cd0d89d', sha1('abc'))
10+
end)
11+
12+
it('produces correct hash for "abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"', function()
13+
assert.equals('84983e441c3bd26ebaae4aa1f95129e5e54670f1', sha1('abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq'))
14+
end)
15+
16+
it('produces correct hash for single character', function()
17+
assert.equals('86f7e437faa5a7fce15d1ddcb9eaeaea377667b8', sha1('a'))
18+
end)
19+
20+
it('produces correct hash for "ab"', function()
21+
assert.equals('da23614e02469a0d7c7bd1bdab5c9c474b1904dc', sha1('ab'))
22+
end)
23+
24+
it('produces correct hash for numbers', function()
25+
assert.equals('01b307acba4f54f55aafc33bb06bbbf6ca803e9a', sha1('1234567890'))
26+
end)
27+
28+
it('produces correct hash for special characters', function()
29+
assert.equals('bf24d65c9bb05b9b814a966940bcfa50767c8a8d', sha1('!@#$%^&*()'))
30+
end)
31+
32+
it('produces correct hash for string with spaces', function()
33+
assert.equals('2aae6c35c94fcfb415dbe95f408b9ce91ee846ed', sha1('hello world'))
34+
end)
35+
36+
it('produces correct hash for newlines', function()
37+
assert.equals('05eed6236c8bda5ecf7af09bae911f9d5f90998b', sha1('line1\nline2'))
38+
end)
39+
40+
it('produces correct hash for null byte', function()
41+
assert.equals('dbdd4f85d8a56500aa5c9c8a0d456f96280c92e5', sha1('ab\0c'))
42+
end)
43+
44+
it('produces correct hash for 448-bit message (exactly 56 bytes, boundary condition)', function()
45+
local msg = string.rep('a', 56)
46+
assert.equals('c2db330f6083854c99d4b5bfb6e8f29f201be699', sha1(msg))
47+
end)
48+
49+
it('produces correct hash for 512-bit message (exactly 64 bytes, one full block)', function()
50+
local msg = string.rep('a', 64)
51+
assert.equals('0098ba824b5c16427bd7a1122a5a442a25ec644d', sha1(msg))
52+
end)
53+
54+
it('produces correct hash for multi-block message (200 bytes)', function()
55+
local msg = string.rep('a', 200)
56+
assert.equals('e61cfffe0d9195a525fc6cf06ca2d77119c24a40', sha1(msg))
57+
end)
58+
59+
it('produces correct hash for unicode text', function()
60+
assert.equals('24e9f5c07847ff8a2a9fa77456655792f5bc7f9f', sha1('héllo wörld'))
61+
end)
62+
end)

0 commit comments

Comments
 (0)