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