use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result, bail};
use lds_core::Session;
pub mod output;
mod read;
mod remote;
mod reset;
mod session;
mod write;
pub use read::LogFilters;
pub use output::{
BranchDeleteOutput, BranchStatusOutput, CommitEntry, CommitOutput, DiffOutput, EntryStatus,
FetchOutput, IsPushedOutput, LogOutput, MergeOutput, OtherStagedMode, RemoteEntry,
RemoteListOutput, ResetMode, ResetOutput, SessionReleaseOutput, StatusKind, StatusOutput,
TagPushedOutput, UnpushedCommitsOutput, WorktreeAddOutput, WorktreeEntry, WorktreeListOutput,
WorktreeRemoveOutput, WorktreeStateOutput,
};
#[derive(Debug)]
pub struct GitModule {
session: Arc<Session>,
owned_worktrees: HashSet<PathBuf>,
owned_branches: HashSet<String>,
}
impl GitModule {
pub fn new(session: Arc<Session>) -> Self {
Self {
session,
owned_worktrees: HashSet::new(),
owned_branches: HashSet::new(),
}
}
pub fn register_worktree(&mut self, path: PathBuf) {
self.owned_worktrees.insert(path);
}
pub fn is_owned(&self, path: &PathBuf) -> bool {
self.owned_worktrees.contains(path)
}
pub fn ensure_owned(&self, path: &PathBuf) -> Result<()> {
if !self.is_owned(path) {
bail!(
"worktree not owned by this session ({}): {}",
self.session.id(),
path.display()
);
}
Ok(())
}
pub(crate) fn ensure_branch_owned(&self, branch: &str) -> Result<()> {
if !self.owned_branches.contains(branch) {
bail!(
"branch not owned by this session ({}): {}",
self.session.id(),
branch,
);
}
Ok(())
}
pub(crate) fn worktrees_dir(&self) -> PathBuf {
self.session.worktrees_dir().to_path_buf()
}
pub(crate) fn ensure_session_scope(&self, working_dir: &Path) -> Result<()> {
if working_dir == self.session.root() {
return Ok(());
}
let canon = working_dir
.canonicalize()
.unwrap_or_else(|_| working_dir.to_path_buf());
if self.owned_worktrees.contains(&canon) {
return Ok(());
}
if self.owned_worktrees.contains(working_dir) {
return Ok(());
}
bail!(
"working_dir not owned by this session ({}): {}",
self.session.id(),
working_dir.display(),
);
}
pub(crate) fn session(&self) -> &Session {
&self.session
}
pub(crate) fn register_branch(&mut self, branch: String) {
self.owned_branches.insert(branch);
}
pub(crate) fn forget_worktree(&mut self, path: &Path) {
let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
self.owned_worktrees.remove(&canon);
self.owned_worktrees.remove(path);
}
}
pub(crate) async fn git_cmd(cwd: &Path, args: &[&str], timeout: Duration) -> Result<String> {
let mut cmd = tokio::process::Command::new("git");
cmd.args(args).current_dir(cwd).kill_on_drop(true);
let result = tokio::time::timeout(timeout, cmd.output()).await;
let output = match result {
Ok(Ok(o)) => o,
Ok(Err(e)) => {
return Err(anyhow::Error::from(e))
.with_context(|| format!("failed to run git {}", args.first().unwrap_or(&"")));
}
Err(_elapsed) => {
bail!(
"git {}: timed out after {}s",
args.first().unwrap_or(&""),
timeout.as_secs()
);
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git {}: {}", args.first().unwrap_or(&""), stderr.trim());
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub(crate) async fn git_cmd_combined(
cwd: &Path,
args: &[&str],
timeout: Duration,
) -> Result<String> {
let mut cmd = tokio::process::Command::new("git");
cmd.args(args).current_dir(cwd).kill_on_drop(true);
let result = tokio::time::timeout(timeout, cmd.output()).await;
let output = match result {
Ok(Ok(o)) => o,
Ok(Err(e)) => {
return Err(anyhow::Error::from(e))
.with_context(|| format!("failed to run git {}", args.first().unwrap_or(&"")));
}
Err(_elapsed) => {
bail!(
"git {}: timed out after {}s",
args.first().unwrap_or(&""),
timeout.as_secs()
);
}
};
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if !output.status.success() {
let combined = if stderr.is_empty() { stdout } else { stderr };
bail!("git {}: {}", args.first().unwrap_or(&""), combined);
}
Ok(match (stdout.is_empty(), stderr.is_empty()) {
(true, true) => String::new(),
(false, true) => stdout,
(true, false) => stderr,
(false, false) => format!("{stdout}\n{stderr}"),
})
}
pub(crate) const TIMEOUT_LOCAL: Duration = Duration::from_secs(10);
pub(crate) const TIMEOUT_NETWORK: Duration = Duration::from_secs(60);
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn git_cmd_reports_timeout_with_literal_message() {
let tmp = std::env::temp_dir();
let err = git_cmd(&tmp, &["version"], Duration::from_nanos(1))
.await
.expect_err("nanosecond timeout must trip");
let msg = err.to_string();
assert!(
msg.contains("timed out after"),
"expected 'timed out after' literal, got: {msg}"
);
assert!(
msg.contains("git version"),
"expected subcommand name in message, got: {msg}"
);
}
#[tokio::test]
async fn git_cmd_combined_reports_timeout_with_literal_message() {
let tmp = std::env::temp_dir();
let err = git_cmd_combined(&tmp, &["version"], Duration::from_nanos(1))
.await
.expect_err("nanosecond timeout must trip");
assert!(
err.to_string().contains("timed out after"),
"expected 'timed out after' literal, got: {err}"
);
}
}