1use std::collections::HashSet;
15use std::path::{Path, PathBuf};
16use std::sync::Arc;
17use std::time::Duration;
18
19use anyhow::{Context, Result, bail};
20use lds_core::Session;
21
22pub mod output;
23mod read;
24mod remote;
25mod reset;
26mod session;
27mod write;
28
29pub use read::LogFilters;
30
31pub use output::{
32 BranchDeleteOutput, BranchStatusOutput, CommitEntry, CommitOutput, DiffOutput, EntryStatus,
33 FetchOutput, IsPushedOutput, LogOutput, MergeOutput, OtherStagedMode, RemoteEntry,
34 RemoteListOutput, ResetMode, ResetOutput, SessionReleaseOutput, StatusKind, StatusOutput,
35 TagPushedOutput, UnpushedCommitsOutput, WorktreeAddOutput, WorktreeEntry, WorktreeListOutput,
36 WorktreeRemoveOutput, WorktreeStateOutput,
37};
38
39#[derive(Debug)]
45pub struct GitModule {
46 session: Arc<Session>,
47 owned_worktrees: HashSet<PathBuf>,
49 owned_branches: HashSet<String>,
51}
52
53impl GitModule {
54 pub fn new(session: Arc<Session>) -> Self {
55 Self {
56 session,
57 owned_worktrees: HashSet::new(),
58 owned_branches: HashSet::new(),
59 }
60 }
61
62 pub fn register_worktree(&mut self, path: PathBuf) {
63 self.owned_worktrees.insert(path);
64 }
65
66 pub fn is_owned(&self, path: &PathBuf) -> bool {
67 self.owned_worktrees.contains(path)
68 }
69
70 pub fn ensure_owned(&self, path: &PathBuf) -> Result<()> {
71 if !self.is_owned(path) {
72 bail!(
73 "worktree not owned by this session ({}): {}",
74 self.session.id(),
75 path.display()
76 );
77 }
78 Ok(())
79 }
80
81 pub(crate) fn ensure_branch_owned(&self, branch: &str) -> Result<()> {
82 if !self.owned_branches.contains(branch) {
83 bail!(
84 "branch not owned by this session ({}): {}",
85 self.session.id(),
86 branch,
87 );
88 }
89 Ok(())
90 }
91
92 pub(crate) fn worktrees_dir(&self) -> PathBuf {
100 self.session.worktrees_dir().to_path_buf()
101 }
102
103 pub(crate) fn ensure_session_scope(&self, working_dir: &Path) -> Result<()> {
104 if working_dir == self.session.root() {
105 return Ok(());
106 }
107 let canon = working_dir
108 .canonicalize()
109 .unwrap_or_else(|_| working_dir.to_path_buf());
110 if self.owned_worktrees.contains(&canon) {
111 return Ok(());
112 }
113 if self.owned_worktrees.contains(working_dir) {
114 return Ok(());
115 }
116 bail!(
117 "working_dir not owned by this session ({}): {}",
118 self.session.id(),
119 working_dir.display(),
120 );
121 }
122
123 pub(crate) fn session(&self) -> &Session {
124 &self.session
125 }
126
127 pub(crate) fn register_branch(&mut self, branch: String) {
128 self.owned_branches.insert(branch);
129 }
130
131 pub(crate) fn forget_worktree(&mut self, path: &Path) {
132 let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
133 self.owned_worktrees.remove(&canon);
134 self.owned_worktrees.remove(path);
135 }
136}
137
138pub(crate) async fn spawn_output(
151 cmd: &mut tokio::process::Command,
152 display: &str,
153 timeout: Duration,
154) -> Result<std::process::Output> {
155 use std::process::Stdio;
156
157 cmd.stdin(Stdio::null())
158 .stdout(Stdio::piped())
159 .stderr(Stdio::piped())
160 .kill_on_drop(true);
161 #[cfg(unix)]
162 cmd.process_group(0);
163
164 let child = cmd
165 .spawn()
166 .with_context(|| format!("failed to spawn git {display}"))?;
167 let pid = child.id();
168
169 match tokio::time::timeout(timeout, child.wait_with_output()).await {
170 Ok(Ok(output)) => Ok(output),
171 Ok(Err(e)) => {
172 Err(anyhow::Error::from(e)).with_context(|| format!("failed to wait on git {display}"))
173 }
174 Err(_elapsed) => {
175 #[cfg(unix)]
179 if let Some(pid) = pid {
180 unsafe {
183 libc::killpg(pid as i32, libc::SIGKILL);
184 }
185 }
186 #[cfg(not(unix))]
187 let _ = pid;
188 bail!(
189 "git {}: timed out after {}s (SIGKILL sent to process group)",
190 display,
191 timeout.as_secs()
192 );
193 }
194 }
195}
196
197pub(crate) async fn git_cmd(cwd: &Path, args: &[&str], timeout: Duration) -> Result<String> {
206 let mut cmd = tokio::process::Command::new("git");
207 cmd.args(args).current_dir(cwd);
208 let display = args.first().copied().unwrap_or("");
209 let output = spawn_output(&mut cmd, display, timeout).await?;
210
211 if !output.status.success() {
212 let stderr = String::from_utf8_lossy(&output.stderr);
213 bail!("git {}: {}", display, stderr.trim());
214 }
215 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
216}
217
218pub(crate) async fn git_cmd_combined(
221 cwd: &Path,
222 args: &[&str],
223 timeout: Duration,
224) -> Result<String> {
225 let mut cmd = tokio::process::Command::new("git");
226 cmd.args(args).current_dir(cwd);
227 let display = args.first().copied().unwrap_or("");
228 let output = spawn_output(&mut cmd, display, timeout).await?;
229
230 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
231 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
232
233 if !output.status.success() {
234 let combined = if stderr.is_empty() { stdout } else { stderr };
235 bail!("git {}: {}", display, combined);
236 }
237
238 Ok(match (stdout.is_empty(), stderr.is_empty()) {
239 (true, true) => String::new(),
240 (false, true) => stdout,
241 (true, false) => stderr,
242 (false, false) => format!("{stdout}\n{stderr}"),
243 })
244}
245
246pub(crate) const TIMEOUT_LOCAL: Duration = Duration::from_secs(30);
256
257pub(crate) const TIMEOUT_NETWORK: Duration = Duration::from_secs(60);
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 #[tokio::test]
271 async fn git_cmd_reports_timeout_with_literal_message() {
272 let tmp = std::env::temp_dir();
273 let err = git_cmd(&tmp, &["version"], Duration::from_nanos(1))
274 .await
275 .expect_err("nanosecond timeout must trip");
276 let msg = err.to_string();
277 assert!(
278 msg.contains("timed out after"),
279 "expected 'timed out after' literal, got: {msg}"
280 );
281 assert!(
282 msg.contains("git version"),
283 "expected subcommand name in message, got: {msg}"
284 );
285 }
286
287 #[tokio::test]
288 async fn git_cmd_combined_reports_timeout_with_literal_message() {
289 let tmp = std::env::temp_dir();
290 let err = git_cmd_combined(&tmp, &["version"], Duration::from_nanos(1))
291 .await
292 .expect_err("nanosecond timeout must trip");
293 assert!(
294 err.to_string().contains("timed out after"),
295 "expected 'timed out after' literal, got: {err}"
296 );
297 }
298}