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