Skip to content

Commit c723f8c

Browse files
unhappychoiceclaude
andcommitted
fix: improve git repository path recognition
- Add support for ssh://git@github.com/ URL format parsing - Implement git repository root detection for subdirectories - Fix relative path handling with path canonicalization - Add git repository info display on title screen - Support git info extraction from any subdirectory within a repository 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 9425bac commit c723f8c

File tree

6 files changed

+245
-4
lines changed

6 files changed

+245
-4
lines changed

src/extractor/git_info.rs

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
use std::path::Path;
2+
use std::process::Command;
3+
use serde::{Deserialize, Serialize};
4+
use crate::Result;
5+
6+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
7+
pub struct GitRepositoryInfo {
8+
pub user_name: String,
9+
pub repository_name: String,
10+
pub remote_url: String,
11+
pub branch: Option<String>,
12+
pub commit_hash: Option<String>,
13+
pub is_dirty: bool,
14+
}
15+
16+
pub struct GitInfoExtractor;
17+
18+
impl GitInfoExtractor {
19+
pub fn new() -> Self {
20+
Self
21+
}
22+
23+
pub fn extract_git_info(repo_path: &Path) -> Result<Option<GitRepositoryInfo>> {
24+
// Canonicalize the path to handle relative paths like ../../
25+
let canonical_path = match repo_path.canonicalize() {
26+
Ok(path) => path,
27+
Err(_) => {
28+
// If canonicalization fails, the path might not exist
29+
return Ok(None);
30+
}
31+
};
32+
33+
// Find git repository root (may be parent directory)
34+
let git_root = match Self::find_git_repository_root(&canonical_path) {
35+
Some(root) => root,
36+
None => return Ok(None),
37+
};
38+
39+
let remote_url = Self::get_remote_url(&git_root)?;
40+
if let Some((user_name, repository_name)) = Self::parse_remote_url(&remote_url) {
41+
let branch = Self::get_current_branch(&git_root).ok();
42+
let commit_hash = Self::get_current_commit_hash(&git_root).ok();
43+
let is_dirty = Self::is_working_directory_dirty(&git_root).unwrap_or(false);
44+
45+
Ok(Some(GitRepositoryInfo {
46+
user_name,
47+
repository_name,
48+
remote_url,
49+
branch,
50+
commit_hash,
51+
is_dirty,
52+
}))
53+
} else {
54+
Ok(None)
55+
}
56+
}
57+
58+
fn find_git_repository_root(start_path: &Path) -> Option<std::path::PathBuf> {
59+
let mut current_path = start_path;
60+
61+
loop {
62+
let git_dir = current_path.join(".git");
63+
if git_dir.exists() {
64+
return Some(current_path.to_path_buf());
65+
}
66+
67+
// Move to parent directory
68+
match current_path.parent() {
69+
Some(parent) => current_path = parent,
70+
None => return None, // Reached root directory without finding .git
71+
}
72+
}
73+
}
74+
75+
#[allow(dead_code)]
76+
fn is_git_repository(repo_path: &Path) -> bool {
77+
let git_dir = repo_path.join(".git");
78+
git_dir.exists()
79+
}
80+
81+
fn get_remote_url(repo_path: &Path) -> Result<String> {
82+
let output = Command::new("git")
83+
.current_dir(repo_path)
84+
.args(["remote", "get-url", "origin"])
85+
.output()
86+
.map_err(|e| crate::GitTypeError::IoError(e))?;
87+
88+
if !output.status.success() {
89+
return Err(crate::GitTypeError::ExtractionFailed("Failed to get remote URL".to_string()));
90+
}
91+
92+
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
93+
Ok(url)
94+
}
95+
96+
fn parse_remote_url(url: &str) -> Option<(String, String)> {
97+
// Handle HTTPS URLs like https://github.com/user/repo.git
98+
if url.starts_with("https://github.com/") {
99+
let path = url.strip_prefix("https://github.com/")?;
100+
let path = path.strip_suffix(".git").unwrap_or(path);
101+
let parts: Vec<&str> = path.split('/').collect();
102+
if parts.len() == 2 {
103+
return Some((parts[0].to_string(), parts[1].to_string()));
104+
}
105+
}
106+
107+
// Handle SSH URLs like git@github.com:user/repo.git
108+
if url.starts_with("git@github.com:") {
109+
let path = url.strip_prefix("git@github.com:")?;
110+
let path = path.strip_suffix(".git").unwrap_or(path);
111+
let parts: Vec<&str> = path.split('/').collect();
112+
if parts.len() == 2 {
113+
return Some((parts[0].to_string(), parts[1].to_string()));
114+
}
115+
}
116+
117+
// Handle SSH URLs like ssh://git@github.com/user/repo.git or ssh://git@github.com/user/repo
118+
if url.starts_with("ssh://git@github.com/") {
119+
let path = url.strip_prefix("ssh://git@github.com/")?;
120+
let path = path.strip_suffix(".git").unwrap_or(path);
121+
let parts: Vec<&str> = path.split('/').collect();
122+
if parts.len() == 2 {
123+
return Some((parts[0].to_string(), parts[1].to_string()));
124+
}
125+
}
126+
127+
None
128+
}
129+
130+
fn get_current_branch(repo_path: &Path) -> Result<String> {
131+
let output = Command::new("git")
132+
.current_dir(repo_path)
133+
.args(["branch", "--show-current"])
134+
.output()
135+
.map_err(|e| crate::GitTypeError::IoError(e))?;
136+
137+
if !output.status.success() {
138+
return Err(crate::GitTypeError::ExtractionFailed("Failed to get current branch".to_string()));
139+
}
140+
141+
let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
142+
Ok(branch)
143+
}
144+
145+
fn get_current_commit_hash(repo_path: &Path) -> Result<String> {
146+
let output = Command::new("git")
147+
.current_dir(repo_path)
148+
.args(["rev-parse", "HEAD"])
149+
.output()
150+
.map_err(|e| crate::GitTypeError::IoError(e))?;
151+
152+
if !output.status.success() {
153+
return Err(crate::GitTypeError::ExtractionFailed("Failed to get current commit hash".to_string()));
154+
}
155+
156+
let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
157+
Ok(hash)
158+
}
159+
160+
fn is_working_directory_dirty(repo_path: &Path) -> Result<bool> {
161+
let output = Command::new("git")
162+
.current_dir(repo_path)
163+
.args(["status", "--porcelain"])
164+
.output()
165+
.map_err(|e| crate::GitTypeError::IoError(e))?;
166+
167+
if !output.status.success() {
168+
return Err(crate::GitTypeError::ExtractionFailed("Failed to check working directory status".to_string()));
169+
}
170+
171+
let status = String::from_utf8_lossy(&output.stdout);
172+
Ok(!status.trim().is_empty())
173+
}
174+
}

src/extractor/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
pub mod centered_progress;
22
pub mod challenge_converter;
33
pub mod chunk;
4+
pub mod git_info;
45
pub mod language;
56
pub mod parser;
67
pub mod progress;
@@ -9,6 +10,7 @@ pub mod repository_loader;
910
pub use centered_progress::CenteredProgressReporter;
1011
pub use challenge_converter::ChallengeConverter;
1112
pub use chunk::{CodeChunk, ChunkType};
13+
pub use git_info::{GitInfoExtractor, GitRepositoryInfo};
1214
pub use language::Language;
1315
pub use parser::{CodeExtractor, ExtractionOptions};
1416
pub use progress::{ConsoleProgressReporter, NoOpProgressReporter, ProgressReporter};

src/extractor/repository_loader.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
use std::path::Path;
22
use crate::game::Challenge;
33
use crate::{Result, GitTypeError};
4-
use super::{CodeExtractor, ExtractionOptions, ChallengeConverter, ProgressReporter, NoOpProgressReporter};
4+
use super::{CodeExtractor, ExtractionOptions, ChallengeConverter, ProgressReporter, NoOpProgressReporter, GitInfoExtractor, GitRepositoryInfo};
55

66
pub struct RepositoryLoader {
77
extractor: CodeExtractor,
88
converter: ChallengeConverter,
9+
git_info: Option<GitRepositoryInfo>,
910
}
1011

1112
impl RepositoryLoader {
@@ -16,6 +17,7 @@ impl RepositoryLoader {
1617
Ok(Self {
1718
extractor,
1819
converter,
20+
git_info: None,
1921
})
2022
}
2123

@@ -37,6 +39,9 @@ impl RepositoryLoader {
3739
return Err(GitTypeError::RepositoryNotFound(repo_path.to_path_buf()));
3840
}
3941

42+
// Extract git information
43+
self.git_info = GitInfoExtractor::extract_git_info(repo_path)?;
44+
4045
let extraction_options = options.unwrap_or_default();
4146
let chunks = self.extractor.extract_chunks_with_progress(repo_path, extraction_options, progress)?;
4247

@@ -134,4 +139,9 @@ impl RepositoryLoader {
134139

135140
Ok(zen_challenges)
136141
}
142+
143+
pub fn get_git_info(&self) -> &Option<GitRepositoryInfo> {
144+
&self.git_info
145+
}
146+
137147
}

src/game/screens/title_screen.rs

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use crate::Result;
22
use crate::game::stage_builder::DifficultyLevel;
3+
use crate::extractor::GitRepositoryInfo;
34
use crossterm::{
45
cursor::MoveTo,
56
event::{self, Event, KeyCode, KeyModifiers},
@@ -24,6 +25,10 @@ impl TitleScreen {
2425
}
2526

2627
pub fn show_with_challenge_counts(challenge_counts: &[usize; 5]) -> Result<TitleAction> {
28+
Self::show_with_challenge_counts_and_git_info(challenge_counts, None)
29+
}
30+
31+
pub fn show_with_challenge_counts_and_git_info(challenge_counts: &[usize; 5], git_info: Option<&GitRepositoryInfo>) -> Result<TitleAction> {
2732
let mut selected_difficulty = 1; // Start with Normal (index 1)
2833
let difficulties = [
2934
("Easy", DifficultyLevel::Easy),
@@ -41,7 +46,7 @@ impl TitleScreen {
4146
let center_col = terminal_width / 2;
4247

4348
// Draw static elements once
44-
Self::draw_static_elements(&mut stdout, center_row, center_col)?;
49+
Self::draw_static_elements(&mut stdout, center_row, center_col, git_info)?;
4550

4651
let mut last_difficulty = selected_difficulty;
4752
// Draw initial difficulty selection
@@ -84,7 +89,7 @@ impl TitleScreen {
8489
}
8590
}
8691

87-
fn draw_static_elements(stdout: &mut std::io::Stdout, center_row: u16, center_col: u16) -> Result<()> {
92+
fn draw_static_elements(stdout: &mut std::io::Stdout, center_row: u16, center_col: u16, git_info: Option<&GitRepositoryInfo>) -> Result<()> {
8893
// ASCII logo lines
8994
let logo_lines = vec![
9095
"─╔═══╗─╔══╗─╔════╗────╔════╗─╔╗──╔╗─╔═══╗─╔═══╗─",
@@ -129,6 +134,9 @@ impl TitleScreen {
129134
execute!(stdout, Print("[ESC] Quit"))?;
130135
execute!(stdout, ResetColor)?;
131136

137+
// Display git info at bottom
138+
Self::draw_git_info(stdout, git_info)?;
139+
132140
Ok(())
133141
}
134142

@@ -190,4 +198,43 @@ impl TitleScreen {
190198

191199
Ok(())
192200
}
201+
202+
fn draw_git_info(stdout: &mut std::io::Stdout, git_info: Option<&GitRepositoryInfo>) -> Result<()> {
203+
if let Some(info) = git_info {
204+
let (terminal_width, terminal_height) = terminal::size()?;
205+
let bottom_row = terminal_height - 1;
206+
207+
// Build git info string
208+
let mut parts = vec![
209+
format!("📁 {}/{}", info.user_name, info.repository_name),
210+
];
211+
212+
if let Some(ref branch) = info.branch {
213+
parts.push(format!("🌿 {}", branch));
214+
}
215+
216+
if let Some(ref commit) = info.commit_hash {
217+
parts.push(format!("📝 {}", &commit[..8]));
218+
}
219+
220+
let status_symbol = if info.is_dirty { "⚠️" } else { "✓" };
221+
parts.push(status_symbol.to_string());
222+
223+
let git_text = parts.join(" • ");
224+
225+
// Calculate approximate display width considering emoji width
226+
// Each emoji takes about 2 characters worth of width
227+
let emoji_count = git_text.chars().filter(|c| *c as u32 > 127).count();
228+
let approximate_width = git_text.chars().count() + emoji_count;
229+
230+
// Center the text using approximate width
231+
let git_col = terminal_width.saturating_sub(approximate_width as u16) / 2;
232+
233+
execute!(stdout, MoveTo(git_col, bottom_row))?;
234+
execute!(stdout, SetForegroundColor(Color::DarkGrey))?;
235+
execute!(stdout, Print(&git_text))?;
236+
execute!(stdout, ResetColor)?;
237+
}
238+
Ok(())
239+
}
193240
}

src/game/stage_manager.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use crate::Result;
22
use crate::scoring::{TypingMetrics, ScoringEngine};
3+
use crate::extractor::GitRepositoryInfo;
34
use crossterm::terminal;
45
use std::sync::{Arc, Mutex};
56
use once_cell::sync::Lazy;
@@ -22,6 +23,7 @@ pub struct StageManager {
2223
stage_engines: Vec<(String, ScoringEngine)>,
2324
current_game_mode: Option<GameMode>,
2425
session_tracker: SessionTracker,
26+
git_info: Option<GitRepositoryInfo>,
2527
}
2628

2729
impl StageManager {
@@ -33,8 +35,13 @@ impl StageManager {
3335
stage_engines: Vec::new(),
3436
current_game_mode: None,
3537
session_tracker: SessionTracker::new(),
38+
git_info: None,
3639
}
3740
}
41+
42+
pub fn set_git_info(&mut self, git_info: Option<GitRepositoryInfo>) {
43+
self.git_info = git_info;
44+
}
3845

3946
pub fn run_session(&mut self) -> Result<()> {
4047
// Set global session tracker for Ctrl+C handler
@@ -57,7 +64,7 @@ impl StageManager {
5764
// Count challenges by difficulty level
5865
let challenge_counts = self.count_challenges_by_difficulty();
5966

60-
match TitleScreen::show_with_challenge_counts(&challenge_counts)? {
67+
match TitleScreen::show_with_challenge_counts_and_git_info(&challenge_counts, self.git_info.as_ref())? {
6168
TitleAction::Start(difficulty) => {
6269
// Build stages based on selected difficulty using pre-generated challenges
6370
let game_mode = GameMode::Custom {

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ fn main() -> anyhow::Result<()> {
148148

149149
// Create StageManager with pre-generated challenges
150150
let mut stage_manager = StageManager::new(available_challenges);
151+
stage_manager.set_git_info(loader.get_git_info().clone());
151152
match stage_manager.run_session() {
152153
Ok(_) => {
153154
// println!("Thanks for playing GitType!");

0 commit comments

Comments
 (0)