Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ require('opencode').setup({
default_system_prompt = nil, -- Custom system prompt to use for all sessions. If nil, uses the default built-in system prompt
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
opencode_executable = 'opencode', -- Name of your opencode binary
snapshot_path = nil, -- Override base path for the snapshot git directory (default: $XDG_DATA_HOME/opencode). Appends /snapshot/<project_id>/<worktree_hash>

-- Server configuration for custom/external opencode servers
server = {
Expand Down
1 change: 1 addition & 0 deletions lua/opencode/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,7 @@ M.defaults = {
},
prompt_guard = nil,
child_readonly = true,
snapshot_path = nil,
hooks = {
on_file_edited = nil,
on_session_loaded = nil,
Expand Down
25 changes: 13 additions & 12 deletions lua/opencode/config_file.lua
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
local Promise = require('opencode.promise')
local sha1 = require('opencode.sha1')
local M = {
config_promise = nil,
project_promise = nil,
Expand Down Expand Up @@ -40,30 +41,30 @@ M.get_opencode_project = Promise.async(function()
return result --[[@as OpencodeProject|nil]]
end)

---Compute SHA1 of a string (used to match opencode's Hash.fast for worktree path)
---@param str string
---@return string|nil
local function sha1(str)
local result = vim.system({ 'sh', '-c', 'printf "%s" "$1" | sha1sum', '_', str }):wait()
if result and result.code == 0 then
return vim.trim(result.stdout):match('^(%x+)')
end
end

---Get the snapshot storage path for the current workspace
---Matches opencode's Global.Path.data + "snapshot" + projectId + Hash.fast(worktree)
---Can be overridden via config.snapshot_path (base path, project_id and worktree_hash are appended)
---@type fun(): Promise<string>
M.get_workspace_snapshot_path = Promise.async(function()
local project = M.get_opencode_project():await() --[[@as OpencodeProject|nil]]
if not project then
return ''
end
local home = vim.uv.os_homedir()
local data_home = require('opencode.config').snapshot_path
if not data_home or data_home == '' then
data_home = vim.uv.os_getenv('XDG_DATA_HOME')
if not data_home or data_home == '' then
data_home = vim.uv.os_homedir() .. '/.local/share'
end
data_home = vim.fs.joinpath(data_home, 'opencode')
end
local cwd = vim.fn.getcwd()
local worktree_hash = sha1(cwd)
if not worktree_hash then
return ''
end
return home .. '/.local/share/opencode/snapshot/' .. project.id .. '/' .. worktree_hash
local path = vim.fs.joinpath(data_home, 'snapshot', project.id, worktree_hash)
return vim.fs.normalize(path)
end)

local _providers_render_callback = false
Expand Down
93 changes: 93 additions & 0 deletions lua/opencode/sha1.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
local band = bit.band
local bor = bit.bor
local bxor = bit.bxor
local bnot = bit.bnot
local rol = bit.rol

local function u32hex(n)
if n < 0 then
n = n + 4294967296
end
return string.format('%08x', n)
end

---@param str string
---@return string|nil
local function sha1(str)
local bytes = { string.byte(str, 1, #str) }
local bit_len = #bytes * 8

bytes[#bytes + 1] = 0x80
while (#bytes % 64) ~= 56 do
bytes[#bytes + 1] = 0
end

local bit_len_hi = math.floor(bit_len / 4294967296)
local bit_len_lo = bit_len % 4294967296

bytes[#bytes + 1] = math.floor(bit_len_hi / 16777216) % 256
bytes[#bytes + 1] = math.floor(bit_len_hi / 65536) % 256
bytes[#bytes + 1] = math.floor(bit_len_hi / 256) % 256
bytes[#bytes + 1] = band(bit_len_hi, 0x000000ff)
bytes[#bytes + 1] = math.floor(bit_len_lo / 16777216) % 256
bytes[#bytes + 1] = math.floor(bit_len_lo / 65536) % 256
bytes[#bytes + 1] = math.floor(bit_len_lo / 256) % 256
bytes[#bytes + 1] = band(bit_len_lo, 0x000000ff)

local h0 = 0x67452301
local h1 = 0xefcdab89
local h2 = 0x98badcfe
local h3 = 0x10325476
local h4 = 0xc3d2e1f0

for i = 1, #bytes, 64 do
local w = {}
for j = 1, 16 do
local k = i + (j - 1) * 4
w[j] = band(bytes[k] * 16777216 + bytes[k + 1] * 65536 + bytes[k + 2] * 256 + bytes[k + 3], 0xffffffff)
end
for j = 17, 80 do
w[j] = rol(bxor(bxor(w[j - 3], w[j - 8]), bxor(w[j - 14], w[j - 16])), 1)
end

local a = h0
local b = h1
local c = h2
local d = h3
local e = h4

for j = 1, 80 do
local f, k
if j <= 20 then
f = bor(band(b, c), band(bnot(b), d))
k = 0x5a827999
elseif j <= 40 then
f = bxor(bxor(b, c), d)
k = 0x6ed9eba1
elseif j <= 60 then
f = bor(bor(band(b, c), band(b, d)), band(c, d))
k = 0x8f1bbcdc
else
f = bxor(bxor(b, c), d)
k = 0xca62c1d6
end

local temp = band(rol(a, 5) + f + e + k + w[j], 0xffffffff)
e = d
d = c
c = rol(b, 30)
b = a
a = temp
end

h0 = band(h0 + a, 0xffffffff)
h1 = band(h1 + b, 0xffffffff)
h2 = band(h2 + c, 0xffffffff)
h3 = band(h3 + d, 0xffffffff)
h4 = band(h4 + e, 0xffffffff)
end

return u32hex(h0) .. u32hex(h1) .. u32hex(h2) .. u32hex(h3) .. u32hex(h4)
end

return sha1
5 changes: 4 additions & 1 deletion lua/opencode/snapshot.lua
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ local function snapshot_git(cmd_args, opts)
local cwd = vim.fn.getcwd()
local args = { 'git', '--git-dir', snapshot_dir, '--work-tree', cwd }
vim.list_extend(args, cmd_args)

local result = vim.system(args, opts or { cwd = cwd }):wait()
if result and result.code == 0 then
return vim.trim(result.stdout), nil
Expand Down Expand Up @@ -172,6 +173,7 @@ end

function M.diff_file(snapshot_id, file_path)
local relative_path = vim.fn.fnamemodify(file_path, ':.')
relative_path = relative_path:gsub('\\', '/')
local file_at_snapshot = snapshot_git({ 'show', snapshot_id .. ':' .. relative_path })
local temp_file = write_to_temp_file(file_at_snapshot or '')
local file_type = vim.fn.fnamemodify(file_path, ':e')
Expand All @@ -187,7 +189,8 @@ function M.revert(snapshot_id)
end
local deleted_files = {}
for _, file in ipairs(patch_result.files) do
local relative_path = file:match('^' .. vim.fn.getcwd() .. '/?(.*)$')
local relative_path = file:match('^' .. vim.pesc(vim.fn.getcwd()) .. '/?(.*)$')
relative_path = relative_path:gsub('\\', '/')
local res, err = snapshot_git({ 'checkout', snapshot_id, '--', relative_path })
if not res then
vim.notify(
Expand Down
1 change: 1 addition & 0 deletions lua/opencode/types.lua
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,7 @@
---@field child_readonly boolean
---@field hooks OpencodeHooks
---@field quick_chat OpencodeQuickChatConfig
---@field snapshot_path? string -- Override base path for snapshot storage (default: $XDG_DATA_HOME/opencode). Appends /snapshot/<project_id>/<worktree_hash>

---@class MessagePartState
---@field input TaskToolInput|BashToolInput|FileToolInput|TodoToolInput|GlobToolInput|GrepToolInput|WebFetchToolInput|ListToolInput|QuestionToolInput|ApplyPatchToolInput Input data for the tool
Expand Down
62 changes: 62 additions & 0 deletions tests/unit/sha1_spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
local sha1 = require('opencode.sha1')

describe('sha1', function()
it('produces correct hash for empty string', function()
assert.equals('da39a3ee5e6b4b0d3255bfef95601890afd80709', sha1(''))
end)

it('produces correct hash for "abc"', function()
assert.equals('a9993e364706816aba3e25717850c26c9cd0d89d', sha1('abc'))
end)

it('produces correct hash for "abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"', function()
assert.equals('84983e441c3bd26ebaae4aa1f95129e5e54670f1', sha1('abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq'))
end)

it('produces correct hash for single character', function()
assert.equals('86f7e437faa5a7fce15d1ddcb9eaeaea377667b8', sha1('a'))
end)

it('produces correct hash for "ab"', function()
assert.equals('da23614e02469a0d7c7bd1bdab5c9c474b1904dc', sha1('ab'))
end)

it('produces correct hash for numbers', function()
assert.equals('01b307acba4f54f55aafc33bb06bbbf6ca803e9a', sha1('1234567890'))
end)

it('produces correct hash for special characters', function()
assert.equals('bf24d65c9bb05b9b814a966940bcfa50767c8a8d', sha1('!@#$%^&*()'))
end)

it('produces correct hash for string with spaces', function()
assert.equals('2aae6c35c94fcfb415dbe95f408b9ce91ee846ed', sha1('hello world'))
end)

it('produces correct hash for newlines', function()
assert.equals('05eed6236c8bda5ecf7af09bae911f9d5f90998b', sha1('line1\nline2'))
end)

it('produces correct hash for null byte', function()
assert.equals('dbdd4f85d8a56500aa5c9c8a0d456f96280c92e5', sha1('ab\0c'))
end)

it('produces correct hash for 448-bit message (exactly 56 bytes, boundary condition)', function()
local msg = string.rep('a', 56)
assert.equals('c2db330f6083854c99d4b5bfb6e8f29f201be699', sha1(msg))
end)

it('produces correct hash for 512-bit message (exactly 64 bytes, one full block)', function()
local msg = string.rep('a', 64)
assert.equals('0098ba824b5c16427bd7a1122a5a442a25ec644d', sha1(msg))
end)

it('produces correct hash for multi-block message (200 bytes)', function()
local msg = string.rep('a', 200)
assert.equals('e61cfffe0d9195a525fc6cf06ca2d77119c24a40', sha1(msg))
end)

it('produces correct hash for unicode text', function()
assert.equals('24e9f5c07847ff8a2a9fa77456655792f5bc7f9f', sha1('héllo wörld'))
end)
end)
Loading