1use std::path::Path;
9
10use anyhow::{Context, Result, bail};
11
12use crate::output::{
13 BranchDeleteOutput, CommitOutput, MergeOutput, OtherStagedMode, WorktreeAddOutput,
14 WorktreeRemoveOutput,
15};
16use crate::{GitModule, git_cmd};
17
18impl GitModule {
19 pub fn worktree_add(
21 &mut self,
22 name: &str,
23 branch: &str,
24 base_branch: Option<&str>,
25 ) -> Result<WorktreeAddOutput> {
26 let wt_dir = self.worktrees_dir();
27 std::fs::create_dir_all(&wt_dir).context("failed to create .worktrees directory")?;
28
29 let wt_path = wt_dir.join(name);
30 if wt_path.exists() {
31 bail!("worktree already exists: {}", wt_path.display());
32 }
33
34 let path_str = wt_path.to_str().unwrap_or(name);
35 if let Some(base) = base_branch {
36 git_cmd(
37 self.session().root(),
38 &["worktree", "add", "-b", branch, path_str, base],
39 )?;
40 } else {
41 git_cmd(
42 self.session().root(),
43 &["worktree", "add", "-b", branch, path_str],
44 )?;
45 }
46
47 let canon = wt_path.canonicalize().unwrap_or_else(|_| wt_path.clone());
48 self.register_worktree(canon);
49 self.register_branch(branch.to_string());
50
51 Ok(WorktreeAddOutput {
52 path: wt_path,
53 branch: branch.to_string(),
54 session: self.session().id().to_string(),
55 })
56 }
57
58 pub fn worktree_remove(&mut self, name: &str) -> Result<WorktreeRemoveOutput> {
60 let wt_path = self.worktrees_dir().join(name);
61 let canon = wt_path.canonicalize().unwrap_or_else(|_| wt_path.clone());
62
63 self.ensure_owned(&canon)
64 .or_else(|_| self.ensure_owned(&wt_path))?;
65
66 git_cmd(
67 self.session().root(),
68 &[
69 "worktree",
70 "remove",
71 "--force",
72 wt_path.to_str().unwrap_or(name),
73 ],
74 )?;
75
76 self.forget_worktree(&canon);
77 self.forget_worktree(&wt_path);
78
79 Ok(WorktreeRemoveOutput { path: wt_path })
80 }
81
82 pub fn commit(
100 &self,
101 working_dir: &Path,
102 message: &str,
103 only: Option<&[String]>,
104 other_staged: OtherStagedMode,
105 ) -> Result<CommitOutput> {
106 self.ensure_session_scope(working_dir)?;
107
108 match only {
109 None | Some([]) => {
110 git_cmd(working_dir, &["add", "-A"])?;
111 }
112 Some(ps) => {
113 let intruders = detect_other_staged(working_dir, ps)?;
114 if !intruders.is_empty() {
115 match other_staged {
116 OtherStagedMode::Stop => {
117 bail!(
118 "commit aborted: index carries staged paths outside \
119 the requested set (other_staged=stop): {}",
120 intruders.join(", ")
121 );
122 }
123 OtherStagedMode::Restage => {
124 unstage(working_dir, &intruders)?;
125 stage(working_dir, ps)?;
126 git_cmd(working_dir, &["commit", "-m", message])?;
127 let output = commit_output(working_dir, message);
128 stage(working_dir, &intruders)?;
132 return output;
133 }
134 }
135 }
136 stage(working_dir, ps)?;
137 }
138 }
139
140 git_cmd(working_dir, &["commit", "-m", message])?;
141 commit_output(working_dir, message)
142 }
143
144 pub fn merge(
147 &self,
148 branch: &str,
149 into_branch: &str,
150 working_dir: &Path,
151 ) -> Result<MergeOutput> {
152 self.ensure_session_scope(working_dir)?;
153
154 let current = git_cmd(working_dir, &["branch", "--show-current"])?;
155 if current != into_branch {
156 git_cmd(working_dir, &["checkout", into_branch])?;
157 }
158
159 let merge_message = format!("Merge branch '{}' into {}", branch, into_branch);
160 match git_cmd(
161 working_dir,
162 &["merge", "--no-ff", branch, "-m", &merge_message],
163 ) {
164 Ok(raw) => {
165 let sha = git_cmd(working_dir, &["rev-parse", "HEAD"])?;
166 let short_sha = sha[..7.min(sha.len())].to_string();
167 Ok(MergeOutput {
168 branch: branch.to_string(),
169 into_branch: into_branch.to_string(),
170 sha,
171 short_sha,
172 raw,
173 })
174 }
175 Err(e) => {
176 let _ = git_cmd(working_dir, &["merge", "--abort"]);
177 bail!("merge failed (aborted): {e}");
178 }
179 }
180 }
181
182 pub fn branch_delete(&self, branch: &str) -> Result<BranchDeleteOutput> {
185 self.ensure_branch_owned(branch)?;
186 git_cmd(self.session().root(), &["branch", "-d", branch])?;
187 Ok(BranchDeleteOutput {
188 branch: branch.to_string(),
189 })
190 }
191}
192
193fn detect_other_staged(working_dir: &Path, only: &[String]) -> Result<Vec<String>> {
196 let staged_raw = git_cmd(working_dir, &["diff", "--cached", "--name-only"])?;
197 let only_set: std::collections::HashSet<&str> = only.iter().map(|s| s.as_str()).collect();
198 Ok(staged_raw
199 .lines()
200 .filter(|l| !l.is_empty())
201 .filter(|l| !only_set.contains(*l))
202 .map(|s| s.to_string())
203 .collect())
204}
205
206fn stage(working_dir: &Path, paths: &[String]) -> Result<()> {
208 if paths.is_empty() {
209 return Ok(());
210 }
211 let mut args = vec!["add", "--"];
212 args.extend(paths.iter().map(|s| s.as_str()));
213 git_cmd(working_dir, &args)?;
214 Ok(())
215}
216
217fn unstage(working_dir: &Path, paths: &[String]) -> Result<()> {
221 if paths.is_empty() {
222 return Ok(());
223 }
224 let mut args = vec!["reset", "--"];
225 args.extend(paths.iter().map(|s| s.as_str()));
226 git_cmd(working_dir, &args)?;
227 Ok(())
228}
229
230fn commit_output(working_dir: &Path, message: &str) -> Result<CommitOutput> {
232 let sha = git_cmd(working_dir, &["rev-parse", "HEAD"])?;
233 let short_sha = sha[..7.min(sha.len())].to_string();
234 let files_changed = git_cmd(working_dir, &["diff", "--name-only", "HEAD~1..HEAD"])
235 .map(|out| out.lines().filter(|l| !l.is_empty()).count())
236 .unwrap_or_else(|e| {
237 tracing::warn!(error = %e, "git diff HEAD~1..HEAD failed (initial commit?)");
238 0
239 });
240 Ok(CommitOutput {
241 sha,
242 short_sha,
243 message: message.to_string(),
244 files_changed,
245 })
246}