1use std::ffi::OsString;
2use std::io;
3use std::path::{Path, PathBuf};
4
5use crate::metadata::Metadata;
6use crate::registry::RegistryEntry;
7use crate::safety;
8use crate::{
9 BranchName, Outpost, OutpostError, OutpostResult, RemoteName, Reporter, SourceRepo, StepKind,
10};
11
12pub enum AddCheckout {
13 CheckoutExisting {
14 target_branch: Option<BranchName>,
15 },
16 NewBranch {
17 name: BranchName,
18 target_branch: Option<BranchName>,
19 },
20}
21
22pub struct AddOptions {
23 pub destination: PathBuf,
24 pub checkout: AddCheckout,
25 pub remote_name: RemoteName,
26}
27
28pub fn run(
29 source: &SourceRepo,
30 opts: AddOptions,
31 reporter: &mut dyn Reporter,
32) -> OutpostResult<Outpost> {
33 let AddOptions {
34 destination,
35 checkout,
36 remote_name,
37 } = opts;
38 let destination = resolve_destination(source, &destination)?;
39 check_destination_clean(&destination)?;
40
41 let branch = resolve_existing_branch(source, &checkout)?;
42
43 source.git().run_check([
44 OsString::from("-c"),
45 OsString::from("protocol.file.allow=user"),
46 OsString::from("clone"),
47 OsString::from("--no-shared"),
48 OsString::from("--"),
49 source.work_tree().as_os_str().to_os_string(),
50 destination.as_os_str().to_os_string(),
51 ])?;
52
53 let outpost_git = crate::source_repo::invoker_at(&destination, source.env());
54 if remote_name.as_str() != "origin" {
55 outpost_git.run_check(["remote", "rename", "origin", remote_name.as_str()])?;
56 }
57 apply_checkout(source, &outpost_git, &checkout, &branch, &remote_name)?;
58 Metadata {
59 source_repo: source.work_tree().to_path_buf(),
60 remote_name: remote_name.clone(),
61 }
62 .write(&outpost_git)?;
63
64 reporter.step(
65 StepKind::ConfigChange,
66 &format!(
67 "configuring source {}: receive.denyCurrentBranch=updateInstead",
68 source.work_tree().display()
69 ),
70 );
71 source.git().run_check([
72 "config",
73 "--local",
74 "receive.denyCurrentBranch",
75 "updateInstead",
76 ])?;
77
78 let mut registry = source.registry_mut()?;
79 registry.add(RegistryEntry::new(destination.clone(), remote_name)?)?;
80 registry.save()?;
81
82 source.outpost_at(&destination)
83}
84
85fn resolve_destination(source: &SourceRepo, destination: &Path) -> OutpostResult<PathBuf> {
86 let anchored = if destination.is_absolute() {
87 destination.to_path_buf()
88 } else {
89 source.work_tree().join(destination)
90 };
91 let (parent, name) = destination_parent_and_name(&anchored)?;
92 let parent = std::fs::canonicalize(&parent).map_err(|source| OutpostError::IoAt {
93 path: parent.clone(),
94 source,
95 })?;
96
97 Ok(parent.join(name))
98}
99
100fn resolve_existing_branch(
101 source: &SourceRepo,
102 checkout: &AddCheckout,
103) -> OutpostResult<BranchName> {
104 match checkout {
105 AddCheckout::CheckoutExisting { target_branch } => {
106 resolve_target_branch(source, target_branch)
107 }
108 AddCheckout::NewBranch { target_branch, .. } => {
109 resolve_target_branch(source, target_branch)
110 }
111 }
112}
113
114fn resolve_target_branch(
115 source: &SourceRepo,
116 target_branch: &Option<BranchName>,
117) -> OutpostResult<BranchName> {
118 match target_branch {
119 Some(branch) => {
120 require_branch_exists(source, branch)?;
121 Ok(branch.clone())
122 }
123 None => {
124 let branch = source.current_branch()?;
125 if source.branch_exists(&branch)? {
126 Ok(branch)
127 } else {
128 Err(OutpostError::BranchNotFound {
129 branch: "HEAD".to_owned(),
130 repo: source.work_tree().to_path_buf(),
131 })
132 }
133 }
134 }
135}
136
137fn require_branch_exists(source: &SourceRepo, branch: &BranchName) -> OutpostResult<()> {
138 if source.branch_exists(branch)? {
139 Ok(())
140 } else {
141 Err(OutpostError::BranchNotFound {
142 branch: branch.as_str().to_owned(),
143 repo: source.work_tree().to_path_buf(),
144 })
145 }
146}
147
148fn check_destination_clean(destination: &Path) -> OutpostResult<()> {
149 let (parent, name) = destination_parent_and_name(destination)?;
150 safety::check_destination_clean(&parent, &name).map_err(|err| match err {
151 OutpostError::DestinationExists(_) => {
152 OutpostError::DestinationExists(destination.to_path_buf())
153 }
154 OutpostError::DestinationInsideRepo(_) => {
155 OutpostError::DestinationInsideRepo(destination.to_path_buf())
156 }
157 other => other,
158 })
159}
160
161fn destination_parent_and_name(destination: &Path) -> OutpostResult<(PathBuf, PathBuf)> {
162 let parent = destination
163 .parent()
164 .filter(|path| !path.as_os_str().is_empty())
165 .map(PathBuf::from)
166 .unwrap_or_else(|| PathBuf::from("."));
167 let name = destination.file_name().ok_or_else(|| OutpostError::IoAt {
168 path: destination.to_path_buf(),
169 source: io::Error::new(
170 io::ErrorKind::InvalidInput,
171 "destination path has no file name",
172 ),
173 })?;
174
175 Ok((parent, PathBuf::from(name)))
176}
177
178fn apply_checkout(
179 source: &SourceRepo,
180 git: &crate::GitInvoker,
181 checkout: &AddCheckout,
182 target_branch: &BranchName,
183 remote_name: &RemoteName,
184) -> OutpostResult<()> {
185 match checkout {
186 AddCheckout::CheckoutExisting { .. } => git.run_check(["switch", target_branch.as_str()]),
187 AddCheckout::NewBranch { name, .. } => {
188 source
189 .git()
190 .run_check(["branch", name.as_str(), target_branch.as_str()])?;
191 let remote_tracking_ref =
192 format!("refs/remotes/{}/{}", remote_name.as_str(), name.as_str());
193 let fetch_refspec = format!("{}:{remote_tracking_ref}", name.as_str());
194 let remote_branch = format!("{}/{}", remote_name.as_str(), name.as_str());
195 git.run_check(["fetch", remote_name.as_str(), &fetch_refspec])?;
196 git.run_check(["switch", "--track", &remote_branch])
197 }
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 #[test]
206 fn destination_parent_and_name_splits_bare_relative_path() {
207 let (parent, name) =
208 destination_parent_and_name(Path::new("outpost")).expect("split destination");
209
210 assert_eq!(parent, PathBuf::from("."));
211 assert_eq!(name, PathBuf::from("outpost"));
212 }
213
214 #[test]
215 fn destination_parent_and_name_splits_nested_relative_path() {
216 let (parent, name) =
217 destination_parent_and_name(Path::new("nested/outpost")).expect("split destination");
218
219 assert_eq!(parent, PathBuf::from("nested"));
220 assert_eq!(name, PathBuf::from("outpost"));
221 }
222}