Feature: Project-relative paths for cross-machine annotation syncing #23

Closed
opened 2026-01-20 23:48:09 -06:00 by EdSplendor · 18 comments
EdSplendor commented 2026-01-20 23:48:09 -06:00 (Migrated from github.com)

Did you check the docs?

  • I have read all the haunt.nvim docs

Currently, haunt.nvim stores file paths as absolute paths in the JSON format, which makes syncing annotations across different machines difficult or impossible when project directories are located at different paths.

Describe the solution you'd like

Implement project root detection and store file paths relative to the project root instead of absolute paths.

Project Root Detection

Detect project root by looking for common markers (in order of priority):

  • .git directory
  • pyproject.toml (Python)
  • Cargo.toml (Rust)
  • package.json (Node.js)
  • go.mod (Go)
  • .projectroot or similar custom marker
  • Allow users to configure custom root markers

Storage Format

Instead of:

{
  "file": "/home/user/projects/myapp/src/main.py",
  "annotation": "Important function"
}

Store as:

{
  "project_root": "/home/user/projects/myapp",
  "file": "src/main.py",
  "annotation": "Important function"
}

or even simpler, organize by project:

{
  "projects": {
    "myapp": {
      "root_markers": [".git"],
      "annotations": [
        {
          "file": "src/main.py",
          "line": 42,
          "annotation": "Important function"
        }
      ]
    }
  }
}

Describe alternatives/other solutions you've considered

Additional context

No response

### Did you check the docs? - [x] I have read all the haunt.nvim docs ### Is your feature request related to a problem? Please describe. Currently, haunt.nvim stores file paths as absolute paths in the JSON format, which makes syncing annotations across different machines difficult or impossible when project directories are located at different paths. ### Describe the solution you'd like Implement project root detection and store file paths relative to the project root instead of absolute paths. ### Project Root Detection Detect project root by looking for common markers (in order of priority): - `.git` directory - `pyproject.toml` (Python) - `Cargo.toml` (Rust) - `package.json` (Node.js) - `go.mod` (Go) - `.projectroot` or similar custom marker - Allow users to configure custom root markers ### Storage Format Instead of: ```json { "file": "/home/user/projects/myapp/src/main.py", "annotation": "Important function" } ``` Store as: ```json { "project_root": "/home/user/projects/myapp", "file": "src/main.py", "annotation": "Important function" } ``` or even simpler, organize by project: ```json { "projects": { "myapp": { "root_markers": [".git"], "annotations": [ { "file": "src/main.py", "line": 42, "annotation": "Important function" } ] } } } ``` ### Describe alternatives/other solutions you've considered - ### Additional context _No response_
Owner

@EdSplendor
I actually think the first solution you mentioned is better.

I am going to think about this and give it a shot.

To be honest, it will not be urgently at the top of my list. I think the effort to reward ratio for this is not the best.

I will keep in touch

@EdSplendor I actually think the first solution you mentioned is better. I am going to think about this and give it a shot. To be honest, it will not be urgently at the top of my list. I think the effort to reward ratio for this is not the best. I will keep in touch
impact-basin commented 2026-01-21 19:46:27 -06:00 (Migrated from github.com)

I was looking for the same thing. Turns out that you can just do this with an autocommand by reinitialising haunt on BufEnter, specifying data_dir by searching upwards until you find a git repo.

This is a bit hacky, but it's only a few lines of code and reliably delivers me a haunts/ directory at the root of my git repo.

vim.api.nvim_create_autocmd({"BufEnter"}, {
    callback = function ()
        local gitpath = vim.fn.finddir(".git", ".;")
        local path = vim.fn.fnamemodify(gitpath .. "/../haunts/", ":p:h") .. "/"
        require("haunt").setup({ data_dir = path })
        vim.notify("Haunting " .. path)
    end
})

I'll do some digging through the codebase; the reinitialisation was a matter of playing it safe, but optimally we could just set data_dir directly.

P.S. Great plugin! Thanks for all the hard work!

I was looking for the same thing. Turns out that you can just do this with an autocommand by reinitialising haunt on BufEnter, specifying `data_dir` by searching upwards until you find a git repo. This *is* a bit hacky, but it's only a few lines of code and reliably delivers me a `haunts/` directory at the root of my git repo. ```lua vim.api.nvim_create_autocmd({"BufEnter"}, { callback = function () local gitpath = vim.fn.finddir(".git", ".;") local path = vim.fn.fnamemodify(gitpath .. "/../haunts/", ":p:h") .. "/" require("haunt").setup({ data_dir = path }) vim.notify("Haunting " .. path) end }) ``` I'll do some digging through the codebase; the reinitialisation was a matter of playing it safe, but optimally we could just set `data_dir` directly. P.S. Great plugin! Thanks for all the hard work!
Owner

@impact-basin
WOW. that is freaking sick. I am glad i exposed setting the data directory.
Calling .setup Does not reinstall the plugin, but it DOES clear out all of your configurations options, and merges again with the default opts. I can expose a function that changes the data directory, but we should still need to refresh all the bookmarks since we are changing where things are stored on disk.

In terms of edge cases, I am thinking: "what if the user has two git repos at once?"

And i dont think we should support that. Its far too complex for what it is worth. This works for submodules since those use a .git file, not directory.

I think we should not support this as a full fledged feature, but we can include what you did on the readme, and i can expose the changing of the data_dir so it doesnt clear the config. (it will still do all the other stuff)

What are both of your thoughts?

@impact-basin WOW. that is freaking sick. I am glad i exposed setting the data directory. Calling `.setup` Does not reinstall the plugin, but it DOES clear out all of your configurations options, and merges again with the default opts. I can expose a function that changes the data directory, but we should still need to refresh all the bookmarks since we are changing where things are stored on disk. In terms of edge cases, I am thinking: "what if the user has two git repos at once?" And i dont think we should support that. Its far too complex for what it is worth. This works for submodules since those use a .git file, not directory. I think we should not support this as a full fledged feature, but we can include what you did on the readme, and i can expose the changing of the data_dir so it doesnt clear the config. (it will still do all the other stuff) What are both of your thoughts?
impact-basin commented 2026-01-21 20:32:21 -06:00 (Migrated from github.com)

Sounds great to me! Glad to be of use :-) Let me know if I can help with anything!

It seems to me that nested situations are inherently supported by finddir() -- it will find the closest-in git repo (so if you're in a submodule, it'll associate the notes with that submodule and not the parent).

Maybe it should be from the filepath of the buffer you've entered, rather than nvim's current directory, in case you're editing files across multiple git repos.

It's getting a bit late in the UK - but I'll fix this up tomorrow!

Sounds great to me! Glad to be of use :-) Let me know if I can help with anything! It seems to me that nested situations are inherently supported by finddir() -- it will find the closest-in git repo (so if you're in a submodule, it'll associate the notes with that submodule and not the parent). Maybe it should be from the filepath of the buffer you've entered, rather than nvim's current directory, in case you're editing files across multiple git repos. It's getting a bit late in the UK - but I'll fix this up tomorrow!
Owner

Here is the change.

https://github.com/user-attachments/assets/41f5c0d8-bbe2-41fe-8147-a80f5462ced7

which means the autocommand can now be:

vim.api.nvim_create_autocmd("DirChanged", {
  callback = function()
    local project_bookmarks = vim.fn.getcwd() .. "/.bookmarks/"
    require("haunt.api").change_data_dir(project_bookmarks)
  end,
})

And the plugin will simply reload everything, and change the data dir.

@EdSplendor @impact-basin
Please test and get back to me! I dont have to time, and I wont be able to either tomorrow.

Take a look at the CONTRIBUTING.md and checkout the branch of PR #33

Here is the change. https://github.com/user-attachments/assets/41f5c0d8-bbe2-41fe-8147-a80f5462ced7 which means the autocommand can now be: ``` lua vim.api.nvim_create_autocmd("DirChanged", { callback = function() local project_bookmarks = vim.fn.getcwd() .. "/.bookmarks/" require("haunt.api").change_data_dir(project_bookmarks) end, }) ``` And the plugin will simply reload everything, and change the data dir. @EdSplendor @impact-basin Please test and get back to me! I dont have to time, and I wont be able to either tomorrow. Take a look at the `CONTRIBUTING.md` and checkout the branch of PR #33
EdSplendor commented 2026-01-21 22:36:58 -06:00 (Migrated from github.com)

Thanks for implementing this!
I'll run through some scenarios this weekend.

Thanks for implementing this! I'll run through some scenarios this weekend.
impact-basin commented 2026-01-22 11:15:17 -06:00 (Migrated from github.com)

This is brilliant, @TheNoeTrevino -- this is exactly how I've always wanted to keep my notes and TODOs, without ever knowing it. I've been using it all day.

The new function is working admirably. Better - it's extremely flexible, letting you swap note sources on the fly.

Here's my current janky config. This lets me switch between per-buffer notes, per-repo notes, notes held at the current working directory, and "private" notes held at the usual datapath.

This is just a sketch of something good enough for my current needs - the functions work, but the autocommand will go all funky if you open a buffer that's not in a Git repo. Some amount of sanity-checking is worthwhile.

I will have to clean this up tomorrow. It's the department happy hour now - the hour per month that the department is happy :-).

local function haunt_switch_cwd()
        local path = vim.fn.getcwd() .. "/.haunts/"
        require("haunt.api").change_data_dir(path)
end

local function haunt_switch_buffer()
        local path = vim.fn.expand("%:p:h") .. "/.haunts/"
        require("haunt.api").change_data_dir(path)
end

local function haunt_switch_gitrepo()
        local gitpath = vim.fn.finddir(".git", vim.fn.expand("%:p:h") .. ";")
        local path = vim.fn.fnamemodify(gitpath .. "/../.haunts/", ":p:h") .. "/"
        require("haunt.api").change_data_dir(path)
end

local function haunt_switch_private()
        local path = vim.fn.stdpath("data") .. "/haunts/"
        require("haunt.api").change_data_dir(path)
end

vim.api.nvim_create_autocmd({"BufEnter"}, {
    callback = haunt_switch_gitrepo
})

vim.keymap.set("n", "<leader>hc", haunt_switch_cwd)
vim.keymap.set("n", "<leader>hb", haunt_switch_buffer)
vim.keymap.set("n", "<leader>hp", haunt_switch_private)
vim.keymap.set("n", "<leader>hg", haunt_switch_gitrepo)
This is brilliant, @TheNoeTrevino -- this is *exactly* how I've always wanted to keep my notes and TODOs, without ever knowing it. I've been using it all day. The new function is working admirably. Better - it's extremely flexible, letting you swap note sources on the fly. Here's my current janky config. This lets me switch between per-buffer notes, per-repo notes, notes held at the current working directory, and "private" notes held at the usual datapath. This is just a sketch of something good enough for my current needs - the functions work, but the autocommand will go all funky if you open a buffer that's *not* in a Git repo. Some amount of sanity-checking is worthwhile. I will have to clean this up tomorrow. It's the department happy hour now - the hour per month that the department is happy :-). ```lua local function haunt_switch_cwd() local path = vim.fn.getcwd() .. "/.haunts/" require("haunt.api").change_data_dir(path) end local function haunt_switch_buffer() local path = vim.fn.expand("%:p:h") .. "/.haunts/" require("haunt.api").change_data_dir(path) end local function haunt_switch_gitrepo() local gitpath = vim.fn.finddir(".git", vim.fn.expand("%:p:h") .. ";") local path = vim.fn.fnamemodify(gitpath .. "/../.haunts/", ":p:h") .. "/" require("haunt.api").change_data_dir(path) end local function haunt_switch_private() local path = vim.fn.stdpath("data") .. "/haunts/" require("haunt.api").change_data_dir(path) end vim.api.nvim_create_autocmd({"BufEnter"}, { callback = haunt_switch_gitrepo }) vim.keymap.set("n", "<leader>hc", haunt_switch_cwd) vim.keymap.set("n", "<leader>hb", haunt_switch_buffer) vim.keymap.set("n", "<leader>hp", haunt_switch_private) vim.keymap.set("n", "<leader>hg", haunt_switch_gitrepo) ```
Owner

@impact-basin

Dude! that is awesome!!!

If you could do me the greatest favor:
Once you do the 'clean up' (looks great to me) could you please post in the discussions tab about this in the show and tell? People will definitely be interested in this sort of thing. I would also consider this a success story :D

I am glad that new exposed function is working well for you!! Its way better than resetting the entirety of the configuration. I am going to leave this issue open until @EdSplendor gets back to me this weekend.

@impact-basin Dude! that is awesome!!! If you could do me the greatest favor: Once you do the 'clean up' (looks great to me) could you please post in the discussions tab about this in the show and tell? People will definitely be interested in this sort of thing. I would also consider this a success story :D I am glad that new exposed function is working well for you!! Its way better than resetting the entirety of the configuration. I am going to leave this issue open until @EdSplendor gets back to me this weekend.
Owner

@impact-basin

I believe you can do something like this:

    local gitroot = vim.fs.find(".git", {
        upward = true,
        path = vim.fn.expand("%:p:h"),
        type = "directory"
    })[1]
    
    if not gitroot then
        vim.notify("haunt.nvim: Not in a git repository, will not attempt to switch directories", vim.log.levels.WARN)
        return
    end

It might be a tad slow though, since it searches upwards. I am not very familiar with 'how far it will go'

@impact-basin I believe you can do something like this: ``` lua local gitroot = vim.fs.find(".git", { upward = true, path = vim.fn.expand("%:p:h"), type = "directory" })[1] if not gitroot then vim.notify("haunt.nvim: Not in a git repository, will not attempt to switch directories", vim.log.levels.WARN) return end ``` It might be a tad slow though, since it searches upwards. I am not very familiar with 'how far it will go'
EdSplendor commented 2026-01-24 00:05:07 -06:00 (Migrated from github.com)

Thanks for the ⁠change_data_dir() implementation!
However, this still stores absolute paths in the JSON files themselves. For true cross-machine syncing, we'd need the JSON to contain relative paths (e.g., ⁠src/main.py instead of ⁠/home/user/project/src/main.py). This way, the JSON files could be synced via cloud storage (Dropbox, Google Drive, etc.) or committed to Git and work regardless of where the project is located on different machines.

The current solution helps organize annotations by project, but if I sync the ⁠haunts/ directory to another machine where my project is at a different path (e.g., ⁠/Users/username/code/project vs ⁠/home/username/projects/project), the absolute paths in the JSON won't match and the annotations won't load.

Unfortunately, I'm not a Lua programmer and won't be able to help with the implementation directly. However, I'll try to implement this with LLM assistance, and if I'm successful, I'll submit a pull request. If anyone with Lua experience wants to tackle this in the meantime, that would be greatly appreciated!

Thanks for the ⁠change_data_dir() implementation! However, this still stores absolute paths in the JSON files themselves. For true cross-machine syncing, we'd need the JSON to contain relative paths (e.g., ⁠src/main.py instead of ⁠/home/user/project/src/main.py). This way, the JSON files could be synced via cloud storage (Dropbox, Google Drive, etc.) or committed to Git and work regardless of where the project is located on different machines. The current solution helps organize annotations by project, but if I sync the ⁠haunts/ directory to another machine where my project is at a different path (e.g., ⁠/Users/username/code/project vs ⁠/home/username/projects/project), the absolute paths in the JSON won't match and the annotations won't load. Unfortunately, I'm not a Lua programmer and won't be able to help with the implementation directly. However, I'll try to implement this with LLM assistance, and if I'm successful, I'll submit a pull request. If anyone with Lua experience wants to tackle this in the meantime, that would be greatly appreciated!
ewok commented 2026-01-24 04:43:01 -06:00 (Migrated from github.com)

what if to use relative path for detected git repo and fall back to original behaviour for non git.
something like github.com/ewok/haunt.nvim@b9699560cf

what if to use relative path for detected git repo and fall back to original behaviour for non git. something like https://github.com/ewok/haunt.nvim/commit/b9699560cf19f7efed385cda58c6bb0ed69eaa3c
EdSplendor commented 2026-01-25 01:14:48 -06:00 (Migrated from github.com)

I changed mainly two things:

  1. Storage paths now use git remote URL instead of absolute path - same
    project = same storage file, regardless of location
  2. Bookmark loading uses detected project root to resolve paths - annotations
    appear correctly after moving projects

So, now annotations should survive project moves and sync across machines.

I changed mainly two things: 1. Storage paths now use git remote URL instead of absolute path - same project = same storage file, regardless of location 2. Bookmark loading uses detected project root to resolve paths - annotations appear correctly after moving projects So, now annotations should survive project moves and sync across machines.
Owner

@EdSplendor
That is one fat PR! I am going to take a look soon, but I will be honest it might take me a couple of days

@EdSplendor That is one fat PR! I am going to take a look soon, but I will be honest it might take me a couple of days
rubin55 commented 2026-04-18 13:07:25 -05:00 (Migrated from github.com)

@TheNoeTrevino Is there any progress on this? It would be really great if bookmarks could be relative paths. As an aside, the picker (I'm using fzf-lua) also shows the full path and ideally, it would just show enough to make the entry unique so there is more space for the annotation; on Reddit, I read in a post that that is configurable but I couldn't find it anywhere. In any case, possibly that would become moot if relative paths are supported.

@TheNoeTrevino Is there any progress on this? It would be really great if bookmarks could be relative paths. As an aside, the picker (I'm using fzf-lua) also shows the full path and ideally, it would just show enough to make the entry unique so there is more space for the annotation; on Reddit, I read in a post that that is configurable but I couldn't find it anywhere. In any case, possibly that would become moot if relative paths are supported.
Owner

@rubin55

Yes! Check out the hooks PR. The plan is for that to have a way for the users to grab the current books marks, make a copy and mutate (standardize paths and whatnot) then save to a place of their choosing.

I have been busy with moving across the country, so I Haven't gotten around to merging it, but I can prioritize it this weekend.

Once I merge I will let you know provide some snippets that might work, and we can see if it works for you or not, then we can work from there.

@rubin55 Yes! Check out the hooks PR. The plan is for that to have a way for the users to grab the current books marks, make a copy and mutate (standardize paths and whatnot) then save to a place of their choosing. I have been busy with moving across the country, so I Haven't gotten around to merging it, but I can prioritize it this weekend. Once I merge I will let you know provide some snippets that might work, and we can see if it works for you or not, then we can work from there.
Owner

@rubin55

As for the fzf lua thing, I think we should open a new PR about that. To be honest, I am completely blind to issues regarding anything other than snacks.nvimsince that is what I use. Feel free to open an issue about it and I'll see what I can do. Should be relatively simple

@rubin55 As for the fzf lua thing, I think we should open a new PR about that. To be honest, I am completely blind to issues regarding anything other than `snacks.nvim`since that is what I use. Feel free to open an issue about it and I'll see what I can do. Should be relatively simple
Owner

@EdSplendor @impact-basin @ewok @rubin55
I apologize for the delay. I have spend a lot of time thinking about how to implement it, and i have added the functionality to the nightly branch for testing! I have had huge success adding my bookmarks to my NAS and targeting that, which moves them across my machines. You can choose a github repo or what not.

Here are some technical details:

  • projects paths are set by the {first commit hash + branch(if per branch is enabled)}. This is then hashed
  • inside of the above files, we can now have paths relative to the project root
  • following the first commit hash lets us make our marks portable, as some local only repos might not have a remote url

Here is an example of a file:

{
  "version": 2,
  "bookmarks": [
    {
      "file": "README.md",
      "line": 1,
      "note": "hey",
      "id": "542ec52c1a9da420"
    },
    {
      "file": "README.md",
      "line": 15,
      "note": "what",
      "id": "70d1faff85ab866f"
    },
    {
      "file": "internal/cli/column/column.go",
      "line": 1,
      "note": "this is deeper!",
      "id": "010d83b685cdfa86"
    }
  ]
}

This was a huge refactor for how we do storage. So, there is a migration process.

Please, target the nightly branch and let me know how you like it!

I will be merging this into main sometimes this weekend once its been looked at a bit.

Thank you for the wait!

@EdSplendor @impact-basin @ewok @rubin55 I apologize for the delay. I have spend a lot of time thinking about how to implement it, and i have added the functionality to the nightly branch for testing! I have had huge success adding my bookmarks to my NAS and targeting that, which moves them across my machines. You can choose a github repo or what not. Here are some technical details: - projects paths are set by the `{first commit hash + branch(if per branch is enabled)}`. This is then hashed - inside of the above files, we can now have paths relative to the project root - following the first commit hash lets us make our marks portable, as some local only repos might not have a remote url Here is an example of a file: ``` json { "version": 2, "bookmarks": [ { "file": "README.md", "line": 1, "note": "hey", "id": "542ec52c1a9da420" }, { "file": "README.md", "line": 15, "note": "what", "id": "70d1faff85ab866f" }, { "file": "internal/cli/column/column.go", "line": 1, "note": "this is deeper!", "id": "010d83b685cdfa86" } ] } ``` This was a huge refactor for how we do storage. So, there is a migration process. Please, target the nightly branch and let me know how you like it! I will be merging this into main sometimes this weekend once its been looked at a bit. Thank you for the wait!
Owner

Please see :h haunt-sharing for a tutorial on this. Once you target nightly of course

Please see `:h haunt-sharing` for a tutorial on this. Once you target nightly of course
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
noe.trevino/haunt.nvim#23
No description provided.