git_claw/workflow/
finish.rs1use crate::cli::output::{print_success, print_warning};
4use crate::core::config::Config;
5use crate::infra::git::{
6 git_checkout, git_delete_remote_branch, git_get_remotes, git_is_clean, git_merge,
7 git_remote_branch_exists, resolve_git_common_dir, resolve_repo_name, resolve_toplevel,
8};
9use crate::infra::hook::{run_post_finish_hook, run_pre_finish_hook};
10use crate::infra::lock::SlotLock;
11use crate::infra::registry::SlotRegistry;
12use crate::infra::worktree::{delete_branch, remove_worktree};
13use crate::workflow::error::WorkflowError;
14use std::collections::BTreeMap;
15use std::path::{Path, PathBuf};
16
17#[derive(Debug, Clone, Default)]
34pub struct FinishOptions<'a> {
35 pub name: Option<&'a str>,
36 pub force: bool,
37 pub keep_remote: bool,
38 pub repo_root: Option<&'a Path>,
39}
40
41pub fn finish_worktree(options: FinishOptions<'_>) -> Result<(), WorkflowError> {
60 let toplevel = resolve_toplevel(options.repo_root)?;
61 let common_dir = resolve_git_common_dir(Some(&toplevel))?;
62 let repo_name = resolve_repo_name(&toplevel);
63
64 let config_path = toplevel.join(".git-claw.toml");
65 let config = Config::load_or_default(&config_path, &repo_name)?;
66
67 let registry_path = common_dir.join("claw/slots.json");
68 let lock_path = common_dir.join("claw/slots.lock");
69
70 let _lock = SlotLock::acquire(&lock_path)?;
71 let mut registry = SlotRegistry::load_or_empty(®istry_path)?;
72
73 let target_name = match options.name {
75 Some(n) => n.to_string(),
76 None => {
77 let cwd = std::env::current_dir().unwrap_or_default();
78 let canonical_cwd = cwd.canonicalize().unwrap_or(cwd);
79 let found = registry.slots.iter().find(|s| {
80 let p = PathBuf::from(&s.path);
81 p.canonicalize().unwrap_or(p) == canonical_cwd
82 });
83
84 match found {
85 Some(s) => s.name.clone(),
86 None => {
87 return Err(WorkflowError::CannotInferWorktree(
88 canonical_cwd.to_string_lossy().to_string(),
89 ));
90 }
91 }
92 }
93 };
94
95 let slot = registry
96 .find_by_name(&target_name)
97 .cloned()
98 .ok_or_else(|| WorkflowError::WorktreeNotFound(target_name.clone()))?;
99
100 let worktree_path = PathBuf::from(&slot.path);
101
102 let mut hook_env = BTreeMap::new();
104 hook_env.insert("CLAW_SLOT_ID".to_string(), slot.id.to_string());
105 hook_env.insert("CLAW_WORKTREE_NAME".to_string(), slot.name.clone());
106 hook_env.insert("CLAW_WORKTREE_PATH".to_string(), slot.path.clone());
107
108 run_pre_finish_hook(
110 config.hooks.pre_finish.as_deref(),
111 &worktree_path,
112 &hook_env,
113 options.force,
114 )?;
115
116 if !git_is_clean(&toplevel)? {
118 return Err(WorkflowError::DirtyWorkingTree);
119 }
120
121 git_checkout(&toplevel, config.main_branch())?;
123 git_merge(&toplevel, &slot.branch)?;
124
125 remove_worktree(&toplevel, &worktree_path, true)?;
127
128 delete_branch(&toplevel, &slot.branch, false)?;
130
131 if !options.keep_remote {
133 let remote = config.remote().unwrap_or("origin");
134 let remotes = git_get_remotes(&toplevel).unwrap_or_default();
135 if remotes.iter().any(|r| r == remote) {
136 match git_remote_branch_exists(&toplevel, remote, &slot.branch) {
137 Ok(true) => {
138 if let Err(e) = git_delete_remote_branch(&toplevel, remote, &slot.branch) {
139 print_warning(format!(
140 "Failed to delete remote branch '{}' from '{}': {}",
141 slot.branch, remote, e
142 ));
143 } else {
144 print_success(format!(
145 "Remote branch '{}' deleted from '{}'",
146 slot.branch, remote
147 ));
148 }
149 }
150 Ok(false) => {}
151 Err(e) => {
152 print_warning(format!(
153 "Could not check remote branch '{}' on '{}': {}",
154 slot.branch, remote, e
155 ));
156 }
157 }
158 }
159 }
160
161 registry.remove_by_id(slot.id);
163 registry.save_atomic(®istry_path)?;
164
165 drop(_lock);
167
168 let _ = run_post_finish_hook(config.hooks.post_finish.as_deref(), &toplevel, &hook_env);
170
171 print_success(format!(
172 "Worktree '{}' (Slot {}) successfully merged into '{}' and cleaned up",
173 slot.name,
174 slot.id,
175 config.main_branch()
176 ));
177
178 Ok(())
179}