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}