1use std::collections::HashSet;
15use std::path::{Path, PathBuf};
16use std::process::Command;
17use std::sync::Arc;
18
19use anyhow::{Context, Result, bail};
20use lds_core::Session;
21
22pub mod output;
23mod read;
24mod remote;
25mod reset;
26mod session;
27mod write;
28
29pub use output::{
30 BranchDeleteOutput, BranchStatusOutput, CommitEntry, CommitOutput, DiffOutput, EntryStatus,
31 FetchOutput, IsPushedOutput, LogOutput, MergeOutput, RemoteEntry, RemoteListOutput, ResetMode,
32 ResetOutput, SessionReleaseOutput, StatusKind, StatusOutput, TagPushedOutput,
33 UnpushedCommitsOutput, WorktreeAddOutput, WorktreeEntry, WorktreeListOutput,
34 WorktreeRemoveOutput, WorktreeStateOutput,
35};
36
37#[derive(Debug)]
43pub struct GitModule {
44 session: Arc<Session>,
45 owned_worktrees: HashSet<PathBuf>,
47 owned_branches: HashSet<String>,
49}
50
51impl GitModule {
52 pub fn new(session: Arc<Session>) -> Self {
53 Self {
54 session,
55 owned_worktrees: HashSet::new(),
56 owned_branches: HashSet::new(),
57 }
58 }
59
60 pub fn register_worktree(&mut self, path: PathBuf) {
61 self.owned_worktrees.insert(path);
62 }
63
64 pub fn is_owned(&self, path: &PathBuf) -> bool {
65 self.owned_worktrees.contains(path)
66 }
67
68 pub fn ensure_owned(&self, path: &PathBuf) -> Result<()> {
69 if !self.is_owned(path) {
70 bail!(
71 "worktree not owned by this session ({}): {}",
72 self.session.id(),
73 path.display()
74 );
75 }
76 Ok(())
77 }
78
79 pub(crate) fn ensure_branch_owned(&self, branch: &str) -> Result<()> {
80 if !self.owned_branches.contains(branch) {
81 bail!(
82 "branch not owned by this session ({}): {}",
83 self.session.id(),
84 branch,
85 );
86 }
87 Ok(())
88 }
89
90 pub(crate) fn worktrees_dir(&self) -> PathBuf {
91 self.session.root().join(".worktrees")
92 }
93
94 pub(crate) fn ensure_session_scope(&self, working_dir: &Path) -> Result<()> {
95 if working_dir == self.session.root() {
96 return Ok(());
97 }
98 let canon = working_dir
99 .canonicalize()
100 .unwrap_or_else(|_| working_dir.to_path_buf());
101 if self.owned_worktrees.contains(&canon) {
102 return Ok(());
103 }
104 if self.owned_worktrees.contains(working_dir) {
105 return Ok(());
106 }
107 bail!(
108 "working_dir not owned by this session ({}): {}",
109 self.session.id(),
110 working_dir.display(),
111 );
112 }
113
114 pub(crate) fn session(&self) -> &Session {
115 &self.session
116 }
117
118 pub(crate) fn register_branch(&mut self, branch: String) {
119 self.owned_branches.insert(branch);
120 }
121
122 pub(crate) fn forget_worktree(&mut self, path: &Path) {
123 let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
124 self.owned_worktrees.remove(&canon);
125 self.owned_worktrees.remove(path);
126 }
127}
128
129pub(crate) fn git_cmd(cwd: &Path, args: &[&str]) -> Result<String> {
138 let output = Command::new("git")
139 .args(args)
140 .current_dir(cwd)
141 .output()
142 .with_context(|| format!("failed to run git {}", args.first().unwrap_or(&"")))?;
143
144 if !output.status.success() {
145 let stderr = String::from_utf8_lossy(&output.stderr);
146 bail!("git {}: {}", args.first().unwrap_or(&""), stderr.trim());
147 }
148 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
149}
150
151pub(crate) fn git_cmd_combined(cwd: &Path, args: &[&str]) -> Result<String> {
154 let output = Command::new("git")
155 .args(args)
156 .current_dir(cwd)
157 .output()
158 .with_context(|| format!("failed to run git {}", args.first().unwrap_or(&"")))?;
159
160 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
161 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
162
163 if !output.status.success() {
164 let combined = if stderr.is_empty() { stdout } else { stderr };
165 bail!("git {}: {}", args.first().unwrap_or(&""), combined);
166 }
167
168 Ok(match (stdout.is_empty(), stderr.is_empty()) {
169 (true, true) => String::new(),
170 (false, true) => stdout,
171 (true, false) => stderr,
172 (false, false) => format!("{stdout}\n{stderr}"),
173 })
174}