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