Skip to main content

git_side/
side_repo.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use crate::config::{self, hash_path};
5use crate::error::{Error, Result};
6use crate::git;
7
8/// Represents a side repository for a project.
9pub struct SideRepo {
10    /// Path to the bare git repository.
11    pub git_dir: PathBuf,
12    /// Path to the work tree (the main project directory).
13    pub work_tree: PathBuf,
14    /// The initial commit SHA of the main repo (project identifier).
15    pub root_sha: String,
16}
17
18impl SideRepo {
19    /// Resolve or create a side repo for the current project.
20    ///
21    /// # Errors
22    ///
23    /// Returns an error if not in a git repository or if config files cannot be accessed.
24    pub fn open() -> Result<Self> {
25        let work_tree = git::repo_root()?;
26        let path_hash = hash_path(&work_tree);
27
28        // Try cache first
29        let root_sha = if let Some(sha) = config::cache_lookup(&path_hash)? {
30            sha
31        } else {
32            // Cache miss: resolve and store
33            let sha = git::initial_commit_sha()?;
34            config::cache_store(&path_hash, &sha)?;
35            sha
36        };
37
38        // Get base path (custom or default)
39        let base_path = config::paths_lookup(&root_sha)?
40            .unwrap_or_else(config::default_base_path);
41
42        let git_dir = base_path.join(&root_sha);
43
44        Ok(Self {
45            git_dir,
46            work_tree,
47            root_sha,
48        })
49    }
50
51    /// Check if the side repo has been initialized.
52    #[must_use]
53    pub fn is_initialized(&self) -> bool {
54        self.git_dir.exists() && self.git_dir.join("HEAD").exists()
55    }
56
57    /// Initialize the side repo if not already done.
58    ///
59    /// # Errors
60    ///
61    /// Returns an error if the directory cannot be created or git init fails.
62    pub fn ensure_initialized(&self) -> Result<()> {
63        if self.is_initialized() {
64            return Ok(());
65        }
66
67        // Create parent directory
68        if let Some(parent) = self.git_dir.parent() {
69            fs::create_dir_all(parent).map_err(|e| Error::CreateDir {
70                path: parent.to_path_buf(),
71                source: e,
72            })?;
73        }
74
75        // Initialize bare repo
76        let git_dir_str = self.git_dir.to_string_lossy();
77        git::run(&["init", "--bare", &git_dir_str])?;
78
79        Ok(())
80    }
81
82    /// Run a git command in the context of the side repo.
83    ///
84    /// # Errors
85    ///
86    /// Returns an error if the git command fails.
87    pub fn git(&self, args: &[&str]) -> Result<String> {
88        git::run_with_paths(&self.git_dir, &self.work_tree, args)
89    }
90
91    /// Get the path to the .side-tracked file.
92    #[must_use]
93    pub fn tracked_file(&self) -> PathBuf {
94        self.git_dir.join(".side-tracked")
95    }
96
97    /// Stage a path (forced, bypassing gitignore).
98    ///
99    /// # Errors
100    ///
101    /// Returns an error if initialization or staging fails.
102    pub fn stage(&self, path: &Path) -> Result<()> {
103        self.ensure_initialized()?;
104        let path_str = path.to_string_lossy();
105        self.git(&["add", "-f", &path_str])?;
106        Ok(())
107    }
108
109    /// Stage paths with update flag (handles modifications and deletions).
110    /// Errors are ignored since paths may not be in the index yet.
111    pub fn stage_update(&self, paths: &[PathBuf]) {
112        if paths.is_empty() {
113            return;
114        }
115        if self.ensure_initialized().is_err() {
116            return;
117        }
118
119        let path_strs: Vec<String> = paths.iter().map(|p| p.to_string_lossy().into_owned()).collect();
120        let mut args: Vec<&str> = vec!["add", "-f", "-u", "--"];
121        args.extend(path_strs.iter().map(String::as_str));
122
123        // Ignore errors — paths may not be in the index yet
124        let _ = self.git(&args);
125    }
126
127    /// Stage paths (adds new files).
128    ///
129    /// # Errors
130    ///
131    /// Returns an error if initialization or staging fails.
132    pub fn stage_new(&self, paths: &[PathBuf]) -> Result<()> {
133        if paths.is_empty() {
134            return Ok(());
135        }
136        self.ensure_initialized()?;
137
138        let path_strs: Vec<String> = paths.iter().map(|p| p.to_string_lossy().into_owned()).collect();
139        let mut args: Vec<&str> = vec!["add", "-f", "--"];
140        args.extend(path_strs.iter().map(String::as_str));
141
142        self.git(&args)?;
143        Ok(())
144    }
145
146    /// Commit staged changes.
147    ///
148    /// # Errors
149    ///
150    /// Returns `NothingToCommit` if there are no staged changes, or an error if commit fails.
151    pub fn commit(&self, message: &str) -> Result<()> {
152        self.ensure_initialized()?;
153
154        // Check if there's anything staged to commit
155        // diff --cached --quiet exits with 1 if there are staged changes, 0 if none
156        let has_staged = self.git(&["diff", "--cached", "--quiet"]).is_err();
157        if !has_staged {
158            return Err(Error::NothingToCommit);
159        }
160
161        self.git(&["commit", "-m", message])?;
162        Ok(())
163    }
164
165    /// Get status output.
166    ///
167    /// # Errors
168    ///
169    /// Returns an error if the git status command fails.
170    pub fn status(&self) -> Result<String> {
171        if !self.is_initialized() {
172            return Ok(String::from("Side repo not initialized. Use 'git side add <path>' to start tracking files."));
173        }
174        self.git(&["status"])
175    }
176
177    /// Get log output.
178    ///
179    /// # Errors
180    ///
181    /// Returns an error if the git log command fails.
182    pub fn log(&self, args: &[&str]) -> Result<String> {
183        if !self.is_initialized() {
184            return Ok(String::from("Side repo not initialized. No history yet."));
185        }
186
187        let mut log_args = vec!["log"];
188        log_args.extend(args);
189        self.git(&log_args)
190    }
191
192    /// Remove a path from the index (unstage).
193    ///
194    /// # Errors
195    ///
196    /// Returns an error if the git rm command fails (though failures are typically ignored).
197    pub fn unstage(&self, path: &Path) -> Result<()> {
198        if !self.is_initialized() {
199            return Ok(());
200        }
201        let path_str = path.to_string_lossy();
202        // Use rm --cached to remove from index without deleting the file
203        let _ = self.git(&["rm", "--cached", "-r", "--ignore-unmatch", &path_str]);
204        Ok(())
205    }
206
207    /// Force push to origin/main.
208    ///
209    /// # Errors
210    ///
211    /// Returns an error if the push fails (e.g. no remote configured).
212    pub fn push(&self) -> Result<()> {
213        self.git(&["push", "-u", "--force", "origin", "main"])?;
214        Ok(())
215    }
216
217    /// Stage the .side-tracked file using git plumbing.
218    /// Since `.side-tracked` lives in `git_dir` (not `work_tree`), we use `hash-object` + `update-index`.
219    ///
220    /// # Errors
221    ///
222    /// Returns an error if the file doesn't exist or git commands fail.
223    pub fn stage_tracked_file(&self) -> Result<()> {
224        let tracked_file = self.tracked_file();
225        if !tracked_file.exists() {
226            return Ok(());
227        }
228
229        // Hash the file and write to object store
230        let tracked_path_str = tracked_file.to_string_lossy();
231        let sha = self.git(&["hash-object", "-w", &tracked_path_str])?;
232        let sha = sha.trim();
233
234        // Add to index with name .side-tracked at repo root
235        let cacheinfo = format!("100644,{sha},.side-tracked");
236        self.git(&["update-index", "--add", "--cacheinfo", &cacheinfo])?;
237
238        Ok(())
239    }
240}