Skip to main content

xianyun_backends/git/
state.rs

1//! Repository state detection helpers.
2//!
3//! Ported from `internal/backends/git/state.go`. The `Client` methods
4//! declared by `Backend` (`get_current_branch`, `is_detached`,
5//! `get_tracking_branch`, `get_default_branch`, `get_commit_counts`,
6//! `merge_ff_only`, `fetch`, `checkout`, `stash_push`, `detect_state`) are
7//! implemented in `mod.rs` alongside the rest of `Client`, matching how
8//! Go splits one `Client` type's methods across `client.go`/`state.go`.
9//! This file carries `normalize_git_url`, the private helper Go's
10//! `DetectState` calls, plus a `has_uncommitted_changes` helper that
11//! exists in Go but is unused by the `Backend` interface (kept for
12//! parity -- `GetStatus` computes the same fact inline).
13
14/// Ported from `normalizeGitURL`. Extracts "host/org/repo" from a Git URL.
15/// The host is kept because different hosts with the same path are
16/// different repos.
17pub(crate) fn normalize_git_url(raw: &str) -> String {
18    let mut s = raw.trim().to_string();
19
20    if let Some(stripped) = s.strip_suffix('/') {
21        s = stripped.to_string();
22    }
23
24    if let Some(stripped) = s.strip_suffix(".git") {
25        s = stripped.to_string();
26    }
27
28    // scp-style "git@host:org/repo" is not a valid URI; rewrite to ssh://.
29    if !s.contains("://")
30        && s.contains('@')
31        && let Some(colon) = s.find(':')
32    {
33        let mut rewritten = s.clone();
34        rewritten.replace_range(colon..=colon, "/");
35        s = format!("ssh://{rewritten}");
36    }
37
38    match url::Url::parse(&s) {
39        Ok(url) if url.host_str().is_some_and(|host| !host.is_empty()) => {
40            let host = url.host_str().unwrap_or_default();
41            let path = url.path().trim_start_matches('/');
42            format!("{host}/{path}").to_lowercase()
43        }
44        _ => s.to_lowercase(),
45    }
46}
47
48#[cfg(test)]
49mod tests {
50    use super::*;
51
52    // --- Ported from state_test.go: TestNormalizeGitURL ---
53
54    struct NormalizeCase {
55        name: &'static str,
56        url: &'static str,
57        want: &'static str,
58    }
59
60    #[test]
61    fn normalize_git_url_cases() {
62        let cases = [
63            NormalizeCase {
64                name: "https with .git suffix",
65                url: "https://gitlab.com/gitlab-org/gitlab.git",
66                want: "gitlab.com/gitlab-org/gitlab",
67            },
68            NormalizeCase {
69                name: "https without .git suffix",
70                url: "https://gitlab.com/gitlab-org/gitlab",
71                want: "gitlab.com/gitlab-org/gitlab",
72            },
73            NormalizeCase {
74                name: "https with trailing slash",
75                url: "https://gitlab.com/gitlab-org/gitlab/",
76                want: "gitlab.com/gitlab-org/gitlab",
77            },
78            NormalizeCase {
79                name: "scp-style ssh",
80                url: "git@gitlab.com:gitlab-org/gitlab.git",
81                want: "gitlab.com/gitlab-org/gitlab",
82            },
83            NormalizeCase {
84                name: "ssh protocol",
85                url: "ssh://git@gitlab.com/gitlab-org/gitlab.git",
86                want: "gitlab.com/gitlab-org/gitlab",
87            },
88            NormalizeCase {
89                name: "uppercase normalized to lowercase",
90                url: "https://GitLab.com/GitLab-Org/GitLab.git",
91                want: "gitlab.com/gitlab-org/gitlab",
92            },
93            NormalizeCase {
94                name: "host with port",
95                url: "https://gitlab.example.com:8080/gitlab-org/gitlab.git",
96                want: "gitlab.example.com/gitlab-org/gitlab",
97            },
98            NormalizeCase {
99                name: "whitespace trimmed",
100                url: "  https://gitlab.com/gitlab-org/gitlab.git  ",
101                want: "gitlab.com/gitlab-org/gitlab",
102            },
103            NormalizeCase {
104                name: "authenticated https drops credentials",
105                url: "https://user:abc@gitlab.com/gitlab-org/gitlab.git",
106                want: "gitlab.com/gitlab-org/gitlab",
107            },
108            NormalizeCase {
109                name: "different hosts produce different output",
110                url: "https://github.com/gitlab-org/gitlab.git",
111                want: "github.com/gitlab-org/gitlab",
112            },
113        ];
114
115        for case in cases {
116            assert_eq!(normalize_git_url(case.url), case.want, "{}", case.name);
117        }
118    }
119
120    // --- Ported from state_test.go: TestNormalizeGitURL_SameRepoMatches ---
121
122    struct SameRepoCase {
123        name: &'static str,
124        a: &'static str,
125        b: &'static str,
126        want_match: bool,
127    }
128
129    #[test]
130    fn normalize_git_url_same_repo_matches_cases() {
131        let cases = [
132            SameRepoCase {
133                name: "https configured, ssh local",
134                a: "https://gitlab.com/gitlab-org/gitlab.git",
135                b: "git@gitlab.com:gitlab-org/gitlab.git",
136                want_match: true,
137            },
138            SameRepoCase {
139                name: "trailing slash vs none",
140                a: "https://gitlab.com/gitlab-org/gitlab/",
141                b: "https://gitlab.com/gitlab-org/gitlab",
142                want_match: true,
143            },
144            SameRepoCase {
145                name: ".git suffix vs none",
146                a: "https://gitlab.com/gitlab-org/gitlab.git",
147                b: "https://gitlab.com/gitlab-org/gitlab",
148                want_match: true,
149            },
150            SameRepoCase {
151                name: "different repos do not match",
152                a: "https://gitlab.com/gitlab-org/gitlab.git",
153                b: "https://gitlab.com/gitlab-org/other.git",
154                want_match: false,
155            },
156            SameRepoCase {
157                name: "same path on different hosts does not match",
158                a: "https://gitlab.com/gitlab-org/gitlab.git",
159                b: "https://github.com/gitlab-org/gitlab.git",
160                want_match: false,
161            },
162            SameRepoCase {
163                name: "authenticated and unauthenticated https match",
164                a: "https://user:abc@gitlab.com/gitlab-org/gitlab.git",
165                b: "https://gitlab.com/gitlab-org/gitlab.git",
166                want_match: true,
167            },
168        ];
169
170        for case in cases {
171            assert_eq!(
172                normalize_git_url(case.a) == normalize_git_url(case.b),
173                case.want_match,
174                "{}",
175                case.name
176            );
177        }
178    }
179
180    // --- Ported from state_integration_test.go (build-tagged
181    // `git_integration_test` in Go; requires a real `git` binary on
182    // PATH). Go's originals cloned `https://github.com/git/git` for the
183    // "clean repository"/"uncommitted changes" cases; this port
184    // exercises the identical `Client::detect_state` code path against a
185    // local `file://` remote instead, so the behaviour under test (real
186    // `git` subprocess output, not a mock) is unchanged while removing
187    // the network dependency entirely. See PORT_NOTES.md. ---
188
189    use std::process::Command;
190    use std::sync::Arc;
191
192    use crate::git::{Backend, Client};
193
194    fn git_in(dir: &std::path::Path, args: &[&str]) {
195        let status = Command::new("git")
196            .arg("-C")
197            .arg(dir)
198            .args(args)
199            .status()
200            .expect("git binary must be on PATH");
201        assert!(status.success(), "git {args:?} failed in {dir:?}");
202    }
203
204    fn init_repo_with_commit(dir: &std::path::Path) {
205        std::fs::create_dir_all(dir).expect("create repo directory");
206        git_in(dir, &["init", "--quiet", "--initial-branch=main"]);
207        git_in(dir, &["config", "user.email", "test@example.com"]);
208        git_in(dir, &["config", "user.name", "Test User"]);
209        std::fs::write(dir.join("README.md"), "hello\n").expect("write seed file");
210        git_in(dir, &["add", "README.md"]);
211        git_in(dir, &["commit", "--quiet", "-m", "initial commit"]);
212    }
213
214    fn client() -> Client {
215        Client::new(Arc::new(xianyun_executor::exec::Client::new()))
216    }
217
218    #[test]
219    fn detect_state_non_existent_directory() {
220        let temp = tempfile::tempdir().expect("create tempdir");
221        let missing = temp.path().join("does-not-exist");
222
223        let state = client()
224            .detect_state(missing.to_str().unwrap(), "")
225            .expect("detect_state is fail-safe and never errors");
226
227        assert!(!state.exists);
228        assert!(!state.is_git_repo);
229        assert!(state.last_error.is_none());
230    }
231
232    #[test]
233    fn detect_state_not_git_repository() {
234        let temp = tempfile::tempdir().expect("create tempdir");
235        std::fs::write(temp.path().join("plain.txt"), "not a repo\n").expect("write plain file");
236
237        let state = client()
238            .detect_state(temp.path().to_str().unwrap(), "")
239            .expect("detect_state is fail-safe and never errors");
240
241        assert!(state.exists);
242        assert!(!state.is_git_repo);
243    }
244
245    #[test]
246    fn detect_state_clean_git_repository() {
247        let temp = tempfile::tempdir().expect("create tempdir");
248        let remote_dir = temp.path().join("remote");
249        init_repo_with_commit(&remote_dir);
250
251        let clone_dir = temp.path().join("clone");
252        let remote_url = format!("file://{}", remote_dir.display());
253        git_in(
254            temp.path(),
255            &["clone", "--quiet", &remote_url, clone_dir.to_str().unwrap()],
256        );
257
258        let state = client()
259            .detect_state(clone_dir.to_str().unwrap(), &remote_url)
260            .expect("detect_state is fail-safe and never errors");
261
262        assert!(state.exists);
263        assert!(state.is_git_repo);
264        assert!(!state.has_uncommitted);
265        assert!(state.remote_matches);
266        assert!(!state.remote_url.is_empty());
267        assert!(state.last_error.is_none());
268    }
269
270    #[test]
271    fn detect_state_uncommitted_changes() {
272        let temp = tempfile::tempdir().expect("create tempdir");
273        let remote_dir = temp.path().join("remote");
274        init_repo_with_commit(&remote_dir);
275
276        let clone_dir = temp.path().join("clone");
277        let remote_url = format!("file://{}", remote_dir.display());
278        git_in(
279            temp.path(),
280            &["clone", "--quiet", &remote_url, clone_dir.to_str().unwrap()],
281        );
282
283        std::fs::write(clone_dir.join("README.md"), "modified\n").expect("modify tracked file");
284
285        let state = client()
286            .detect_state(clone_dir.to_str().unwrap(), &remote_url)
287            .expect("detect_state is fail-safe and never errors");
288
289        assert!(state.exists);
290        assert!(state.is_git_repo);
291        assert!(state.has_uncommitted);
292    }
293
294    #[test]
295    fn checkout_pins_to_commit() {
296        let temp = tempfile::tempdir().expect("create tempdir");
297        let repo_dir = temp.path().join("repo");
298        init_repo_with_commit(&repo_dir);
299
300        let first_commit = client()
301            .get_revision(repo_dir.to_str().unwrap())
302            .expect("get first revision");
303
304        std::fs::write(repo_dir.join("README.md"), "second\n").expect("write second revision");
305        git_in(&repo_dir, &["add", "README.md"]);
306        git_in(&repo_dir, &["commit", "--quiet", "-m", "second commit"]);
307
308        client()
309            .checkout(repo_dir.to_str().unwrap(), &first_commit)
310            .expect("checkout the pinned commit");
311
312        let revision_after_checkout = client()
313            .get_revision(repo_dir.to_str().unwrap())
314            .expect("get revision after checkout");
315
316        assert_eq!(revision_after_checkout, first_commit);
317
318        let contents =
319            std::fs::read_to_string(repo_dir.join("README.md")).expect("read checked-out file");
320        assert_eq!(contents, "hello\n");
321    }
322}