1use std::path::{Path, PathBuf};
2
3use crate::{GitInvoker, OutpostError, OutpostResult, RemoteName};
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct RawMetadata {
7 pub managed: Option<bool>,
8 pub source_repo: Option<PathBuf>,
9 pub remote_name: Option<RemoteName>,
10}
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct Metadata {
14 pub source_repo: PathBuf,
15 pub remote_name: RemoteName,
16}
17
18impl RawMetadata {
19 pub fn read(git: &GitInvoker) -> OutpostResult<Self> {
20 let managed = match read_optional_config(git, "outpost.managed")? {
21 Some(value) => {
22 Some(
23 parse_git_bool(&value).ok_or_else(|| OutpostError::BadMetadata {
24 outpost: git.cwd().to_path_buf(),
25 reason: format!("invalid outpost.managed value: {value}"),
26 })?,
27 )
28 }
29 None => None,
30 };
31 let source_repo = read_optional_config(git, "outpost.sourceRepo")?.map(PathBuf::from);
32 let remote_name = read_optional_config(git, "outpost.remoteName")?
33 .map(RemoteName::parse)
34 .transpose()?;
35
36 Ok(Self {
37 managed,
38 source_repo,
39 remote_name,
40 })
41 }
42}
43
44impl Metadata {
45 pub fn from_raw(outpost: &Path, raw: RawMetadata) -> OutpostResult<Self> {
46 if raw.managed != Some(true) {
47 return Err(OutpostError::NotAnOutpost(outpost.to_path_buf()));
48 }
49
50 let source_repo = raw.source_repo.ok_or_else(|| OutpostError::BadMetadata {
51 outpost: outpost.to_path_buf(),
52 reason: "missing outpost.sourceRepo".to_owned(),
53 })?;
54 let remote_name = raw.remote_name.ok_or_else(|| OutpostError::BadMetadata {
55 outpost: outpost.to_path_buf(),
56 reason: "missing outpost.remoteName".to_owned(),
57 })?;
58
59 Ok(Self {
60 source_repo,
61 remote_name,
62 })
63 }
64
65 pub fn write(&self, git: &GitInvoker) -> OutpostResult<()> {
66 let source_repo =
67 std::fs::canonicalize(&self.source_repo).map_err(|source| OutpostError::IoAt {
68 path: self.source_repo.clone(),
69 source,
70 })?;
71 let source_repo = source_repo.to_string_lossy().into_owned();
72
73 git.run_check(["config", "--local", "outpost.managed", "true"])?;
74 git.run_check(["config", "--local", "outpost.sourceRepo", &source_repo])?;
75 git.run_check([
76 "config",
77 "--local",
78 "outpost.remoteName",
79 self.remote_name.as_str(),
80 ])
81 }
82}
83
84fn read_optional_config(git: &GitInvoker, key: &str) -> OutpostResult<Option<String>> {
85 if git.run_status(["config", "--local", "--get", key])? {
86 git.run_capture(["config", "--local", "--get", key])
87 .map(Some)
88 } else {
89 Ok(None)
90 }
91}
92
93fn parse_git_bool(value: &str) -> Option<bool> {
94 match value.trim().to_ascii_lowercase().as_str() {
95 "true" | "yes" | "on" | "1" => Some(true),
96 "false" | "no" | "off" | "0" => Some(false),
97 _ => None,
98 }
99}
100
101#[cfg(test)]
102mod tests {
103 use std::collections::BTreeMap;
104 use std::ffi::OsString;
105 use std::fs;
106 use std::path::Path;
107
108 use super::*;
109
110 #[test]
111 fn metadata_write_sets_local_outpost_config_keys() {
112 let temp = tempfile::tempdir().expect("tempdir");
113 let outpost = temp.path().join("outpost");
114 let source = temp.path().join("source");
115 init_repo(&outpost);
116 init_repo(&source);
117
118 let metadata = Metadata {
119 source_repo: source.clone(),
120 remote_name: RemoteName::parse("local").expect("remote parses"),
121 };
122 let git = GitInvoker::at(&outpost);
123
124 metadata.write(&git).expect("metadata writes");
125
126 assert_eq!(
127 git.run_capture(["config", "--local", "--get", "outpost.managed"])
128 .expect("managed key"),
129 "true"
130 );
131 assert_eq!(
132 git.run_capture(["config", "--local", "--get", "outpost.sourceRepo"])
133 .expect("source key"),
134 fs::canonicalize(&source)
135 .expect("canonical source")
136 .to_string_lossy()
137 );
138 assert_eq!(
139 git.run_capture(["config", "--local", "--get", "outpost.remoteName"])
140 .expect("remote key"),
141 "local"
142 );
143 }
144
145 #[test]
146 fn raw_metadata_on_non_managed_repo_promotes_to_not_an_outpost() {
147 let temp = tempfile::tempdir().expect("tempdir");
148 init_repo(temp.path());
149 let raw = RawMetadata::read(&GitInvoker::at(temp.path())).expect("read raw metadata");
150
151 assert_eq!(raw.managed, None);
152 assert!(matches!(
153 Metadata::from_raw(temp.path(), raw),
154 Err(OutpostError::NotAnOutpost(path)) if path == temp.path()
155 ));
156
157 let raw_false = RawMetadata {
158 managed: Some(false),
159 source_repo: None,
160 remote_name: None,
161 };
162 assert!(matches!(
163 Metadata::from_raw(temp.path(), raw_false),
164 Err(OutpostError::NotAnOutpost(path)) if path == temp.path()
165 ));
166 }
167
168 #[test]
169 fn raw_metadata_read_ignores_global_outpost_managed_config() {
170 let temp = tempfile::tempdir().expect("tempdir");
171 let repo = temp.path().join("repo");
172 let global = temp.path().join("global.gitconfig");
173 init_repo(&repo);
174 fs::write(&global, "[outpost]\n\tmanaged = true\n").expect("write global config");
175
176 let env = BTreeMap::from([(
177 OsString::from("GIT_CONFIG_GLOBAL"),
178 global.as_os_str().to_os_string(),
179 )]);
180 let git = env.iter().fold(GitInvoker::at(&repo), |git, (key, val)| {
181 git.with_env(key.clone(), val.clone())
182 });
183
184 let raw = RawMetadata::read(&git).expect("read raw metadata");
185 assert_eq!(raw.managed, None);
186 }
187
188 fn init_repo(path: &Path) {
189 fs::create_dir_all(path).expect("create repo dir");
190 GitInvoker::at(path)
191 .run_check(["init", "--initial-branch=main"])
192 .expect("init repo");
193 }
194}