Skip to main content

gitprint/
git.rs

1use std::collections::HashMap;
2use std::future::Future;
3use std::path::{Path, PathBuf};
4use std::pin::Pin;
5use std::sync::Arc;
6use std::time::UNIX_EPOCH;
7
8use tokio::process::Command;
9
10use crate::error::Error;
11use crate::types::{Config, RepoMetadata};
12
13async fn run_git(repo_path: &Path, args: &[&str]) -> Result<String, Error> {
14    let output = Command::new("git")
15        .args(["-C", &repo_path.to_string_lossy()])
16        .args(args)
17        .output()
18        .await
19        .map_err(|e| Error::Git(format!("failed to run git: {e}")))?;
20
21    if !output.status.success() {
22        let stderr = String::from_utf8_lossy(&output.stderr);
23        return Err(Error::Git(stderr.trim().to_string()));
24    }
25
26    Ok(String::from_utf8(output.stdout)
27        .unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned()))
28}
29
30/// Describes what the user-supplied path resolves to.
31#[derive(Debug)]
32pub struct RepoInfo {
33    /// Git repo root (git mode) or canonical directory path (plain-dir mode).
34    pub root: PathBuf,
35    /// Whether `root` is inside a git repository.
36    pub is_git: bool,
37    /// Subdirectory scope within the git repo (relative to `root`).
38    /// Only set when the user supplied a strict subdirectory of the repo root.
39    pub scope: Option<PathBuf>,
40    /// When the user supplied a single file, its path relative to `root`.
41    pub single_file: Option<PathBuf>,
42}
43
44/// Resolves the user-supplied path into a `RepoInfo`.
45///
46/// - File inside git repo  → `{ root: repo_root, is_git: true,  single_file: Some(rel) }`
47/// - Subdir inside git repo → `{ root: repo_root, is_git: true,  scope: Some(rel) }`
48/// - Git repo root          → `{ root: repo_root, is_git: true  }`
49/// - Plain directory        → `{ root: canonical, is_git: false }`
50/// - File outside git       → `{ root: parent,   is_git: false, single_file: Some(name) }`
51pub async fn verify_repo(path: &Path) -> Result<RepoInfo, Error> {
52    let canonical = std::fs::canonicalize(path)
53        .map_err(|_| Error::Git(format!("{}: path not found", path.display())))?;
54
55    // Git must be invoked from a directory; use parent when the path is a file.
56    let git_dir = if canonical.is_file() {
57        canonical
58            .parent()
59            .ok_or_else(|| Error::Git("file has no parent directory".to_string()))?
60            .to_path_buf()
61    } else {
62        canonical.clone()
63    };
64
65    let output = Command::new("git")
66        .args(["-C", &git_dir.to_string_lossy()])
67        .args(["rev-parse", "--show-toplevel"])
68        .output()
69        .await
70        .map_err(|e| Error::Git(format!("failed to run git: {e}")))?;
71
72    if output.status.success() {
73        let root = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim().to_string());
74
75        if canonical.is_file() {
76            let rel = canonical
77                .strip_prefix(&root)
78                .map_err(|_| Error::Git("file is outside the git repository".to_string()))?
79                .to_path_buf();
80            return Ok(RepoInfo {
81                root,
82                is_git: true,
83                scope: None,
84                single_file: Some(rel),
85            });
86        }
87
88        let scope = (canonical != root)
89            .then(|| canonical.strip_prefix(&root).ok().map(|p| p.to_path_buf()))
90            .flatten();
91        return Ok(RepoInfo {
92            root,
93            is_git: true,
94            scope,
95            single_file: None,
96        });
97    }
98
99    // Not inside a git repo.
100    if canonical.is_file() {
101        let parent = canonical
102            .parent()
103            .ok_or_else(|| Error::Git("file has no parent directory".to_string()))?
104            .to_path_buf();
105        return Ok(RepoInfo {
106            root: parent,
107            is_git: false,
108            scope: None,
109            single_file: Some(PathBuf::from(canonical.file_name().unwrap())),
110        });
111    }
112
113    if canonical.is_dir() {
114        return Ok(RepoInfo {
115            root: canonical,
116            is_git: false,
117            scope: None,
118            single_file: None,
119        });
120    }
121
122    Err(Error::Git(format!(
123        "{}: not a git repository, directory, or file",
124        path.display()
125    )))
126}
127
128pub async fn get_metadata(
129    repo_path: &Path,
130    config: &Config,
131    is_git: bool,
132    scope: Option<&Path>,
133) -> Result<RepoMetadata, Error> {
134    let base = repo_path
135        .file_name()
136        .map(|n| n.to_string_lossy().to_string())
137        .unwrap_or_else(|| "unknown".to_string());
138    let name = match scope {
139        Some(s) => format!("{}/{}", base, s.display()),
140        None => base,
141    };
142
143    if !is_git {
144        return Ok(RepoMetadata {
145            name,
146            branch: String::new(),
147            commit_hash: String::new(),
148            commit_hash_short: String::new(),
149            commit_date: String::new(),
150            commit_message: String::new(),
151            file_count: 0,
152            total_lines: 0,
153        });
154    }
155
156    let rev = match (&config.commit, &config.branch) {
157        (Some(c), _) => c.clone(),
158        (_, Some(b)) => b.clone(),
159        _ => "HEAD".to_string(),
160    };
161
162    // Run branch detection and commit log in parallel — both are independent git calls.
163    let log_args = ["log", "-1", "--format=%H%n%ci%n%s", &rev];
164    let (branch, log_output) = tokio::join!(
165        async {
166            match &config.branch {
167                Some(b) => b.clone(),
168                None => run_git(repo_path, &["rev-parse", "--abbrev-ref", "HEAD"])
169                    .await
170                    .map(|s| s.trim().to_string())
171                    .unwrap_or_else(|_| "detached".to_string()),
172            }
173        },
174        run_git(repo_path, &log_args),
175    );
176    let log_output = log_output?;
177
178    let mut lines = log_output.trim().lines();
179    let commit_hash = lines.next().unwrap_or("").to_string();
180    let commit_hash_short = commit_hash[..7.min(commit_hash.len())].to_string();
181    let commit_date = lines.next().unwrap_or("").to_string();
182    let commit_message = lines.collect::<Vec<_>>().join("\n");
183
184    Ok(RepoMetadata {
185        name,
186        branch,
187        commit_hash,
188        commit_hash_short,
189        commit_date,
190        commit_message,
191        file_count: 0,
192        total_lines: 0,
193    })
194}
195
196pub async fn list_tracked_files(
197    repo_path: &Path,
198    config: &Config,
199    is_git: bool,
200    scope: Option<&Path>,
201) -> Result<Vec<PathBuf>, Error> {
202    if !is_git {
203        return walk_files_async(repo_path.to_path_buf()).await;
204    }
205
206    let scope_str = scope.and_then(|p| p.to_str());
207    let output = match (&config.commit, &config.branch) {
208        (Some(commit), _) => match scope_str {
209            Some(s) => {
210                run_git(
211                    repo_path,
212                    &["ls-tree", "-r", "--name-only", commit, "--", s],
213                )
214                .await?
215            }
216            None => run_git(repo_path, &["ls-tree", "-r", "--name-only", commit]).await?,
217        },
218        (_, Some(branch)) => match scope_str {
219            Some(s) => {
220                run_git(
221                    repo_path,
222                    &["ls-tree", "-r", "--name-only", branch, "--", s],
223                )
224                .await?
225            }
226            None => run_git(repo_path, &["ls-tree", "-r", "--name-only", branch]).await?,
227        },
228        _ => match scope_str {
229            Some(s) => run_git(repo_path, &["ls-files", "--", s]).await?,
230            None => run_git(repo_path, &["ls-files"]).await?,
231        },
232    };
233
234    Ok(output
235        .lines()
236        .filter(|l| !l.is_empty())
237        .map(PathBuf::from)
238        .collect())
239}
240
241/// Returns a map of file path → last modified date (YYYY-MM-DD).
242/// In git mode: parsed from `git log`. In directory mode: from filesystem mtime.
243pub async fn file_last_modified_dates(
244    repo_path: &Path,
245    config: &Config,
246    is_git: bool,
247    scope: Option<&Path>,
248) -> Result<HashMap<PathBuf, String>, Error> {
249    if !is_git {
250        return walk_dates_async(repo_path.to_path_buf()).await;
251    }
252
253    let rev = match (&config.commit, &config.branch) {
254        (Some(c), _) => c.clone(),
255        (_, Some(b)) => b.clone(),
256        _ => "HEAD".to_string(),
257    };
258
259    let scope_str = scope.and_then(|p| p.to_str());
260    let output = match scope_str {
261        Some(s) => {
262            run_git(
263                repo_path,
264                &["log", "--format=COMMIT:%ci", "--name-only", &rev, "--", s],
265            )
266            .await?
267        }
268        None => {
269            run_git(
270                repo_path,
271                &["log", "--format=COMMIT:%ci", "--name-only", &rev],
272            )
273            .await?
274        }
275    };
276
277    let mut map = HashMap::new();
278    let mut current_date = String::new();
279
280    output.lines().for_each(|line| {
281        if let Some(date_str) = line.strip_prefix("COMMIT:") {
282            current_date = date_str.chars().take(10).collect();
283        } else if !line.is_empty() && !current_date.is_empty() {
284            map.entry(PathBuf::from(line))
285                .or_insert_with(|| current_date.clone());
286        }
287    });
288
289    Ok(map)
290}
291
292/// Returns the last-modified date (YYYY-MM-DD) for a single file.
293/// In git mode: from `git log`. In plain mode: from filesystem mtime.
294pub async fn file_last_modified(root: &Path, file: &Path, config: &Config, is_git: bool) -> String {
295    if is_git {
296        let rev = config
297            .commit
298            .as_deref()
299            .or(config.branch.as_deref())
300            .unwrap_or("HEAD");
301        let file_str = file.to_string_lossy();
302        run_git(
303            root,
304            &["log", "-1", "--format=%ci", rev, "--", file_str.as_ref()],
305        )
306        .await
307        .ok()
308        .map(|s| s.trim().chars().take(10).collect())
309        .unwrap_or_default()
310    } else {
311        tokio::fs::metadata(root.join(file))
312            .await
313            .ok()
314            .and_then(|m| m.modified().ok())
315            .map(|t| {
316                let secs = t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
317                let (y, m, d) = unix_secs_to_ymd(secs);
318                format!("{y:04}-{m:02}-{d:02}")
319            })
320            .unwrap_or_default()
321    }
322}
323
324pub async fn read_file_content(
325    repo_path: &Path,
326    file_path: &Path,
327    config: &Config,
328) -> Result<String, Error> {
329    let rev = config.commit.as_deref().or(config.branch.as_deref());
330    match rev {
331        Some(rev) => {
332            let spec = format!("{rev}:{}", file_path.display());
333            run_git(repo_path, &["show", &spec]).await
334        }
335        None => tokio::fs::read_to_string(repo_path.join(file_path))
336            .await
337            .map_err(Error::Io),
338    }
339}
340
341// ── Private helpers for plain-directory mode ──────────────────────────────────
342
343/// Converts Unix timestamp (seconds since epoch) to (year, month, day).
344/// Uses Howard Hinnant's date algorithm.
345fn unix_secs_to_ymd(secs: u64) -> (u32, u32, u32) {
346    let z = (secs / 86400) as i64 + 719_468;
347    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
348    let doe = (z - era * 146_097) as u32;
349    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
350    let y = yoe as i64 + era * 400;
351    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
352    let mp = (5 * doy + 2) / 153;
353    let d = doy - (153 * mp + 2) / 5 + 1;
354    let m = if mp < 10 { mp + 3 } else { mp - 9 };
355    let y = if m <= 2 { y + 1 } else { y };
356    (y as u32, m, d)
357}
358
359/// Recursive async walk returning all file paths relative to `root`.
360/// Each directory immediately spawns tasks for its subdirectories — no
361/// level-by-level BFS barriers, maximum concurrency throughout the tree.
362fn walk_files_inner(
363    root: Arc<PathBuf>,
364    dir: PathBuf,
365) -> Pin<Box<dyn Future<Output = Result<Vec<PathBuf>, Error>> + Send>> {
366    Box::pin(async move {
367        let mut rd = tokio::fs::read_dir(&dir).await.map_err(Error::Io)?;
368        let mut files: Vec<PathBuf> = Vec::new();
369        let mut set: tokio::task::JoinSet<Result<Vec<PathBuf>, Error>> =
370            tokio::task::JoinSet::new();
371
372        while let Some(entry) = rd.next_entry().await.map_err(Error::Io)? {
373            let ft = entry.file_type().await.map_err(Error::Io)?;
374            if ft.is_dir() {
375                set.spawn(walk_files_inner(Arc::clone(&root), entry.path()));
376            } else if ft.is_file()
377                && let Ok(rel) = entry.path().strip_prefix(root.as_ref())
378            {
379                files.push(rel.to_path_buf());
380            }
381        }
382
383        set.join_all()
384            .await
385            .into_iter()
386            .try_for_each(|res| res.map(|sub| files.extend(sub)))?;
387
388        Ok(files)
389    })
390}
391
392async fn walk_files_async(root: PathBuf) -> Result<Vec<PathBuf>, Error> {
393    walk_files_inner(Arc::new(root.clone()), root).await
394}
395
396/// Walk the tree (via `walk_files_async`) then fetch all file mtimes concurrently.
397async fn walk_dates_async(root: PathBuf) -> Result<HashMap<PathBuf, String>, Error> {
398    let files = walk_files_async(root.clone()).await?;
399    let mut set: tokio::task::JoinSet<Option<(PathBuf, String)>> = tokio::task::JoinSet::new();
400
401    files.into_iter().for_each(|rel| {
402        let abs = root.join(&rel);
403        set.spawn(async move {
404            let date = tokio::fs::metadata(&abs)
405                .await
406                .ok()
407                .and_then(|m| m.modified().ok())
408                .map(|t| {
409                    let secs = t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
410                    let (y, m, d) = unix_secs_to_ymd(secs);
411                    format!("{y:04}-{m:02}-{d:02}")
412                })?;
413            Some((rel, date))
414        });
415    });
416
417    Ok(set.join_all().await.into_iter().flatten().collect())
418}