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 git_cmd(cwd: &Path, args: &[&str], timeout: Duration) -> Result<String> {
147 let mut cmd = tokio::process::Command::new("git");
148 cmd.args(args).current_dir(cwd).kill_on_drop(true);
149
150 let result = tokio::time::timeout(timeout, cmd.output()).await;
151 let output = match result {
152 Ok(Ok(o)) => o,
153 Ok(Err(e)) => {
154 return Err(anyhow::Error::from(e))
155 .with_context(|| format!("failed to run git {}", args.first().unwrap_or(&"")));
156 }
157 Err(_elapsed) => {
158 bail!(
159 "git {}: timed out after {}s",
160 args.first().unwrap_or(&""),
161 timeout.as_secs()
162 );
163 }
164 };
165
166 if !output.status.success() {
167 let stderr = String::from_utf8_lossy(&output.stderr);
168 bail!("git {}: {}", args.first().unwrap_or(&""), stderr.trim());
169 }
170 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
171}
172
173pub(crate) async fn git_cmd_combined(
176 cwd: &Path,
177 args: &[&str],
178 timeout: Duration,
179) -> Result<String> {
180 let mut cmd = tokio::process::Command::new("git");
181 cmd.args(args).current_dir(cwd).kill_on_drop(true);
182
183 let result = tokio::time::timeout(timeout, cmd.output()).await;
184 let output = match result {
185 Ok(Ok(o)) => o,
186 Ok(Err(e)) => {
187 return Err(anyhow::Error::from(e))
188 .with_context(|| format!("failed to run git {}", args.first().unwrap_or(&"")));
189 }
190 Err(_elapsed) => {
191 bail!(
192 "git {}: timed out after {}s",
193 args.first().unwrap_or(&""),
194 timeout.as_secs()
195 );
196 }
197 };
198
199 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
200 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
201
202 if !output.status.success() {
203 let combined = if stderr.is_empty() { stdout } else { stderr };
204 bail!("git {}: {}", args.first().unwrap_or(&""), combined);
205 }
206
207 Ok(match (stdout.is_empty(), stderr.is_empty()) {
208 (true, true) => String::new(),
209 (false, true) => stdout,
210 (true, false) => stderr,
211 (false, false) => format!("{stdout}\n{stderr}"),
212 })
213}
214
215pub(crate) const TIMEOUT_LOCAL: Duration = Duration::from_secs(10);
220
221pub(crate) const TIMEOUT_NETWORK: Duration = Duration::from_secs(60);
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 #[tokio::test]
235 async fn git_cmd_reports_timeout_with_literal_message() {
236 let tmp = std::env::temp_dir();
237 let err = git_cmd(&tmp, &["version"], Duration::from_nanos(1))
238 .await
239 .expect_err("nanosecond timeout must trip");
240 let msg = err.to_string();
241 assert!(
242 msg.contains("timed out after"),
243 "expected 'timed out after' literal, got: {msg}"
244 );
245 assert!(
246 msg.contains("git version"),
247 "expected subcommand name in message, got: {msg}"
248 );
249 }
250
251 #[tokio::test]
252 async fn git_cmd_combined_reports_timeout_with_literal_message() {
253 let tmp = std::env::temp_dir();
254 let err = git_cmd_combined(&tmp, &["version"], Duration::from_nanos(1))
255 .await
256 .expect_err("nanosecond timeout must trip");
257 assert!(
258 err.to_string().contains("timed out after"),
259 "expected 'timed out after' literal, got: {err}"
260 );
261 }
262}