Skip to main content

lean_ctx/
git_context.rs

1use crate::models::{CheckoutBinding, ProjectContext, RepositoryFingerprint};
2use crate::project_metadata;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6pub fn discover_project_context(current_directory: &Path) -> ProjectContext {
7    let local_root = git_output(current_directory, ["rev-parse", "--show-toplevel"])
8        .map(PathBuf::from)
9        .unwrap_or_else(|| current_directory.to_path_buf());
10    let remote_url = git_output(&local_root, ["config", "--get", "remote.origin.url"]);
11    let branch = git_output(&local_root, ["rev-parse", "--abbrev-ref", "HEAD"]);
12    let last_commit = git_output(&local_root, ["rev-parse", "HEAD"]);
13    let default_branch = git_output(&local_root, ["symbolic-ref", "refs/remotes/origin/HEAD"])
14        .and_then(|value| value.rsplit('/').next().map(ToString::to_string));
15    let parsed_remote = remote_url.as_deref().and_then(parse_remote_url);
16    let project_slug = parsed_remote
17        .as_ref()
18        .map(|(_, _, repo_name)| repo_name.clone())
19        .or_else(|| local_root.file_name().map(|value| value.to_string_lossy().to_string()))
20        .unwrap_or_else(|| "project".to_string());
21
22    let fingerprint = RepositoryFingerprint {
23        remote_url,
24        host: parsed_remote.as_ref().map(|(host, _, _)| host.clone()),
25        owner: parsed_remote.as_ref().map(|(_, owner, _)| owner.clone()),
26        repo_name: parsed_remote.as_ref().map(|(_, _, repo_name)| repo_name.clone()),
27        default_branch,
28    };
29
30    let checkout_binding = CheckoutBinding {
31        project_id: "pending".to_string(),
32        local_root: Some(local_root.to_string_lossy().to_string()),
33        branch,
34        last_commit,
35        client_label: detect_client_label(),
36        last_sync: None,
37    };
38
39    ProjectContext {
40        project_slug,
41        project_root: local_root.to_string_lossy().to_string(),
42        fingerprint,
43        checkout_binding,
44        project_metadata: project_metadata::build_project_metadata(&local_root).ok(),
45    }
46}
47
48pub fn discover_repository_context(current_directory: &Path) -> ProjectContext {
49    discover_project_context(current_directory)
50}
51
52fn parse_remote_url(remote_url: &str) -> Option<(String, String, String)> {
53    let trimmed = remote_url.trim().trim_end_matches('/').trim_end_matches(".git");
54    if let Some(rest) = trimmed.strip_prefix("https://") {
55        return parse_host_path(rest);
56    }
57
58    if let Some(rest) = trimmed.strip_prefix("http://") {
59        return parse_host_path(rest);
60    }
61
62    if let Some(rest) = trimmed.strip_prefix("ssh://git@") {
63        return parse_host_path(rest);
64    }
65
66    if let Some(rest) = trimmed.strip_prefix("git@") {
67        let (host, path) = rest.split_once(':')?;
68        return parse_path_segments(host, path);
69    }
70
71    None
72}
73
74fn parse_host_path(value: &str) -> Option<(String, String, String)> {
75    let (host, path) = value.split_once('/')?;
76    parse_path_segments(host, path)
77}
78
79fn parse_path_segments(host: &str, path: &str) -> Option<(String, String, String)> {
80    let mut segments = path.split('/').filter(|segment| !segment.is_empty());
81    let owner = segments.next()?.to_string();
82    let repo_name = segments.next()?.to_string();
83    Some((host.to_string(), owner, repo_name))
84}
85
86fn git_output<const N: usize>(working_directory: &Path, args: [&str; N]) -> Option<String> {
87    let output = Command::new("git")
88        .args(args)
89        .current_dir(working_directory)
90        .output()
91        .ok()?;
92    if !output.status.success() {
93        return None;
94    }
95
96    let value = String::from_utf8(output.stdout).ok()?;
97    let trimmed = value.trim();
98    if trimmed.is_empty() {
99        return None;
100    }
101
102    Some(trimmed.to_string())
103}
104
105fn detect_client_label() -> Option<String> {
106    std::env::var("COMPUTERNAME")
107        .ok()
108        .filter(|value| !value.trim().is_empty())
109        .or_else(|| std::env::var("HOSTNAME").ok().filter(|value| !value.trim().is_empty()))
110}
111
112#[cfg(test)]
113mod tests {
114    use super::parse_remote_url;
115
116    #[test]
117    fn parse_remote_url_supports_https_and_ssh_formats() {
118        assert_eq!(
119            parse_remote_url("https://github.com/MarkBovee/nebu-ctx.git"),
120            Some(("github.com".to_string(), "MarkBovee".to_string(), "nebu-ctx".to_string()))
121        );
122
123        assert_eq!(
124            parse_remote_url("git@github.com:MarkBovee/nebu-ctx.git"),
125            Some(("github.com".to_string(), "MarkBovee".to_string(), "nebu-ctx".to_string()))
126        );
127    }
128}