Skip to main content

outpost_core/
source_repo.rs

1use std::collections::BTreeMap;
2use std::ffi::OsString;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use crate::outpost::Outpost;
7use crate::registry::{Registry, RegistryMut};
8use crate::{BranchName, GitInvoker, OutpostError, OutpostResult, RefName, UpstreamRef};
9
10pub struct SourceRepo {
11    work_tree: PathBuf,
12    git_dir: PathBuf,
13    git_common_dir: PathBuf,
14    git: GitInvoker,
15    env: BTreeMap<OsString, OsString>,
16}
17
18impl SourceRepo {
19    pub fn discover(start: &Path) -> OutpostResult<Self> {
20        Self::discover_with(start, &BTreeMap::new())
21    }
22
23    pub fn discover_with(start: &Path, env: &BTreeMap<OsString, OsString>) -> OutpostResult<Self> {
24        let git = invoker_at(start, env);
25        let work_tree = git
26            .run_capture(["rev-parse", "--show-toplevel"])
27            .map_err(|err| map_discovery_error(err, start))?;
28        Self::at_with(work_tree, env)
29    }
30
31    pub fn at(path: impl Into<PathBuf>) -> OutpostResult<Self> {
32        Self::at_with(path, &BTreeMap::new())
33    }
34
35    pub fn at_with(
36        path: impl Into<PathBuf>,
37        env: &BTreeMap<OsString, OsString>,
38    ) -> OutpostResult<Self> {
39        let start = path.into();
40        let git = invoker_at(&start, env);
41
42        let work_tree_raw = git
43            .run_capture(["rev-parse", "--show-toplevel"])
44            .map_err(|err| map_discovery_error(err, &start))?;
45        let git_dir_raw = git
46            .run_capture(["rev-parse", "--git-dir"])
47            .map_err(|err| map_discovery_error(err, &start))?;
48        let git_common_dir_raw = git
49            .run_capture(["rev-parse", "--git-common-dir"])
50            .map_err(|err| map_discovery_error(err, &start))?;
51
52        let work_tree = canonicalize_path(Path::new(&work_tree_raw))?;
53        let git_dir = canonicalize_git_path(&start, &git_dir_raw)?;
54        let git_common_dir = canonicalize_git_path(&start, &git_common_dir_raw)?;
55        let git = invoker_at(&work_tree, env);
56
57        Ok(Self {
58            work_tree,
59            git_dir,
60            git_common_dir,
61            git,
62            env: env.clone(),
63        })
64    }
65
66    pub fn work_tree(&self) -> &Path {
67        &self.work_tree
68    }
69
70    pub fn git_dir(&self) -> &Path {
71        &self.git_dir
72    }
73
74    pub fn git_common_dir(&self) -> &Path {
75        &self.git_common_dir
76    }
77
78    pub fn outpost_at(&self, path: &Path) -> OutpostResult<Outpost> {
79        Outpost::at_with(path, &self.env)
80    }
81
82    pub fn env(&self) -> &BTreeMap<OsString, OsString> {
83        &self.env
84    }
85
86    #[cfg(any(test, feature = "test-helpers"))]
87    pub fn test_invoker(&self) -> &GitInvoker {
88        &self.git
89    }
90
91    pub fn current_branch(&self) -> OutpostResult<BranchName> {
92        current_branch(&self.git, &self.work_tree)
93    }
94
95    pub fn checked_out_branches(&self) -> OutpostResult<Vec<BranchName>> {
96        let mut branches = Vec::new();
97        if let Ok(branch) = self.current_branch() {
98            branches.push(branch);
99        }
100
101        let output = self.git.run_capture(["worktree", "list", "--porcelain"])?;
102        for line in output.lines() {
103            if let Some(branch) = line.strip_prefix("branch refs/heads/") {
104                let branch = BranchName::parse(branch.to_owned())?;
105                if !branches.iter().any(|existing| existing == &branch) {
106                    branches.push(branch);
107                }
108            }
109        }
110        Ok(branches)
111    }
112
113    pub fn checked_out_worktree_for(&self, branch: &BranchName) -> OutpostResult<Option<PathBuf>> {
114        let output = self.git.run_capture(["worktree", "list", "--porcelain"])?;
115        let mut current_path: Option<PathBuf> = None;
116        for line in output.lines() {
117            if let Some(path) = line.strip_prefix("worktree ") {
118                current_path = Some(canonicalize_path(Path::new(path))?);
119            } else if let Some(value) = line.strip_prefix("branch refs/heads/") {
120                if value == branch.as_str() {
121                    return Ok(current_path);
122                }
123            }
124        }
125        Ok(None)
126    }
127
128    pub fn is_dirty(&self) -> OutpostResult<bool> {
129        is_dirty(&self.git)
130    }
131
132    pub fn upstream_for(&self, branch: &BranchName) -> OutpostResult<Option<UpstreamRef>> {
133        let remote_key = format!("branch.{}.remote", branch.as_str());
134        let merge_key = format!("branch.{}.merge", branch.as_str());
135        let Some(remote) = read_optional_config(&self.git, &remote_key)? else {
136            return Ok(None);
137        };
138        let Some(merge_ref) = read_optional_config(&self.git, &merge_key)? else {
139            return Ok(None);
140        };
141
142        Ok(Some(UpstreamRef {
143            remote: crate::RemoteName::parse(remote)?,
144            merge_ref: RefName::parse(merge_ref)?,
145        }))
146    }
147
148    pub fn branch_exists(&self, branch: &BranchName) -> OutpostResult<bool> {
149        let branch_ref = format!("refs/heads/{}", branch.as_str());
150        self.git
151            .run_status(["rev-parse", "--verify", "--quiet", &branch_ref])
152    }
153
154    pub fn fast_forward_branch_from_origin(&self, branch: &BranchName) -> OutpostResult<()> {
155        if !self.branch_exists(branch)? {
156            return Err(OutpostError::BranchNotFound {
157                branch: branch.as_str().to_owned(),
158                repo: self.work_tree.clone(),
159            });
160        }
161
162        let local_ref = format!("refs/heads/{}", branch.as_str());
163        let remote_ref = format!("refs/remotes/origin/{}", branch.as_str());
164        let fetch_refspec = format!("{}:{remote_ref}", branch.as_str());
165        self.git.run_check(["fetch", "origin", &fetch_refspec])?;
166
167        let local_oid = rev_parse(&self.git, &local_ref)?;
168        let remote_oid = rev_parse(&self.git, &remote_ref)?;
169        if local_oid == remote_oid || is_ancestor(&self.git, &remote_oid, &local_oid)? {
170            return Ok(());
171        }
172        if !is_ancestor(&self.git, &local_oid, &remote_oid)? {
173            return Err(OutpostError::Divergence {
174                branch: branch.as_str().to_owned(),
175            });
176        }
177
178        if let Some(worktree) = self.checked_out_worktree_for(branch)? {
179            let git = invoker_at(&worktree, &self.env);
180            git.run_check(["merge", "--ff-only", &remote_ref])?;
181        } else {
182            self.git
183                .run_check(["update-ref", &local_ref, &remote_oid, &local_oid])?;
184        }
185
186        Ok(())
187    }
188
189    pub fn registry_path(&self) -> PathBuf {
190        self.work_tree.join(".outpost").join("registry.json")
191    }
192
193    pub fn registry(&self) -> OutpostResult<Registry> {
194        Registry::load(self)
195    }
196
197    pub fn registry_mut(&self) -> OutpostResult<RegistryMut<'_>> {
198        RegistryMut::load(self)
199    }
200
201    pub(crate) fn local_exclude_path(&self) -> PathBuf {
202        self.git_dir.join("info").join("exclude")
203    }
204
205    pub(crate) fn git(&self) -> &GitInvoker {
206        &self.git
207    }
208
209    #[cfg(test)]
210    pub(crate) fn from_storage_paths(work_tree: &Path, git_dir: &Path) -> OutpostResult<Self> {
211        let work_tree = canonicalize_path(work_tree)?;
212        let git_dir = canonicalize_path(git_dir)?;
213        Ok(Self {
214            git_common_dir: git_dir.clone(),
215            git: GitInvoker::at(&work_tree),
216            env: BTreeMap::new(),
217            work_tree,
218            git_dir,
219        })
220    }
221}
222
223pub(crate) fn invoker_at(cwd: &Path, env: &BTreeMap<OsString, OsString>) -> GitInvoker {
224    env.iter().fold(GitInvoker::at(cwd), |git, (key, val)| {
225        git.with_env(key.clone(), val.clone())
226    })
227}
228
229pub(crate) fn current_branch(git: &GitInvoker, repo: &Path) -> OutpostResult<BranchName> {
230    let name = git
231        .run_capture(["symbolic-ref", "--quiet", "--short", "HEAD"])
232        .map_err(|err| match err {
233            OutpostError::GitFailed { .. } => OutpostError::BranchNotFound {
234                branch: "HEAD".to_owned(),
235                repo: repo.to_path_buf(),
236            },
237            other => other,
238        })?;
239    BranchName::parse(name)
240}
241
242pub(crate) fn is_dirty(git: &GitInvoker) -> OutpostResult<bool> {
243    Ok(!git
244        .run_capture(["status", "--porcelain=v1", "--untracked-files=normal"])?
245        .is_empty())
246}
247
248pub(crate) fn read_optional_config(git: &GitInvoker, key: &str) -> OutpostResult<Option<String>> {
249    if git.run_status(["config", "--local", "--get", key])? {
250        git.run_capture(["config", "--local", "--get", key])
251            .map(Some)
252    } else {
253        Ok(None)
254    }
255}
256
257pub(crate) fn rev_parse(git: &GitInvoker, reference: &str) -> OutpostResult<String> {
258    git.run_capture(["rev-parse", reference])
259}
260
261pub(crate) fn is_ancestor(
262    git: &GitInvoker,
263    ancestor: &str,
264    descendant: &str,
265) -> OutpostResult<bool> {
266    git.run_status(["merge-base", "--is-ancestor", ancestor, descendant])
267}
268
269pub(crate) fn canonicalize_path(path: &Path) -> OutpostResult<PathBuf> {
270    fs::canonicalize(path).map_err(|source| OutpostError::IoAt {
271        path: path.to_path_buf(),
272        source,
273    })
274}
275
276fn canonicalize_git_path(start: &Path, value: &str) -> OutpostResult<PathBuf> {
277    let path = PathBuf::from(value);
278    if path.is_absolute() {
279        canonicalize_path(&path)
280    } else {
281        canonicalize_path(&start.join(path))
282    }
283}
284
285fn map_discovery_error(err: OutpostError, path: &Path) -> OutpostError {
286    match err {
287        OutpostError::GitFailed { .. } => OutpostError::NotARepo(path.to_path_buf()),
288        other => other,
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use std::fs;
295
296    use super::*;
297
298    #[test]
299    fn source_at_canonicalizes_paths_and_reads_current_branch() {
300        let temp = tempfile::tempdir().expect("tempdir");
301        GitInvoker::at(temp.path())
302            .run_check(["init", "--initial-branch=main"])
303            .expect("init");
304        let source = SourceRepo::at(temp.path()).expect("source repo");
305
306        assert_eq!(source.work_tree(), fs::canonicalize(temp.path()).unwrap());
307        assert_eq!(
308            source.git_dir(),
309            fs::canonicalize(temp.path().join(".git")).unwrap()
310        );
311        assert_eq!(
312            source.git_common_dir(),
313            fs::canonicalize(temp.path().join(".git")).unwrap()
314        );
315        assert_eq!(source.current_branch().unwrap().as_str(), "main");
316        assert!(!source.is_dirty().unwrap());
317    }
318
319    #[test]
320    fn source_discover_rejects_non_repo() {
321        let temp = tempfile::tempdir().expect("tempdir");
322        let Err(err) = SourceRepo::discover(temp.path()) else {
323            panic!("non repo should fail");
324        };
325
326        assert!(matches!(err, OutpostError::NotARepo(path) if path == temp.path()));
327    }
328
329    #[test]
330    fn source_dirty_detects_untracked_files() {
331        let temp = tempfile::tempdir().expect("tempdir");
332        GitInvoker::at(temp.path())
333            .run_check(["init", "--initial-branch=main"])
334            .expect("init");
335        fs::write(temp.path().join("new.txt"), "dirty").expect("write untracked");
336
337        let source = SourceRepo::at(temp.path()).expect("source repo");
338        assert!(source.is_dirty().unwrap());
339    }
340
341    #[test]
342    fn source_branch_helpers_read_local_heads_upstream_and_worktrees() {
343        let temp = tempfile::tempdir().expect("tempdir");
344        let sibling = tempfile::tempdir().expect("worktree parent");
345        let feature_worktree = sibling.path().join("feature-worktree");
346        let git = GitInvoker::at(temp.path());
347        git.run_check(["init", "--initial-branch=main"])
348            .expect("init");
349        git.run_check(["config", "user.name", "Test User"])
350            .expect("user name");
351        git.run_check(["config", "user.email", "test@example.com"])
352            .expect("user email");
353        git.run_check(["commit", "--allow-empty", "-m", "initial"])
354            .expect("initial commit");
355        git.run_check(["branch", "feature"])
356            .expect("feature branch");
357        git.run_check(["config", "--local", "branch.main.remote", "origin"])
358            .expect("remote config");
359        git.run_check(["config", "--local", "branch.main.merge", "refs/heads/main"])
360            .expect("merge config");
361        git.run_check([
362            "worktree",
363            "add",
364            feature_worktree.to_str().unwrap(),
365            "feature",
366        ])
367        .expect("add worktree");
368
369        let source = SourceRepo::at(temp.path()).expect("source repo");
370        let main = BranchName::parse("main").unwrap();
371        let feature = BranchName::parse("feature").unwrap();
372
373        assert!(source.branch_exists(&main).unwrap());
374        assert!(
375            !source
376                .branch_exists(&BranchName::parse("missing").unwrap())
377                .unwrap()
378        );
379        assert_eq!(
380            source
381                .upstream_for(&main)
382                .unwrap()
383                .expect("main upstream")
384                .merge_ref
385                .as_str(),
386            "refs/heads/main"
387        );
388        assert_eq!(
389            source.checked_out_worktree_for(&feature).unwrap(),
390            Some(fs::canonicalize(&feature_worktree).unwrap())
391        );
392        let checked_out = source.checked_out_branches().unwrap();
393        assert!(checked_out.iter().any(|branch| branch == &main));
394        assert!(checked_out.iter().any(|branch| branch == &feature));
395    }
396}