Skip to main content

jj_lib/
workspace_store.rs

1// Copyright 2025 The Jujutsu Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Workspace store for managing workspace metadata.
16
17use std::fmt::Debug;
18use std::fs;
19use std::io::Write as _;
20use std::path::Path;
21use std::path::PathBuf;
22
23use jj_lib::file_util::BadPathEncoding;
24use jj_lib::file_util::IoResultExt as _;
25use jj_lib::file_util::PathError;
26use jj_lib::file_util::path_from_bytes;
27use jj_lib::file_util::path_to_bytes;
28use jj_lib::file_util::persist_temp_file;
29use jj_lib::file_util::relative_path;
30use jj_lib::file_util::slash_path;
31use jj_lib::lock::FileLock;
32use jj_lib::lock::FileLockError;
33use jj_lib::protos::simple_workspace_store;
34use jj_lib::ref_name::WorkspaceName;
35use prost::Message as _;
36use tempfile::NamedTempFile;
37use thiserror::Error;
38
39/// Errors that can occur when interacting with a workspace store.
40#[derive(Error, Debug)]
41pub enum WorkspaceStoreError {
42    /// An unspecified error occurred.
43    #[error(transparent)]
44    Other(#[from] Box<dyn std::error::Error + Send + Sync>),
45}
46
47/// A storage backend for workspace metadata.
48pub trait WorkspaceStore: Send + Sync + Debug {
49    /// Returns the name of this workspace store implementation.
50    fn name(&self) -> &str;
51
52    /// Adds a workspace with the given name and path to the store.
53    fn add(&self, workspace_name: &WorkspaceName, path: &Path) -> Result<(), WorkspaceStoreError>;
54
55    /// Forgets the workspaces with the given names.
56    fn forget(&self, workspace_names: &[&WorkspaceName]) -> Result<(), WorkspaceStoreError>;
57
58    /// Renames a workspace from `old_name` to `new_name`.
59    fn rename(
60        &self,
61        old_name: &WorkspaceName,
62        new_name: &WorkspaceName,
63    ) -> Result<(), WorkspaceStoreError>;
64
65    /// Gets the path of the workspace with the given name, if it exists.
66    fn get_workspace_path(
67        &self,
68        workspace_name: &WorkspaceName,
69    ) -> Result<Option<PathBuf>, WorkspaceStoreError>;
70}
71
72/// Errors specific to the `SimpleWorkspaceStore` implementation.
73#[derive(Error, Debug)]
74pub enum SimpleWorkspaceStoreError {
75    /// An I/O error related to a file path.
76    #[error(transparent)]
77    Path(#[from] PathError),
78    /// An error occurred while trying to lock the workspace store.
79    #[error("Failed to lock workspace store")]
80    Lock(#[from] FileLockError),
81    /// An error occurred while decoding Protobuf data.
82    #[error(transparent)]
83    ProstDecode(#[from] prost::DecodeError),
84    /// An error occurred due to bad path encoding.
85    #[error(transparent)]
86    BadPathEncoding(#[from] BadPathEncoding),
87}
88
89impl From<SimpleWorkspaceStoreError> for WorkspaceStoreError {
90    fn from(err: SimpleWorkspaceStoreError) -> Self {
91        Self::Other(Box::new(err))
92    }
93}
94
95/// A simple file-based implementation of `WorkspaceStore`.
96#[derive(Debug)]
97pub struct SimpleWorkspaceStore {
98    repo_path: PathBuf,
99    store_file: PathBuf,
100    lock_file: PathBuf,
101}
102
103impl SimpleWorkspaceStore {
104    /// Loads the workspace store from the given repository path.
105    pub fn load(repo_path: &Path) -> Result<Self, WorkspaceStoreError> {
106        let store_dir = repo_path.join("workspace_store");
107        let file = store_dir.join("index");
108
109        let store = Self {
110            repo_path: repo_path.to_path_buf(),
111            store_file: file.clone(),
112            lock_file: file.with_extension("lock"),
113        };
114
115        // Ensure the workspace_store directory exists. We need this
116        // for repos that were created before workspace_store was added.
117        if !store_dir.exists() {
118            fs::create_dir(&store_dir)
119                .context(store_dir)
120                .map_err(SimpleWorkspaceStoreError::Path)?;
121
122            let _lock = store.lock()?;
123
124            store.write_store(simple_workspace_store::Workspaces::default())?;
125        }
126
127        Ok(store)
128    }
129
130    fn lock(&self) -> Result<FileLock, SimpleWorkspaceStoreError> {
131        Ok(FileLock::lock(self.lock_file.clone())?)
132    }
133
134    fn read_store(&self) -> Result<simple_workspace_store::Workspaces, SimpleWorkspaceStoreError> {
135        let workspace_data = fs::read(&self.store_file).context(&self.store_file)?;
136
137        let workspaces_proto = simple_workspace_store::Workspaces::decode(&*workspace_data)?;
138
139        Ok(workspaces_proto)
140    }
141
142    fn write_store(
143        &self,
144        workspaces_proto: simple_workspace_store::Workspaces,
145    ) -> Result<(), SimpleWorkspaceStoreError> {
146        // We had created the store dir in load(), so parent() must exist.
147        let store_file_parent = self.store_file.parent().unwrap();
148        let temp_file = NamedTempFile::new_in(store_file_parent).context(store_file_parent)?;
149
150        temp_file
151            .as_file()
152            .write_all(&workspaces_proto.encode_to_vec())
153            .context(temp_file.path())?;
154
155        persist_temp_file(temp_file, &self.store_file).context(&self.store_file)?;
156
157        Ok(())
158    }
159}
160
161impl WorkspaceStore for SimpleWorkspaceStore {
162    fn name(&self) -> &'static str {
163        "simple"
164    }
165
166    fn add(&self, workspace_name: &WorkspaceName, path: &Path) -> Result<(), WorkspaceStoreError> {
167        let _lock = self.lock()?;
168
169        let mut workspaces_proto = self.read_store()?;
170
171        // Delete any existing entry with the same name
172        workspaces_proto
173            .workspaces
174            .retain(|w| w.name.as_str() != workspace_name.as_str());
175
176        let path_to_store = relative_path(&self.repo_path, path);
177        let path_to_store = if path_to_store.is_relative() {
178            slash_path(&path_to_store).into_owned()
179        } else {
180            path_to_store
181        };
182        workspaces_proto
183            .workspaces
184            .push(simple_workspace_store::Workspace {
185                name: workspace_name.as_str().to_string(),
186                path: path_to_bytes(&path_to_store)
187                    .map_err(SimpleWorkspaceStoreError::BadPathEncoding)?
188                    .to_owned(),
189            });
190
191        self.write_store(workspaces_proto)?;
192
193        Ok(())
194    }
195
196    fn forget(&self, workspace_names: &[&WorkspaceName]) -> Result<(), WorkspaceStoreError> {
197        let _lock = self.lock()?;
198
199        let mut workspaces_proto = self.read_store()?;
200
201        workspaces_proto.workspaces.retain(|w| {
202            !workspace_names
203                .iter()
204                .any(|name| w.name.as_str() == name.as_str())
205        });
206
207        self.write_store(workspaces_proto)?;
208
209        Ok(())
210    }
211
212    fn rename(
213        &self,
214        old_name: &WorkspaceName,
215        new_name: &WorkspaceName,
216    ) -> Result<(), WorkspaceStoreError> {
217        let _lock = self.lock()?;
218
219        let mut workspaces_proto = self.read_store()?;
220
221        for workspace in &mut workspaces_proto.workspaces {
222            if workspace.name.as_str() == old_name.as_str() {
223                workspace.name = new_name.as_str().to_string();
224            }
225        }
226
227        self.write_store(workspaces_proto)?;
228
229        Ok(())
230    }
231
232    fn get_workspace_path(
233        &self,
234        workspace_name: &WorkspaceName,
235    ) -> Result<Option<PathBuf>, WorkspaceStoreError> {
236        let workspace = self
237            .read_store()?
238            .workspaces
239            .iter()
240            .find(|w| w.name.as_str() == workspace_name.as_str())
241            .cloned();
242
243        Ok(workspace
244            .map(|w| {
245                path_from_bytes(&w.path)
246                    .map(|p| p.to_path_buf())
247                    .map_err(SimpleWorkspaceStoreError::BadPathEncoding)
248            })
249            .transpose()?)
250    }
251}