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_primary_repo_root, resolve_repo_name,
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_primary_repo_root(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 = options
78                .repo_root
79                .map(|p| p.to_path_buf())
80                .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
81            let canonical_cwd = cwd.canonicalize().unwrap_or(cwd);
82            let found = registry.slots.iter().find(|s| {
83                let p = PathBuf::from(&s.path);
84                let canonical_p = p.canonicalize().unwrap_or(p);
85                canonical_cwd == canonical_p || canonical_cwd.starts_with(&canonical_p)
86            });
87
88            match found {
89                Some(s) => s.name.clone(),
90                None => {
91                    return Err(WorkflowError::CannotInferWorktree(
92                        canonical_cwd.to_string_lossy().to_string(),
93                    ));
94                }
95            }
96        }
97    };
98
99    let slot = registry
100        .find_by_name(&target_name)
101        .cloned()
102        .ok_or_else(|| WorkflowError::WorktreeNotFound(target_name.clone()))?;
103
104    let worktree_path = PathBuf::from(&slot.path);
105
106    // Prepare environment variables for hooks
107    let mut hook_env = BTreeMap::new();
108    hook_env.insert("CLAW_SLOT_ID".to_string(), slot.id.to_string());
109    hook_env.insert("CLAW_WORKTREE_NAME".to_string(), slot.name.clone());
110    hook_env.insert("CLAW_WORKTREE_PATH".to_string(), slot.path.clone());
111
112    // 1. Run pre_finish hook (abort on failure unless force is true)
113    run_pre_finish_hook(
114        config.hooks.pre_finish.as_deref(),
115        &worktree_path,
116        &hook_env,
117        options.force,
118    )?;
119
120    // 2. Verify primary working tree is clean before merging
121    if !git_is_clean(&toplevel)? {
122        return Err(WorkflowError::DirtyWorkingTree);
123    }
124
125    // 3. Checkout main and merge worktree branch
126    git_checkout(&toplevel, config.main_branch())?;
127    git_merge(&toplevel, &slot.branch)?;
128
129    // 4. Force remove worktree directory (ignoring untracked generated files)
130    remove_worktree(&toplevel, &worktree_path, true)?;
131
132    // 5. Delete the merged branch
133    delete_branch(&toplevel, &slot.branch, false)?;
134
135    // Teardown remote branch if published and not opted out
136    if !options.keep_remote {
137        let remote = config.remote().unwrap_or("origin");
138        let remotes = git_get_remotes(&toplevel).unwrap_or_default();
139        if remotes.iter().any(|r| r == remote) {
140            match git_remote_branch_exists(&toplevel, remote, &slot.branch) {
141                Ok(true) => {
142                    if let Err(e) = git_delete_remote_branch(&toplevel, remote, &slot.branch) {
143                        print_warning(format!(
144                            "Failed to delete remote branch '{}' from '{}': {}",
145                            slot.branch, remote, e
146                        ));
147                    } else {
148                        print_success(format!(
149                            "Remote branch '{}' deleted from '{}'",
150                            slot.branch, remote
151                        ));
152                    }
153                }
154                Ok(false) => {}
155                Err(e) => {
156                    print_warning(format!(
157                        "Could not check remote branch '{}' on '{}': {}",
158                        slot.branch, remote, e
159                    ));
160                }
161            }
162        }
163    }
164
165    // 6. Release Slot ID in registry
166    registry.remove_by_id(slot.id);
167    registry.save_atomic(&registry_path)?;
168
169    // Release lock before running post-finish hook
170    drop(_lock);
171
172    // 7. Run post_finish hook
173    let _ = run_post_finish_hook(config.hooks.post_finish.as_deref(), &toplevel, &hook_env);
174
175    // Record target primary root for shell auto-cd integration
176    let _ = std::fs::write(
177        "/tmp/.git_claw_target_cd",
178        toplevel.to_string_lossy().as_bytes(),
179    );
180
181    print_success(format!(
182        "Worktree '{}' (Slot {}) successfully merged into '{}' and cleaned up",
183        slot.name,
184        slot.id,
185        config.main_branch()
186    ));
187
188    Ok(())
189}