Skip to main content

git_claw/workflow/
finish.rs

1//! Finish worktree lifecycle orchestration.
2
3use 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/// Options for finishing and merging a worktree.
18///
19/// # Example
20///
21/// ```
22/// use git_claw::workflow::FinishOptions;
23///
24/// let options = FinishOptions {
25///     name: Some("login"),
26///     force: false,
27///     keep_remote: false,
28///     repo_root: None,
29/// };
30/// assert_eq!(options.name, Some("login"));
31/// assert!(!options.keep_remote);
32/// ```
33#[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
41/// Orchestrates finishing and merging a worktree following the AD-8 teardown order.
42///
43/// # Example
44///
45/// ```no_run
46/// use std::path::Path;
47/// use git_claw::workflow::{finish_worktree, FinishOptions};
48///
49/// # fn run() -> Result<(), git_claw::workflow::WorkflowError> {
50/// finish_worktree(FinishOptions {
51///     name: Some("login"),
52///     force: false,
53///     keep_remote: false,
54///     repo_root: Some(Path::new(".")),
55/// })?;
56/// # Ok(())
57/// # }
58/// ```
59pub 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(&registry_path)?;
72
73    // Infer worktree name if not provided
74    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    // Prepare environment variables for hooks
103    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    // 1. Run pre_finish hook (abort on failure unless force is true)
109    run_pre_finish_hook(
110        config.hooks.pre_finish.as_deref(),
111        &worktree_path,
112        &hook_env,
113        options.force,
114    )?;
115
116    // 2. Verify primary working tree is clean before merging
117    if !git_is_clean(&toplevel)? {
118        return Err(WorkflowError::DirtyWorkingTree);
119    }
120
121    // 3. Checkout main and merge worktree branch
122    git_checkout(&toplevel, config.main_branch())?;
123    git_merge(&toplevel, &slot.branch)?;
124
125    // 4. Force remove worktree directory (ignoring untracked generated files)
126    remove_worktree(&toplevel, &worktree_path, true)?;
127
128    // 5. Delete the merged branch
129    delete_branch(&toplevel, &slot.branch, false)?;
130
131    // Teardown remote branch if published and not opted out
132    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    // 6. Release Slot ID in registry
162    registry.remove_by_id(slot.id);
163    registry.save_atomic(&registry_path)?;
164
165    // Release lock before running post-finish hook
166    drop(_lock);
167
168    // 7. Run post_finish hook
169    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}