Skip to main content

ito_core/
git_remote.rs

1//! Git remote URL resolution for org/repo namespace discovery.
2//!
3//! Provides helpers to determine the `(org, repo)` pair for the current
4//! project by consulting config first and falling back to the `origin` remote
5//! URL when config is incomplete.
6
7use std::path::Path;
8
9use ito_config::types::BackendApiConfig;
10
11use crate::errors::{CoreError, CoreResult};
12use crate::process::{ProcessRequest, ProcessRunner, SystemProcessRunner};
13
14/// Resolve the `(org, repo)` pair for the current project.
15///
16/// Resolution order:
17///
18/// 1. `backend.project.org` / `backend.project.repo` from the supplied config
19///    (both must be non-empty for this source to be used).
20/// 2. Parse the `origin` remote URL via `git remote get-url origin` run in
21///    `repo_root`, delegating URL parsing to
22///    [`ito_common::git_url::parse_remote_url_org_repo`].
23///
24/// Returns `None` when neither source yields a complete pair (e.g., no config
25/// values and no `origin` remote, or the remote URL is in an unrecognised
26/// format).
27pub fn resolve_org_repo_from_config_or_remote(
28    repo_root: &Path,
29    config: &BackendApiConfig,
30) -> Option<(String, String)> {
31    let runner = SystemProcessRunner;
32    resolve_org_repo_from_config_or_remote_with_runner(&runner, repo_root, config)
33}
34
35/// Testable inner implementation that accepts an injected [`ProcessRunner`].
36pub(crate) fn resolve_org_repo_from_config_or_remote_with_runner(
37    runner: &dyn ProcessRunner,
38    repo_root: &Path,
39    config: &BackendApiConfig,
40) -> Option<(String, String)> {
41    // 1. Config takes priority when both fields are present and non-empty.
42    let config_org = config
43        .project
44        .org
45        .as_deref()
46        .filter(|s| !s.is_empty())
47        .map(String::from);
48    let config_repo = config
49        .project
50        .repo
51        .as_deref()
52        .filter(|s| !s.is_empty())
53        .map(String::from);
54
55    if let (Some(org), Some(repo)) = (config_org, config_repo) {
56        return Some((org, repo));
57    }
58
59    // 2. Fall back to parsing the `origin` remote URL.
60    let url = get_origin_remote_url(runner, repo_root)?;
61    ito_common::git_url::parse_remote_url_org_repo(&url)
62}
63
64/// Parse `<org>/<repo>` from a git remote URL.
65///
66/// This is a thin re-export of
67/// [`ito_common::git_url::parse_remote_url_org_repo`] for callers that already
68/// depend on `ito-core` and do not want to add a direct `ito-common` dependency.
69///
70/// See the `ito_common::git_url` module for the full list of supported URL
71/// formats and edge-case behaviour.
72pub fn parse_remote_url_org_repo(url: &str) -> Option<(String, String)> {
73    ito_common::git_url::parse_remote_url_org_repo(url)
74}
75
76/// Attempt to resolve `(org, repo)` from the `origin` remote URL only.
77///
78/// Runs `git remote get-url origin` in `repo_root` and parses the result.
79/// Returns `Ok(Some((org, repo)))` on success, `Ok(None)` when no origin is
80/// configured or the URL format is not recognised, and `Err` only on process
81/// execution failure.
82pub fn resolve_org_repo_from_remote(repo_root: &Path) -> CoreResult<Option<(String, String)>> {
83    let runner = SystemProcessRunner;
84    let request = ProcessRequest::new("git")
85        .args(["remote", "get-url", "origin"])
86        .current_dir(repo_root);
87    let output = runner
88        .run(&request)
89        .map_err(|e| CoreError::process(format!("git remote get-url origin failed: {e}")))?;
90    if !output.success {
91        return Ok(None);
92    }
93    let url = output.stdout.trim().to_string();
94    Ok(ito_common::git_url::parse_remote_url_org_repo(&url))
95}
96
97/// Run `git remote get-url origin` in `repo_root` and return the trimmed URL.
98///
99/// Returns `None` when the command fails or produces no output (e.g., no
100/// `origin` remote is configured).
101fn get_origin_remote_url(runner: &dyn ProcessRunner, repo_root: &Path) -> Option<String> {
102    let request = ProcessRequest::new("git")
103        .args(["remote", "get-url", "origin"])
104        .current_dir(repo_root);
105    let output = runner.run(&request).ok()?;
106    if !output.success {
107        return None;
108    }
109    let url = output.stdout.trim().to_string();
110    if url.is_empty() {
111        return None;
112    }
113    Some(url)
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::process::{ProcessExecutionError, ProcessOutput};
120    use ito_config::types::BackendProjectConfig;
121    use std::cell::RefCell;
122    use std::collections::VecDeque;
123
124    struct StubRunner {
125        outputs: RefCell<VecDeque<Result<ProcessOutput, ProcessExecutionError>>>,
126    }
127
128    impl StubRunner {
129        fn with_outputs(outputs: Vec<Result<ProcessOutput, ProcessExecutionError>>) -> Self {
130            Self {
131                outputs: RefCell::new(outputs.into()),
132            }
133        }
134    }
135
136    impl ProcessRunner for StubRunner {
137        fn run(&self, _request: &ProcessRequest) -> Result<ProcessOutput, ProcessExecutionError> {
138            self.outputs
139                .borrow_mut()
140                .pop_front()
141                .expect("expected process output")
142        }
143
144        fn run_with_timeout(
145            &self,
146            _request: &ProcessRequest,
147            _timeout: std::time::Duration,
148        ) -> Result<ProcessOutput, ProcessExecutionError> {
149            unreachable!("not used")
150        }
151    }
152
153    fn ok_output(stdout: &str) -> ProcessOutput {
154        ProcessOutput {
155            exit_code: 0,
156            success: true,
157            stdout: stdout.to_string(),
158            stderr: String::new(),
159            timed_out: false,
160        }
161    }
162
163    fn err_output(stderr: &str) -> ProcessOutput {
164        ProcessOutput {
165            exit_code: 1,
166            success: false,
167            stdout: String::new(),
168            stderr: stderr.to_string(),
169            timed_out: false,
170        }
171    }
172
173    fn config_with_project(org: &str, repo: &str) -> BackendApiConfig {
174        BackendApiConfig {
175            project: BackendProjectConfig {
176                org: Some(org.to_string()),
177                repo: Some(repo.to_string()),
178            },
179            ..BackendApiConfig::default()
180        }
181    }
182
183    // ── Config-first resolution ───────────────────────────────────────────────
184
185    #[test]
186    fn returns_config_values_when_both_set() {
187        let config = config_with_project("acme", "widget");
188        // Runner must not be called when config provides both values.
189        let runner = StubRunner::with_outputs(vec![]);
190        let result = resolve_org_repo_from_config_or_remote_with_runner(
191            &runner,
192            std::env::temp_dir().as_path(),
193            &config,
194        );
195        assert_eq!(result, Some(("acme".to_string(), "widget".to_string())));
196    }
197
198    #[test]
199    fn falls_back_to_remote_when_config_org_missing() {
200        let config = BackendApiConfig {
201            project: BackendProjectConfig {
202                org: None,
203                repo: Some("widget".to_string()),
204            },
205            ..BackendApiConfig::default()
206        };
207        let runner =
208            StubRunner::with_outputs(vec![Ok(ok_output("git@github.com:acme/widget.git\n"))]);
209        let result = resolve_org_repo_from_config_or_remote_with_runner(
210            &runner,
211            std::env::temp_dir().as_path(),
212            &config,
213        );
214        assert_eq!(result, Some(("acme".to_string(), "widget".to_string())));
215    }
216
217    #[test]
218    fn falls_back_to_remote_when_config_repo_missing() {
219        let config = BackendApiConfig {
220            project: BackendProjectConfig {
221                org: Some("acme".to_string()),
222                repo: None,
223            },
224            ..BackendApiConfig::default()
225        };
226        let runner =
227            StubRunner::with_outputs(vec![Ok(ok_output("https://github.com/acme/widget.git\n"))]);
228        let result = resolve_org_repo_from_config_or_remote_with_runner(
229            &runner,
230            std::env::temp_dir().as_path(),
231            &config,
232        );
233        assert_eq!(result, Some(("acme".to_string(), "widget".to_string())));
234    }
235
236    #[test]
237    fn falls_back_to_remote_when_config_empty() {
238        let config = BackendApiConfig::default();
239        let runner =
240            StubRunner::with_outputs(vec![Ok(ok_output("git@github.com:withakay/ito.git\n"))]);
241        let result = resolve_org_repo_from_config_or_remote_with_runner(
242            &runner,
243            std::env::temp_dir().as_path(),
244            &config,
245        );
246        assert_eq!(result, Some(("withakay".to_string(), "ito".to_string())));
247    }
248
249    #[test]
250    fn ignores_empty_config_strings_and_falls_back_to_remote() {
251        let config = BackendApiConfig {
252            project: BackendProjectConfig {
253                org: Some("".to_string()),
254                repo: Some("".to_string()),
255            },
256            ..BackendApiConfig::default()
257        };
258        let runner =
259            StubRunner::with_outputs(vec![Ok(ok_output("https://github.com/acme/widget.git\n"))]);
260        let result = resolve_org_repo_from_config_or_remote_with_runner(
261            &runner,
262            std::env::temp_dir().as_path(),
263            &config,
264        );
265        assert_eq!(result, Some(("acme".to_string(), "widget".to_string())));
266    }
267
268    // ── Remote-command failure paths ──────────────────────────────────────────
269
270    #[test]
271    fn returns_none_when_remote_command_fails() {
272        let config = BackendApiConfig::default();
273        let runner =
274            StubRunner::with_outputs(vec![Ok(err_output("fatal: No such remote 'origin'"))]);
275        let result = resolve_org_repo_from_config_or_remote_with_runner(
276            &runner,
277            std::env::temp_dir().as_path(),
278            &config,
279        );
280        assert_eq!(result, None);
281    }
282
283    #[test]
284    fn returns_none_when_remote_url_unrecognised() {
285        let config = BackendApiConfig::default();
286        // A URL with only one path component — cannot extract org/repo.
287        let runner = StubRunner::with_outputs(vec![Ok(ok_output("https://github.com/onlyone\n"))]);
288        let result = resolve_org_repo_from_config_or_remote_with_runner(
289            &runner,
290            std::env::temp_dir().as_path(),
291            &config,
292        );
293        assert_eq!(result, None);
294    }
295
296    #[test]
297    fn returns_none_when_remote_output_is_empty() {
298        let config = BackendApiConfig::default();
299        let runner = StubRunner::with_outputs(vec![Ok(ok_output(""))]);
300        let result = resolve_org_repo_from_config_or_remote_with_runner(
301            &runner,
302            std::env::temp_dir().as_path(),
303            &config,
304        );
305        assert_eq!(result, None);
306    }
307
308    // ── parse_remote_url_org_repo re-export ───────────────────────────────────
309
310    #[test]
311    fn reexport_delegates_to_common_parser() {
312        assert_eq!(
313            parse_remote_url_org_repo("git@github.com:withakay/ito.git"),
314            Some(("withakay".to_string(), "ito".to_string()))
315        );
316        assert_eq!(parse_remote_url_org_repo(""), None);
317    }
318}