prs_lib/
sync.rs

1//! Password store synchronization functionality.
2
3use std::path::Path;
4use std::time::Duration;
5
6use anyhow::Result;
7
8use crate::{
9    Store,
10    git::{self, RepositoryState},
11};
12
13/// Store git directory.
14pub const STORE_GIT_DIR: &str = ".git/";
15
16/// Duration after which pull refs are considered outdated.
17///
18/// If the last pull is within this duration, some operations such as a push may be optimized away
19/// if not needed.
20pub const GIT_PULL_OUTDATED: Duration = Duration::from_secs(30);
21
22/// Sync helper for given store.
23pub struct Sync<'a> {
24    /// The store.
25    store: &'a Store,
26}
27
28impl<'a> Sync<'a> {
29    /// Construct new sync helper for given store.
30    pub fn new(store: &'a Store) -> Sync<'a> {
31        Self { store }
32    }
33
34    /// Get the repository path.
35    fn path(&self) -> &Path {
36        &self.store.root
37    }
38
39    /// Check readyness of store for syncing.
40    ///
41    /// This checks whether the repository state is clean, which means that there's no active
42    /// merge/rebase/etc.
43    /// The repository might be dirty, use `sync_is_dirty` to check that.
44    pub fn readyness(&self) -> Result<Readyness> {
45        let path = self.path();
46
47        if !self.is_init() {
48            return Ok(Readyness::NoSync);
49        }
50
51        match git::git_state(path).unwrap() {
52            RepositoryState::Clean => {
53                if is_dirty(path)? {
54                    Ok(Readyness::Dirty)
55                } else {
56                    Ok(Readyness::Ready)
57                }
58            }
59            state => Ok(Readyness::RepoState(state)),
60        }
61    }
62
63    /// Prepare the store for new changes.
64    ///
65    /// - If sync is not initialized, it does nothing.
66    /// - If sync remote is set, it pulls changes.
67    pub fn prepare(&self) -> Result<()> {
68        // TODO: return error if dirty?
69
70        // Skip if no sync
71        if !self.is_init() {
72            return Ok(());
73        }
74
75        // We're done if we don't have a remote
76        if !self.has_remote()? {
77            return Ok(());
78        }
79
80        // We must have upstream set, otherwise try to automatically set or don't pull
81        let repo = self.path();
82        if git::git_branch_upstream(repo, "HEAD")?.is_none() {
83            // Get remotes, we cannot decide upstream if we don't have exactly one
84            let remotes = self.tracked_remote_or_remotes()?;
85            if remotes.len() != 1 {
86                return Ok(());
87            }
88
89            // Fetch remote branches
90            let remote = &remotes[0];
91            git::git_fetch(repo, Some(remote))?;
92
93            // List remote branches, stop if there are none
94            let remote_branches = git::git_branch_remote(repo)?;
95            if remote_branches.is_empty() {
96                return Ok(());
97            }
98
99            // Determine upstream reference
100            let branch = git::git_current_branch(repo)?;
101            let upstream_ref = format!("{remote}/{branch}");
102
103            // Set upstream reference if available on remote, otherwise stop
104            if !remote_branches.contains(&upstream_ref) {
105                return Ok(());
106            }
107            git::git_branch_set_upstream(repo, None, &upstream_ref)?;
108        }
109
110        self.pull()?;
111
112        Ok(())
113    }
114
115    /// Finalize the store with new changes.
116    ///
117    /// - If sync is not initialized, it does nothing.
118    /// - If sync is initialized, it commits changes.
119    /// - If sync remote is set, it pushes changes.
120    pub fn finalize<M: AsRef<str>>(&self, msg: M) -> Result<()> {
121        // Skip if no sync
122        if !self.is_init() {
123            return Ok(());
124        }
125
126        // Commit changes if dirty
127        if is_dirty(self.path())? {
128            self.commit_all(msg, false)?;
129        }
130
131        // Do not push  if no remote or not out of sync
132        if !self.has_remote()? || !safe_need_to_push(self.path()) {
133            return Ok(());
134        }
135
136        // We must have upstream set, otherwise try to automatically set or don't push
137        let mut set_branch = None;
138        let mut set_upstream = None;
139        let repo = self.path();
140        if git::git_branch_upstream(repo, "HEAD")?.is_none() {
141            // Get remotes, we cannot decide upstream if we don't have exactly one
142            let remotes = git::git_remote(repo)?;
143            if remotes.len() == 1 {
144                // Fetch and list remote branches
145                let remote = &remotes[0];
146                git::git_fetch(repo, Some(remote))?;
147                let remote_branches = git::git_branch_remote(repo)?;
148
149                // Determine upstream reference
150                let branch = git::git_current_branch(repo)?;
151                let upstream_ref = format!("{remote}/{branch}");
152
153                // Set upstream reference if not yet used on remote
154                if !remote_branches.contains(&upstream_ref) {
155                    set_branch = Some(branch);
156                    set_upstream = Some(remote.to_string());
157                }
158            }
159        }
160
161        self.push(set_branch.as_deref(), set_upstream.as_deref())?;
162
163        Ok(())
164    }
165
166    /// Initialize sync.
167    pub fn init(&self) -> Result<()> {
168        git::git_init(self.path())?;
169        self.commit_all("Initialize sync with git", true)?;
170        Ok(())
171    }
172
173    /// Clone sync from a remote URL.
174    pub fn clone(&self, url: &str, quiet: bool) -> Result<()> {
175        let path = self
176            .path()
177            .to_str()
178            .expect("failed to determine clone path");
179        git::git_clone(self.path(), url, path, quiet)?;
180        Ok(())
181    }
182
183    /// Check whether sync has been initialized in this store.
184    pub fn is_init(&self) -> bool {
185        self.path().join(STORE_GIT_DIR).is_dir()
186    }
187
188    /// Get a list of sync remotes.
189    pub fn remotes(&self) -> Result<Vec<String>> {
190        git::git_remote(self.path())
191    }
192
193    /// Get a list of tracked remote or all sync remotes.
194    pub fn tracked_remote_or_remotes(&self) -> Result<Vec<String>> {
195        // Get current branch and remote
196        let branch = git::git_current_branch(self.path())?;
197        let remote = git::git_config_branch_remote(self.path(), &branch);
198        if let Ok(Some(remote)) = remote {
199            return Ok(vec![remote]);
200        }
201
202        // Fall back to remote list
203        git::git_remote(self.path())
204    }
205
206    /// Get the URL of the given remote.
207    pub fn remote_url(&self, remote: &str) -> Result<String> {
208        git::git_remote_get_url(self.path(), remote)
209    }
210
211    /// Add the URL of the given remote.
212    pub fn add_remote_url(&self, remote: &str, url: &str) -> Result<()> {
213        git::git_remote_add(self.path(), remote, url)?;
214
215        // Set this remote for the current branch if none is set, ignore errors
216        let branch = git::git_current_branch(self.path());
217        if let Ok(ref branch) = branch {
218            let _ = git::git_config_branch_set_remote(self.path(), branch, remote);
219        }
220
221        Ok(())
222    }
223
224    /// Set the URL of the given remote.
225    pub fn set_remote_url(&self, remote: &str, url: &str) -> Result<()> {
226        // Do not set but remove and add to flush any fetched remote data
227        git::git_remote_remove(self.path(), remote)?;
228        self.add_remote_url(remote, url)
229    }
230
231    /// Check whether this store has a remote configured.
232    pub fn has_remote(&self) -> Result<bool> {
233        if !self.is_init() {
234            return Ok(false);
235        }
236        git::git_has_remote(self.path())
237    }
238
239    /// Pull changes from remote.
240    fn pull(&self) -> Result<()> {
241        git::git_pull(self.path())
242    }
243
244    /// Push changes to remote.
245    fn push(&self, set_branch: Option<&str>, set_upstream: Option<&str>) -> Result<()> {
246        git::git_push(self.path(), set_branch, set_upstream)
247    }
248
249    /// Add all changes and commit them.
250    pub fn commit_all<M: AsRef<str>>(&self, msg: M, commit_empty: bool) -> Result<()> {
251        let path = self.path();
252        git::git_add_all(path)?;
253        git::git_commit(path, msg.as_ref(), commit_empty)
254    }
255
256    /// Hard reset all changes.
257    pub fn reset_hard_all(&self) -> Result<()> {
258        let path = self.path();
259        git::git_add_all(path)?;
260        git::git_reset_hard(path)
261    }
262
263    /// Get a list of changed files as raw output.
264    /// This output is directly from git, is not processed, and is not stable.
265    ///
266    /// If the list is empty, an empty string is returned.
267    pub fn changed_files_raw(&self, short: bool) -> Result<String> {
268        let path = self.path();
269        let mut status = git::git_status(path, short)?;
270
271        // If empty when trimmed, wipe completely
272        if status.trim().is_empty() {
273            status.truncate(0);
274        }
275
276        Ok(status)
277    }
278}
279
280/// Defines readyness of store sync.
281///
282/// Some states block sync usage, including:
283/// - Sync not initialized
284/// - Git repository is dirty
285#[derive(Debug, Eq, PartialEq)]
286pub enum Readyness {
287    /// Sync is not initialized for this store.
288    NoSync,
289
290    /// Special repository state.
291    RepoState(git::RepositoryState),
292
293    /// Repository is dirty (has uncommitted changes).
294    Dirty,
295
296    /// Ready to sync.
297    Ready,
298}
299
300impl Readyness {
301    /// Check if ready.
302    pub fn is_ready(&self) -> bool {
303        matches!(self, Self::Ready)
304    }
305}
306
307/// Check if repository is dirty.
308///
309/// Repository is dirty if it has any uncommitted changed.
310fn is_dirty(repo: &Path) -> Result<bool> {
311    git::git_has_changes(repo)
312}
313
314/// Check whether we need to push to the remote.
315///
316/// This defaults to true on error.
317fn safe_need_to_push(repo: &Path) -> bool {
318    match need_to_push(repo) {
319        Ok(push) => push,
320        Err(err) => {
321            eprintln!("failed to test if local branch is different than remote, ignoring: {err}",);
322            true
323        }
324    }
325}
326
327/// Check whether we need to push to the remote.
328///
329/// If the upstream branch is unknown, this always returns true.
330fn need_to_push(repo: &Path) -> Result<bool> {
331    // If last pull is outdated, always push
332    let last_pulled = git::git_last_pull_time(repo)?;
333    if last_pulled.elapsed()? > GIT_PULL_OUTDATED {
334        return Ok(true);
335    }
336
337    // Get branch and upstream branch name
338    let branch = git::git_current_branch(repo)?;
339    let upstream = match git::git_branch_upstream(repo, &branch)? {
340        Some(upstream) => upstream,
341        None => return Ok(true),
342    };
343
344    // Compare local and remote branch hashes
345    Ok(git::git_ref_hash(repo, branch)? != git::git_ref_hash(repo, upstream)?)
346}