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};
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(
126 &self,
127 working_dir: &Path,
128 message: &str,
129 only: Option<&[String]>,
130 other_staged: OtherStagedMode,
131 force_dot: bool,
132 ) -> Result<CommitOutput> {
133 self.ensure_session_scope(working_dir)?;
134
135 let (warnings, skipped) = match only {
136 None | Some([]) => {
137 let candidates = enumerate_changes(working_dir).await?;
138 if force_dot || !candidates.iter().any(|p| is_dotfile_path(p)) {
139 git_cmd(working_dir, &["add", "-A"], TIMEOUT_LOCAL).await?;
143 (Vec::new(), Vec::new())
144 } else {
145 let cls = classify_paths(working_dir, &candidates, false).await?;
146 stage_add_all(working_dir, &cls.stage).await?;
147 (cls.warnings, cls.skipped)
148 }
149 }
150 Some(ps) => {
151 let intruders = detect_other_staged(working_dir, ps).await?;
152 if !intruders.is_empty() {
153 match other_staged {
154 OtherStagedMode::Stop => {
155 bail!(
156 "commit aborted: index carries staged paths outside \
157 the requested set (other_staged=stop): {}",
158 intruders.join(", ")
159 );
160 }
161 OtherStagedMode::Restage => {
162 unstage(working_dir, &intruders).await?;
163 let cls = classify_paths(working_dir, ps, force_dot).await?;
164 stage(working_dir, &cls.stage).await?;
165 git_cmd(working_dir, &["commit", "-m", message], TIMEOUT_LOCAL).await?;
166 let output =
167 commit_output(working_dir, message, cls.warnings, cls.skipped)
168 .await;
169 stage(working_dir, &intruders).await?;
173 return output;
174 }
175 }
176 }
177 let cls = classify_paths(working_dir, ps, force_dot).await?;
178 stage(working_dir, &cls.stage).await?;
179 (cls.warnings, cls.skipped)
180 }
181 };
182
183 git_cmd(working_dir, &["commit", "-m", message], TIMEOUT_LOCAL).await?;
184 commit_output(working_dir, message, warnings, skipped).await
185 }
186
187 pub async fn merge(
190 &self,
191 branch: &str,
192 into_branch: &str,
193 working_dir: &Path,
194 ) -> Result<MergeOutput> {
195 self.ensure_session_scope(working_dir)?;
196
197 let current = git_cmd(working_dir, &["branch", "--show-current"], TIMEOUT_LOCAL).await?;
198 if current != into_branch {
199 git_cmd(working_dir, &["checkout", into_branch], TIMEOUT_LOCAL).await?;
200 }
201
202 let merge_message = format!("Merge branch '{}' into {}", branch, into_branch);
203 match git_cmd(
204 working_dir,
205 &["merge", "--no-ff", branch, "-m", &merge_message],
206 TIMEOUT_LOCAL,
207 )
208 .await
209 {
210 Ok(raw) => {
211 let sha = git_cmd(working_dir, &["rev-parse", "HEAD"], TIMEOUT_LOCAL).await?;
212 let short_sha = sha[..7.min(sha.len())].to_string();
213 Ok(MergeOutput {
214 branch: branch.to_string(),
215 into_branch: into_branch.to_string(),
216 sha,
217 short_sha,
218 raw,
219 })
220 }
221 Err(e) => {
222 let _ = git_cmd(working_dir, &["merge", "--abort"], TIMEOUT_LOCAL).await;
223 bail!("merge failed (aborted): {e}");
224 }
225 }
226 }
227
228 pub async fn branch_delete(&self, branch: &str) -> Result<BranchDeleteOutput> {
231 self.ensure_branch_owned(branch)?;
232 git_cmd(
233 self.session().root(),
234 &["branch", "-d", branch],
235 TIMEOUT_LOCAL,
236 )
237 .await?;
238 Ok(BranchDeleteOutput {
239 branch: branch.to_string(),
240 })
241 }
242}
243
244async fn detect_other_staged(working_dir: &Path, only: &[String]) -> Result<Vec<String>> {
247 let staged_raw = git_cmd(
248 working_dir,
249 &["diff", "--cached", "--name-only"],
250 TIMEOUT_LOCAL,
251 )
252 .await?;
253 let only_set: std::collections::HashSet<&str> = only.iter().map(|s| s.as_str()).collect();
254 Ok(staged_raw
255 .lines()
256 .filter(|l| !l.is_empty())
257 .filter(|l| !only_set.contains(*l))
258 .map(|s| s.to_string())
259 .collect())
260}
261
262async fn stage(working_dir: &Path, paths: &[String]) -> Result<()> {
264 if paths.is_empty() {
265 return Ok(());
266 }
267 let mut args = vec!["add", "--"];
268 args.extend(paths.iter().map(|s| s.as_str()));
269 git_cmd(working_dir, &args, TIMEOUT_LOCAL).await?;
270 Ok(())
271}
272
273async fn unstage(working_dir: &Path, paths: &[String]) -> Result<()> {
277 if paths.is_empty() {
278 return Ok(());
279 }
280 let mut args = vec!["reset", "--"];
281 args.extend(paths.iter().map(|s| s.as_str()));
282 git_cmd(working_dir, &args, TIMEOUT_LOCAL).await?;
283 Ok(())
284}
285
286async fn commit_output(
288 working_dir: &Path,
289 message: &str,
290 dotfile_warnings: Vec<DotfileWarning>,
291 dotfile_skipped: Vec<String>,
292) -> Result<CommitOutput> {
293 let sha = git_cmd(working_dir, &["rev-parse", "HEAD"], TIMEOUT_LOCAL).await?;
294 let short_sha = sha[..7.min(sha.len())].to_string();
295 let files_changed = match git_cmd(
296 working_dir,
297 &["diff", "--name-only", "HEAD~1..HEAD"],
298 TIMEOUT_LOCAL,
299 )
300 .await
301 {
302 Ok(out) => out.lines().filter(|l| !l.is_empty()).count(),
303 Err(e) => {
304 tracing::warn!(error = %e, "git diff HEAD~1..HEAD failed (initial commit?)");
305 0
306 }
307 };
308 Ok(CommitOutput {
309 sha,
310 short_sha,
311 message: message.to_string(),
312 files_changed,
313 dotfile_warnings,
314 dotfile_skipped,
315 })
316}
317
318struct Classification {
320 stage: Vec<String>,
324 warnings: Vec<DotfileWarning>,
327 skipped: Vec<String>,
331}
332
333async fn classify_paths(
335 working_dir: &Path,
336 candidates: &[String],
337 force_dot: bool,
338) -> Result<Classification> {
339 let mut stage = Vec::new();
340 let mut warnings = Vec::new();
341 let mut skipped = Vec::new();
342
343 for p in candidates {
344 if force_dot || !is_dotfile_path(p) {
345 stage.push(p.clone());
346 continue;
347 }
348 let tracked = is_tracked(working_dir, p).await?;
350 if tracked {
351 stage.push(p.clone());
352 warnings.push(DotfileWarning {
353 path: p.clone(),
354 tracked: true,
355 in_gitignore: false,
356 });
357 } else if is_ignored(working_dir, p).await {
358 skipped.push(p.clone());
360 } else {
361 skipped.push(p.clone());
362 warnings.push(DotfileWarning {
363 path: p.clone(),
364 tracked: false,
365 in_gitignore: false,
366 });
367 }
368 }
369
370 Ok(Classification {
371 stage,
372 warnings,
373 skipped,
374 })
375}
376
377fn 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 .kill_on_drop(true);
401 let output = match tokio::time::timeout(TIMEOUT_LOCAL, cmd.output()).await {
402 Ok(Ok(o)) => o,
403 Ok(Err(e)) => {
404 return Err(anyhow::Error::from(e)).context("failed to run git status --porcelain");
405 }
406 Err(_elapsed) => {
407 bail!("git status: timed out after {}s", TIMEOUT_LOCAL.as_secs());
408 }
409 };
410 if !output.status.success() {
411 let stderr = String::from_utf8_lossy(&output.stderr);
412 bail!("git status --porcelain: {}", stderr.trim());
413 }
414
415 let raw = String::from_utf8_lossy(&output.stdout);
416 let mut paths = Vec::new();
417 let mut it = raw.split('\0').peekable();
421 while let Some(rec) = it.next() {
422 if rec.len() < 4 {
423 continue;
424 }
425 let status = &rec[..2];
426 let path = &rec[3..];
427 if status.starts_with('R') || status.starts_with('C') {
428 it.next();
431 }
432 if !path.is_empty() {
433 paths.push(path.to_string());
434 }
435 }
436 Ok(paths)
437}
438
439async fn is_tracked(working_dir: &Path, path: &str) -> Result<bool> {
442 let out = git_cmd(working_dir, &["ls-files", "--", path], TIMEOUT_LOCAL).await?;
443 Ok(!out.trim().is_empty())
444}
445
446async fn is_ignored(working_dir: &Path, path: &str) -> bool {
450 let mut cmd = tokio::process::Command::new("git");
451 cmd.args(["check-ignore", "--quiet", "--", path])
452 .current_dir(working_dir)
453 .kill_on_drop(true);
454 match tokio::time::timeout(TIMEOUT_LOCAL, cmd.output()).await {
455 Ok(Ok(o)) => o.status.code() == Some(0),
456 Ok(Err(_)) => false,
458 Err(_elapsed) => false,
461 }
462}
463
464async fn stage_add_all(working_dir: &Path, paths: &[String]) -> Result<()> {
467 if paths.is_empty() {
468 return Ok(());
469 }
470 let mut args = vec!["add", "-A", "--"];
471 args.extend(paths.iter().map(|s| s.as_str()));
472 git_cmd(working_dir, &args, TIMEOUT_LOCAL).await?;
473 Ok(())
474}