1use std::path::Path;
9
10use anyhow::{Context, Result, bail};
11
12use crate::output::{
13 BranchDeleteOutput, CommitOutput, DotfileWarning, MergeOutput, OtherStagedMode,
14 WorktreeAddOutput, WorktreeRemoveOutput,
15};
16use crate::{GitModule, TIMEOUT_LOCAL, git_cmd, spawn_output};
17
18impl GitModule {
19 pub async fn worktree_add(
24 &mut self,
25 name: &str,
26 branch: &str,
27 base_branch: Option<&str>,
28 ) -> Result<WorktreeAddOutput> {
29 let wt_dir = self.worktrees_dir();
30 std::fs::create_dir_all(&wt_dir).with_context(|| {
31 format!("failed to create worktrees directory: {}", wt_dir.display())
32 })?;
33
34 let wt_path = wt_dir.join(name);
35 if wt_path.exists() {
36 bail!("worktree already exists: {}", wt_path.display());
37 }
38
39 let path_str = wt_path.to_str().unwrap_or(name);
40 if let Some(base) = base_branch {
41 git_cmd(
42 self.session().root(),
43 &["worktree", "add", "-b", branch, path_str, base],
44 TIMEOUT_LOCAL,
45 )
46 .await?;
47 } else {
48 git_cmd(
49 self.session().root(),
50 &["worktree", "add", "-b", branch, path_str],
51 TIMEOUT_LOCAL,
52 )
53 .await?;
54 }
55
56 let canon = wt_path.canonicalize().unwrap_or_else(|_| wt_path.clone());
57 self.register_worktree(canon);
58 self.register_branch(branch.to_string());
59
60 Ok(WorktreeAddOutput {
61 path: wt_path,
62 branch: branch.to_string(),
63 session: self.session().id().to_string(),
64 })
65 }
66
67 pub async fn worktree_remove(&mut self, name: &str) -> Result<WorktreeRemoveOutput> {
69 let wt_path = self.worktrees_dir().join(name);
70 let canon = wt_path.canonicalize().unwrap_or_else(|_| wt_path.clone());
71
72 self.ensure_owned(&canon)
73 .or_else(|_| self.ensure_owned(&wt_path))?;
74
75 git_cmd(
76 self.session().root(),
77 &[
78 "worktree",
79 "remove",
80 "--force",
81 wt_path.to_str().unwrap_or(name),
82 ],
83 TIMEOUT_LOCAL,
84 )
85 .await?;
86
87 self.forget_worktree(&canon);
88 self.forget_worktree(&wt_path);
89
90 Ok(WorktreeRemoveOutput { path: wt_path })
91 }
92
93 pub async fn commit(
125 &self,
126 working_dir: &Path,
127 message: &str,
128 only: Option<&[String]>,
129 other_staged: OtherStagedMode,
130 force_dot: bool,
131 ) -> Result<CommitOutput> {
132 self.ensure_session_scope(working_dir)?;
133
134 let (warnings, skipped) = match only {
135 None | Some([]) => {
136 let candidates = enumerate_changes(working_dir).await?;
137 if force_dot || !candidates.iter().any(|p| is_dotfile_path(p)) {
138 git_cmd(working_dir, &["add", "-A"], TIMEOUT_LOCAL).await?;
142 (Vec::new(), Vec::new())
143 } else {
144 let cls = classify_paths(working_dir, &candidates, false).await?;
145 stage_add_all(working_dir, &cls.stage).await?;
146 (cls.warnings, cls.skipped)
147 }
148 }
149 Some(ps) => {
150 let intruders = detect_other_staged(working_dir, ps).await?;
151 if !intruders.is_empty() {
152 match other_staged {
153 OtherStagedMode::Stop => {
154 bail!(
155 "commit aborted: index carries staged paths outside \
156 the requested set (other_staged=stop): {}",
157 intruders.join(", ")
158 );
159 }
160 OtherStagedMode::Restage => {
161 unstage(working_dir, &intruders).await?;
162 let cls = classify_paths(working_dir, ps, force_dot).await?;
163 stage(working_dir, &cls.stage).await?;
164 git_cmd(working_dir, &["commit", "-m", message], TIMEOUT_LOCAL).await?;
165 let output =
166 commit_output(working_dir, message, cls.warnings, cls.skipped)
167 .await;
168 stage(working_dir, &intruders).await?;
172 return output;
173 }
174 }
175 }
176 let cls = classify_paths(working_dir, ps, force_dot).await?;
177 stage(working_dir, &cls.stage).await?;
178 (cls.warnings, cls.skipped)
179 }
180 };
181
182 git_cmd(working_dir, &["commit", "-m", message], TIMEOUT_LOCAL).await?;
183 commit_output(working_dir, message, warnings, skipped).await
184 }
185
186 pub async fn merge(
189 &self,
190 branch: &str,
191 into_branch: &str,
192 working_dir: &Path,
193 ) -> Result<MergeOutput> {
194 self.ensure_session_scope(working_dir)?;
195
196 let current = git_cmd(working_dir, &["branch", "--show-current"], TIMEOUT_LOCAL).await?;
197 if current != into_branch {
198 git_cmd(working_dir, &["checkout", into_branch], TIMEOUT_LOCAL).await?;
199 }
200
201 let merge_message = format!("Merge branch '{}' into {}", branch, into_branch);
202 match git_cmd(
203 working_dir,
204 &["merge", "--no-ff", branch, "-m", &merge_message],
205 TIMEOUT_LOCAL,
206 )
207 .await
208 {
209 Ok(raw) => {
210 let sha = git_cmd(working_dir, &["rev-parse", "HEAD"], TIMEOUT_LOCAL).await?;
211 let short_sha = sha[..7.min(sha.len())].to_string();
212 Ok(MergeOutput {
213 branch: branch.to_string(),
214 into_branch: into_branch.to_string(),
215 sha,
216 short_sha,
217 raw,
218 })
219 }
220 Err(e) => {
221 let _ = git_cmd(working_dir, &["merge", "--abort"], TIMEOUT_LOCAL).await;
222 bail!("merge failed (aborted): {e}");
223 }
224 }
225 }
226
227 pub async fn branch_delete(&self, branch: &str) -> Result<BranchDeleteOutput> {
230 self.ensure_branch_owned(branch)?;
231 git_cmd(
232 self.session().root(),
233 &["branch", "-d", branch],
234 TIMEOUT_LOCAL,
235 )
236 .await?;
237 Ok(BranchDeleteOutput {
238 branch: branch.to_string(),
239 })
240 }
241}
242
243async fn detect_other_staged(working_dir: &Path, only: &[String]) -> Result<Vec<String>> {
246 let staged_raw = git_cmd(
247 working_dir,
248 &["diff", "--cached", "--name-only"],
249 TIMEOUT_LOCAL,
250 )
251 .await?;
252 let only_set: std::collections::HashSet<&str> = only.iter().map(|s| s.as_str()).collect();
253 Ok(staged_raw
254 .lines()
255 .filter(|l| !l.is_empty())
256 .filter(|l| !only_set.contains(*l))
257 .map(|s| s.to_string())
258 .collect())
259}
260
261async fn stage(working_dir: &Path, paths: &[String]) -> Result<()> {
263 if paths.is_empty() {
264 return Ok(());
265 }
266 let mut args = vec!["add", "--"];
267 args.extend(paths.iter().map(|s| s.as_str()));
268 git_cmd(working_dir, &args, TIMEOUT_LOCAL).await?;
269 Ok(())
270}
271
272async fn unstage(working_dir: &Path, paths: &[String]) -> Result<()> {
276 if paths.is_empty() {
277 return Ok(());
278 }
279 let mut args = vec!["reset", "--"];
280 args.extend(paths.iter().map(|s| s.as_str()));
281 git_cmd(working_dir, &args, TIMEOUT_LOCAL).await?;
282 Ok(())
283}
284
285async fn commit_output(
287 working_dir: &Path,
288 message: &str,
289 dotfile_warnings: Vec<DotfileWarning>,
290 dotfile_skipped: Vec<String>,
291) -> Result<CommitOutput> {
292 let sha = git_cmd(working_dir, &["rev-parse", "HEAD"], TIMEOUT_LOCAL).await?;
293 let short_sha = sha[..7.min(sha.len())].to_string();
294 let files_changed = match git_cmd(
295 working_dir,
296 &["diff", "--name-only", "HEAD~1..HEAD"],
297 TIMEOUT_LOCAL,
298 )
299 .await
300 {
301 Ok(out) => out.lines().filter(|l| !l.is_empty()).count(),
302 Err(e) => {
303 tracing::warn!(error = %e, "git diff HEAD~1..HEAD failed (initial commit?)");
304 0
305 }
306 };
307 Ok(CommitOutput {
308 sha,
309 short_sha,
310 message: message.to_string(),
311 files_changed,
312 dotfile_warnings,
313 dotfile_skipped,
314 })
315}
316
317struct Classification {
319 stage: Vec<String>,
323 warnings: Vec<DotfileWarning>,
326 skipped: Vec<String>,
330}
331
332async fn classify_paths(
334 working_dir: &Path,
335 candidates: &[String],
336 force_dot: bool,
337) -> Result<Classification> {
338 let mut stage = Vec::new();
339 let mut warnings = Vec::new();
340 let mut skipped = Vec::new();
341
342 for p in candidates {
343 if force_dot || !is_dotfile_path(p) {
344 stage.push(p.clone());
345 continue;
346 }
347 let tracked = is_tracked(working_dir, p).await?;
349 if tracked {
350 stage.push(p.clone());
351 warnings.push(DotfileWarning {
352 path: p.clone(),
353 tracked: true,
354 in_gitignore: false,
355 });
356 } else if is_ignored(working_dir, p).await {
357 skipped.push(p.clone());
359 } else {
360 skipped.push(p.clone());
361 warnings.push(DotfileWarning {
362 path: p.clone(),
363 tracked: false,
364 in_gitignore: false,
365 });
366 }
367 }
368
369 Ok(Classification {
370 stage,
371 warnings,
372 skipped,
373 })
374}
375
376fn is_dotfile_path(p: &str) -> bool {
381 p.split('/')
382 .any(|c| c.starts_with('.') && c != "." && c != "..")
383}
384
385async fn enumerate_changes(working_dir: &Path) -> Result<Vec<String>> {
397 let mut cmd = tokio::process::Command::new("git");
398 cmd.args(["status", "--porcelain=v1", "-z", "--untracked-files=all"])
399 .current_dir(working_dir);
400 let output = spawn_output(&mut cmd, "status", TIMEOUT_LOCAL).await?;
401 if !output.status.success() {
402 let stderr = String::from_utf8_lossy(&output.stderr);
403 bail!("git status --porcelain: {}", stderr.trim());
404 }
405
406 let raw = String::from_utf8_lossy(&output.stdout);
407 let mut paths = Vec::new();
408 let mut it = raw.split('\0').peekable();
412 while let Some(rec) = it.next() {
413 if rec.len() < 4 {
414 continue;
415 }
416 let status = &rec[..2];
417 let path = &rec[3..];
418 if status.starts_with('R') || status.starts_with('C') {
419 it.next();
422 }
423 if !path.is_empty() {
424 paths.push(path.to_string());
425 }
426 }
427 Ok(paths)
428}
429
430async fn is_tracked(working_dir: &Path, path: &str) -> Result<bool> {
433 let out = git_cmd(working_dir, &["ls-files", "--", path], TIMEOUT_LOCAL).await?;
434 Ok(!out.trim().is_empty())
435}
436
437async fn is_ignored(working_dir: &Path, path: &str) -> bool {
445 let mut cmd = tokio::process::Command::new("git");
446 cmd.args(["check-ignore", "--quiet", "--", path])
447 .current_dir(working_dir);
448 match spawn_output(&mut cmd, "check-ignore", TIMEOUT_LOCAL).await {
449 Ok(o) => o.status.code() == Some(0),
450 Err(_) => false,
451 }
452}
453
454async fn stage_add_all(working_dir: &Path, paths: &[String]) -> Result<()> {
457 if paths.is_empty() {
458 return Ok(());
459 }
460 let mut args = vec!["add", "-A", "--"];
461 args.extend(paths.iter().map(|s| s.as_str()));
462 git_cmd(working_dir, &args, TIMEOUT_LOCAL).await?;
463 Ok(())
464}