1use std::collections::HashSet;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11use std::sync::Arc;
12
13use anyhow::{Context, Result, bail};
14use lds_core::Session;
15
16#[derive(Debug)]
22pub struct GitModule {
23 session: Arc<Session>,
24 owned_worktrees: HashSet<PathBuf>,
26 owned_branches: HashSet<String>,
28}
29
30impl GitModule {
31 pub fn new(session: Arc<Session>) -> Self {
32 Self {
33 session,
34 owned_worktrees: HashSet::new(),
35 owned_branches: HashSet::new(),
36 }
37 }
38
39 pub fn status(&self) -> Result<String> {
42 let repo = git2::Repository::open(self.session.root())?;
43 let statuses = repo.statuses(None)?;
44 let mut out = String::new();
45 for entry in statuses.iter() {
46 let path = entry.path().unwrap_or("???");
47 let st = entry.status();
48 out.push_str(&format!("{st:?} {path}\n"));
49 }
50 Ok(out)
51 }
52
53 pub fn log(&self, max_count: usize) -> Result<String> {
54 let repo = git2::Repository::open(self.session.root())?;
55 let mut revwalk = repo.revwalk()?;
56 revwalk.push_head()?;
57 let mut out = String::new();
58 for (i, oid) in revwalk.enumerate() {
59 if i >= max_count {
60 break;
61 }
62 let oid = oid?;
63 let commit = repo.find_commit(oid)?;
64 let summary = commit.summary().unwrap_or("");
65 out.push_str(&format!("{} {summary}\n", &oid.to_string()[..7]));
66 }
67 Ok(out)
68 }
69
70 pub fn diff(&self) -> Result<String> {
71 let repo = git2::Repository::open(self.session.root())?;
72 let head = repo.head()?.peel_to_tree()?;
73 let diff = repo.diff_tree_to_workdir_with_index(Some(&head), None)?;
74 let mut out = String::new();
75 diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
76 let origin = line.origin();
77 if matches!(origin, '+' | '-' | ' ') {
78 out.push(origin);
79 }
80 out.push_str(std::str::from_utf8(line.content()).unwrap_or(""));
81 true
82 })?;
83 Ok(out)
84 }
85
86 pub fn register_worktree(&mut self, path: PathBuf) {
89 self.owned_worktrees.insert(path);
90 }
91
92 pub fn is_owned(&self, path: &PathBuf) -> bool {
93 self.owned_worktrees.contains(path)
94 }
95
96 pub fn ensure_owned(&self, path: &PathBuf) -> Result<()> {
97 if !self.is_owned(path) {
98 bail!(
99 "worktree not owned by this session ({}): {}",
100 self.session.id(),
101 path.display()
102 );
103 }
104 Ok(())
105 }
106
107 fn ensure_branch_owned(&self, branch: &str) -> Result<()> {
108 if !self.owned_branches.contains(branch) {
109 bail!(
110 "branch not owned by this session ({}): {}",
111 self.session.id(),
112 branch,
113 );
114 }
115 Ok(())
116 }
117
118 fn worktrees_dir(&self) -> PathBuf {
119 self.session.root().join(".worktrees")
120 }
121
122 fn ensure_session_scope(&self, working_dir: &Path) -> Result<()> {
123 if working_dir == self.session.root() {
124 return Ok(());
125 }
126 let canon = working_dir
127 .canonicalize()
128 .unwrap_or_else(|_| working_dir.to_path_buf());
129 if self.owned_worktrees.contains(&canon) {
130 return Ok(());
131 }
132 if self.owned_worktrees.contains(working_dir) {
133 return Ok(());
134 }
135 bail!(
136 "working_dir not owned by this session ({}): {}",
137 self.session.id(),
138 working_dir.display(),
139 );
140 }
141
142 pub fn worktree_list(&self) -> Result<String> {
145 let out = git_cmd(self.session.root(), &["worktree", "list", "--porcelain"])?;
146 let mut entries = Vec::new();
147 let mut current: Vec<String> = Vec::new();
148 for line in out.lines() {
149 if line.is_empty() {
150 if !current.is_empty() {
151 let entry_text = current.join("\n");
152 let path_line = current.first().and_then(|l| l.strip_prefix("worktree "));
153 let owned = path_line
154 .map(|p| {
155 let pb = PathBuf::from(p);
156 self.owned_worktrees.contains(&pb)
157 })
158 .unwrap_or(false);
159 entries.push(format!("{entry_text}\nowned: {owned}"));
160 current.clear();
161 }
162 } else {
163 current.push(line.to_string());
164 }
165 }
166 if !current.is_empty() {
167 let entry_text = current.join("\n");
168 let path_line = current.first().and_then(|l| l.strip_prefix("worktree "));
169 let owned = path_line
170 .map(|p| self.owned_worktrees.contains(&PathBuf::from(p)))
171 .unwrap_or(false);
172 entries.push(format!("{entry_text}\nowned: {owned}"));
173 }
174 Ok(entries.join("\n\n"))
175 }
176
177 pub fn worktree_add(
178 &mut self,
179 name: &str,
180 branch: &str,
181 base_branch: Option<&str>,
182 ) -> Result<String> {
183 let wt_dir = self.worktrees_dir();
184 std::fs::create_dir_all(&wt_dir).context("failed to create .worktrees directory")?;
185
186 let wt_path = wt_dir.join(name);
187 if wt_path.exists() {
188 bail!("worktree already exists: {}", wt_path.display());
189 }
190
191 if let Some(base) = base_branch {
192 git_cmd(
193 self.session.root(),
194 &[
195 "worktree",
196 "add",
197 "-b",
198 branch,
199 wt_path.to_str().unwrap_or(name),
200 base,
201 ],
202 )?;
203 } else {
204 git_cmd(
205 self.session.root(),
206 &[
207 "worktree",
208 "add",
209 "-b",
210 branch,
211 wt_path.to_str().unwrap_or(name),
212 ],
213 )?;
214 }
215
216 let canon = wt_path.canonicalize().unwrap_or_else(|_| wt_path.clone());
217 self.owned_worktrees.insert(canon);
218 self.owned_branches.insert(branch.to_string());
219
220 Ok(format!(
221 "worktree created: path={}, branch={}, session={}",
222 wt_path.display(),
223 branch,
224 self.session.id(),
225 ))
226 }
227
228 pub fn worktree_remove(&mut self, name: &str) -> Result<String> {
229 let wt_path = self.worktrees_dir().join(name);
230 let canon = wt_path.canonicalize().unwrap_or_else(|_| wt_path.clone());
231
232 self.ensure_owned(&canon)
233 .or_else(|_| self.ensure_owned(&wt_path))?;
234
235 git_cmd(
236 self.session.root(),
237 &[
238 "worktree",
239 "remove",
240 "--force",
241 wt_path.to_str().unwrap_or(name),
242 ],
243 )?;
244
245 self.owned_worktrees.remove(&canon);
246 self.owned_worktrees.remove(&wt_path);
247
248 Ok(format!("worktree removed: {}", wt_path.display()))
249 }
250
251 pub fn commit(
252 &self,
253 working_dir: &Path,
254 message: &str,
255 paths: Option<&[String]>,
256 ) -> Result<String> {
257 self.ensure_session_scope(working_dir)?;
258
259 match paths {
260 Some(ps) if !ps.is_empty() => {
261 let mut args = vec!["add", "--"];
262 let owned: Vec<&str> = ps.iter().map(|s| s.as_str()).collect();
263 args.extend(owned);
264 git_cmd(working_dir, &args)?;
265 }
266 _ => {
267 git_cmd(working_dir, &["add", "-A"])?;
268 }
269 }
270
271 git_cmd(working_dir, &["commit", "-m", message])?;
272
273 let hash = git_cmd(working_dir, &["rev-parse", "--short", "HEAD"])?;
274 let files_changed = git_cmd(working_dir, &["diff", "--name-only", "HEAD~1..HEAD"])
275 .unwrap_or_else(|e| {
276 tracing::warn!(error = %e, "git diff HEAD~1..HEAD failed (initial commit?)");
277 String::new()
278 });
279
280 let count = files_changed.lines().count();
281 Ok(format!(
282 "committed: hash={hash}, message={message}, files_changed={count}"
283 ))
284 }
285
286 pub fn merge(&self, branch: &str, into_branch: &str, working_dir: &Path) -> Result<String> {
287 self.ensure_session_scope(working_dir)?;
288
289 let current = git_cmd(working_dir, &["branch", "--show-current"])?;
290 if current != into_branch {
291 git_cmd(working_dir, &["checkout", into_branch])?;
292 }
293
294 let result = git_cmd(
295 working_dir,
296 &[
297 "merge",
298 "--no-ff",
299 branch,
300 "-m",
301 &format!("Merge branch '{}' into {}", branch, into_branch),
302 ],
303 );
304
305 match result {
306 Ok(out) => {
307 let hash = git_cmd(working_dir, &["rev-parse", "--short", "HEAD"])?;
308 Ok(format!(
309 "merged: {branch} -> {into_branch}, hash={hash}\n{out}"
310 ))
311 }
312 Err(e) => {
313 let _ = git_cmd(working_dir, &["merge", "--abort"]);
314 bail!("merge failed (aborted): {e}");
315 }
316 }
317 }
318
319 pub fn branch_delete(&self, branch: &str) -> Result<String> {
320 self.ensure_branch_owned(branch)?;
321
322 git_cmd(self.session.root(), &["branch", "-d", branch])?;
323 Ok(format!("branch deleted: {branch}"))
324 }
325}
326
327fn git_cmd(cwd: &Path, args: &[&str]) -> Result<String> {
328 let output = Command::new("git")
329 .args(args)
330 .current_dir(cwd)
331 .output()
332 .with_context(|| format!("failed to run git {}", args.first().unwrap_or(&"")))?;
333
334 if !output.status.success() {
335 let stderr = String::from_utf8_lossy(&output.stderr);
336 bail!("git {}: {}", args.first().unwrap_or(&""), stderr.trim());
337 }
338 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
339}