posthog_cli/utils/
git.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::fs;
4use std::io::Read;
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct GitInfo {
9    #[serde(skip_serializing_if = "Option::is_none")]
10    pub remote_url: Option<String>,
11    #[serde(skip_serializing_if = "Option::is_none")]
12    pub repo_name: Option<String>,
13    pub branch: String,
14    pub commit_id: String,
15}
16
17pub fn get_git_info(dir: Option<PathBuf>) -> Result<Option<GitInfo>> {
18    let git_dir = match find_git_dir(dir) {
19        Some(dir) => dir,
20        None => return Ok(None),
21    };
22
23    let remote_url = get_remote_url(&git_dir);
24    let repo_name = get_repo_name(&git_dir);
25    let branch = get_current_branch(&git_dir).context("Failed to determine current branch")?;
26    let commit = get_head_commit(&git_dir, &branch).context("Failed to determine commit ID")?;
27
28    Ok(Some(GitInfo {
29        remote_url,
30        repo_name,
31        branch,
32        commit_id: commit,
33    }))
34}
35
36fn find_git_dir(dir: Option<PathBuf>) -> Option<PathBuf> {
37    let mut current_dir = dir.unwrap_or(std::env::current_dir().ok()?);
38
39    loop {
40        let git_dir = current_dir.join(".git");
41        if git_dir.is_dir() {
42            return Some(git_dir);
43        }
44
45        if !current_dir.pop() {
46            return None;
47        }
48    }
49}
50
51pub fn get_remote_url(git_dir: &Path) -> Option<String> {
52    // Try grab it from the git config
53    let config_path = git_dir.join("config");
54    if config_path.exists() {
55        let config_content = match fs::read_to_string(&config_path) {
56            Ok(content) => content,
57            Err(_) => return None,
58        };
59
60        for line in config_content.lines() {
61            let line = line.trim();
62            if line.starts_with("url = ") {
63                let url = line.trim_start_matches("url = ").trim();
64                let normalized = if url.ends_with(".git") {
65                    url.to_string()
66                } else {
67                    format!("{url}.git")
68                };
69                return Some(normalized);
70            }
71        }
72    }
73
74    None
75}
76
77pub fn get_repo_name(git_dir: &Path) -> Option<String> {
78    // Try grab it from the configured remote, otherwise just use the directory name
79    let config_path = git_dir.join("config");
80    if config_path.exists() {
81        let config_content = match fs::read_to_string(&config_path) {
82            Ok(content) => content,
83            Err(_) => return None,
84        };
85
86        for line in config_content.lines() {
87            let line = line.trim();
88            if line.starts_with("url = ") {
89                let url = line.trim_start_matches("url = ");
90                if let Some(repo_name) = url.split('/').next_back() {
91                    let clean_name = repo_name.trim_end_matches(".git");
92                    return Some(clean_name.to_string());
93                }
94            }
95        }
96    }
97
98    if let Some(parent) = git_dir.parent() {
99        if let Some(name) = parent.file_name() {
100            return Some(name.to_string_lossy().to_string());
101        }
102    }
103
104    None
105}
106
107fn get_current_branch(git_dir: &Path) -> Result<String> {
108    // First try to read from HEAD file
109    let head_path = git_dir.join("HEAD");
110    let mut head_content = String::new();
111    fs::File::open(&head_path)
112        .with_context(|| format!("Failed to open HEAD file at {head_path:?}"))?
113        .read_to_string(&mut head_content)
114        .context("Failed to read HEAD file")?;
115
116    // Parse HEAD content
117    if head_content.starts_with("ref: refs/heads/") {
118        Ok(head_content
119            .trim_start_matches("ref: refs/heads/")
120            .trim()
121            .to_string())
122    } else if head_content.trim().len() == 40 || head_content.trim().len() == 64 {
123        Ok("HEAD-detached".to_string())
124    } else {
125        anyhow::bail!("Unrecognized HEAD format")
126    }
127}
128
129fn get_head_commit(git_dir: &Path, branch: &str) -> Result<String> {
130    if branch == "HEAD-detached" {
131        // For detached HEAD, read directly from HEAD
132        let head_path = git_dir.join("HEAD");
133        let mut head_content = String::new();
134        fs::File::open(&head_path)
135            .with_context(|| format!("Failed to open HEAD file at {head_path:?}"))?
136            .read_to_string(&mut head_content)
137            .context("Failed to read HEAD file")?;
138
139        return Ok(head_content.trim().to_string());
140    }
141
142    // Try to read the commit from the branch reference
143    let ref_path = git_dir.join("refs/heads").join(branch);
144    if ref_path.exists() {
145        let mut commit_id = String::new();
146        fs::File::open(&ref_path)
147            .with_context(|| format!("Failed to open branch reference at {ref_path:?}"))?
148            .read_to_string(&mut commit_id)
149            .context("Failed to read branch reference file")?;
150
151        return Ok(commit_id.trim().to_string());
152    }
153
154    anyhow::bail!("Could not determine commit ID")
155}