Skip to main content

loom_core/workspace/
reset.rs

1use anyhow::Result;
2
3use crate::git::GitRepo;
4use crate::manifest::{RepoManifestEntry, WorkspaceManifest};
5
6/// Result of resetting a workspace.
7pub struct ResetResult {
8    pub repos_reset: Vec<String>,
9    pub repos_failed: Vec<(String, String)>,
10}
11
12/// Reset all repos in a workspace to the workspace's base branch (or the repo's default branch).
13///
14/// For each repo: discard all changes, fetch origin, and rebase onto the
15/// base branch. If rebase fails, falls back to hard reset.
16pub fn reset_workspace(
17    manifest: &WorkspaceManifest,
18    on_progress: impl Fn(super::ProgressEvent),
19) -> Result<ResetResult> {
20    let mut repos_reset = Vec::new();
21    let mut repos_failed = Vec::new();
22    let total = manifest.repos.len();
23
24    for (i, repo) in manifest.repos.iter().enumerate() {
25        on_progress(super::ProgressEvent::RepoStarted {
26            name: repo.name.clone(),
27            index: i,
28            total,
29        });
30
31        match reset_repo(repo, manifest.base_branch.as_deref()) {
32            Ok(()) => {
33                repos_reset.push(repo.name.clone());
34                on_progress(super::ProgressEvent::RepoComplete {
35                    name: repo.name.clone(),
36                });
37            }
38            Err(e) => {
39                let msg = e.to_string();
40                repos_failed.push((repo.name.clone(), msg.clone()));
41                on_progress(super::ProgressEvent::RepoFailed {
42                    name: repo.name.clone(),
43                    error: msg,
44                });
45            }
46        }
47    }
48
49    Ok(ResetResult {
50        repos_reset,
51        repos_failed,
52    })
53}
54
55fn reset_repo(repo: &RepoManifestEntry, base_branch: Option<&str>) -> Result<()> {
56    let git = GitRepo::new(&repo.worktree_path);
57
58    // 1. Discard all changes (staged, unstaged, untracked)
59    git.reset_hard()?;
60    git.clean_untracked()?;
61
62    // 2. Fetch origin (non-fatal — stale refs just mean rebase uses older state)
63    if let Err(e) = git.fetch() {
64        tracing::warn!(repo = %repo.name, error = %e, "fetch failed, using local state");
65    }
66
67    // 3. Determine target branch: workspace base_branch > repo default > "main"
68    let branch = match base_branch {
69        Some(b) => b.to_string(),
70        None => git.default_branch().unwrap_or_else(|_| "main".to_string()),
71    };
72    let target = format!("origin/{branch}");
73
74    if git.ref_exists(&target).unwrap_or(false) {
75        // 4. Rebase onto origin/main; fall back to hard reset on conflict
76        if let Err(_e) = git.rebase(&target) {
77            git.rebase_abort().ok();
78            git.reset_hard_to(&target)?;
79        }
80    } else {
81        tracing::debug!(
82            repo = %repo.name,
83            target = %target,
84            "remote ref not found, skipping rebase"
85        );
86    }
87
88    Ok(())
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use crate::manifest::RepoManifestEntry;
95
96    fn create_git_repo(path: &std::path::Path) {
97        std::fs::create_dir_all(path).unwrap();
98        std::process::Command::new("git")
99            .args(["init", "-b", "main", &path.to_string_lossy()])
100            .env("LC_ALL", "C")
101            .output()
102            .unwrap();
103        std::process::Command::new("git")
104            .args([
105                "-C",
106                &path.to_string_lossy(),
107                "commit",
108                "--allow-empty",
109                "-m",
110                "init",
111            ])
112            .env("LC_ALL", "C")
113            .output()
114            .unwrap();
115    }
116
117    #[test]
118    fn test_reset_clean_repo() {
119        let dir = tempfile::tempdir().unwrap();
120        let repo_path = dir.path().join("my-repo");
121        create_git_repo(&repo_path);
122
123        let manifest = WorkspaceManifest {
124            name: "test-ws".to_string(),
125            branch: None,
126            created: chrono::Utc::now(),
127            base_branch: None,
128            preset: None,
129            repos: vec![RepoManifestEntry {
130                name: "my-repo".to_string(),
131                original_path: repo_path.clone(),
132                worktree_path: repo_path,
133                branch: "main".to_string(),
134                remote_url: String::new(),
135            }],
136        };
137
138        let result = reset_workspace(&manifest, |_| {}).unwrap();
139        assert_eq!(result.repos_reset.len(), 1);
140        assert!(result.repos_failed.is_empty());
141    }
142
143    #[test]
144    fn test_reset_dirty_repo() {
145        let dir = tempfile::tempdir().unwrap();
146        let repo_path = dir.path().join("my-repo");
147        create_git_repo(&repo_path);
148
149        // Make it dirty
150        std::fs::write(repo_path.join("dirty.txt"), "content").unwrap();
151
152        let git = GitRepo::new(&repo_path);
153        assert!(git.is_dirty().unwrap());
154
155        let manifest = WorkspaceManifest {
156            name: "test-ws".to_string(),
157            branch: None,
158            created: chrono::Utc::now(),
159            base_branch: None,
160            preset: None,
161            repos: vec![RepoManifestEntry {
162                name: "my-repo".to_string(),
163                original_path: repo_path.clone(),
164                worktree_path: repo_path.clone(),
165                branch: "main".to_string(),
166                remote_url: String::new(),
167            }],
168        };
169
170        let result = reset_workspace(&manifest, |_| {}).unwrap();
171        assert_eq!(result.repos_reset.len(), 1);
172
173        // Verify it's clean now
174        assert!(!git.is_dirty().unwrap());
175        assert!(!repo_path.join("dirty.txt").exists());
176    }
177}