1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
//! Git repository wrapper.
use std::path::Path;
use git2::Repository;
use crate::error::Result;
/// Metadata for a Git commit, used for amend detection.
#[derive(Debug, Clone)]
pub struct CommitMetadata {
/// Author's email address.
pub author_email: String,
/// First line of the commit message.
pub message_first_line: String,
/// Unix timestamp of when the commit was authored.
pub timestamp: i64,
}
/// Wrapper around git2::Repository for Git operations.
pub struct GitRepository {
repo: Repository,
}
impl GitRepository {
/// Open a Git repository at the given path.
pub fn open(path: &Path) -> Result<Self> {
let repo = Repository::discover(path)?;
Ok(Self { repo })
}
/// Get the path to the repository working directory.
///
/// This is the root directory containing `.git/`.
pub fn workdir(&self) -> Option<&Path> {
self.repo.workdir()
}
/// Get the path to the `.git` directory.
pub fn git_dir(&self) -> &Path {
self.repo.path()
}
/// Get the current branch name.
pub fn current_branch(&self) -> Result<String> {
let head = self.repo.head()?;
if head.is_branch() {
if let Some(name) = head.shorthand() {
return Ok(name.to_string());
}
}
// Detached HEAD - return the short hash
if let Some(oid) = head.target() {
return Ok(oid.to_string()[..7].to_string());
}
Ok("unknown".to_string())
}
/// Get the hash of the HEAD commit.
pub fn head_commit_hash(&self) -> Result<String> {
let head = self.repo.head()?;
let commit = head.peel_to_commit()?;
Ok(commit.id().to_string())
}
/// Get the user's email from git config.
pub fn config_user_email(&self) -> Result<Option<String>> {
let config = self.repo.config()?;
match config.get_string("user.email") {
Ok(email) => Ok(Some(email)),
Err(_) => Ok(None),
}
}
/// Get the user's name from git config.
pub fn config_user_name(&self) -> Result<Option<String>> {
let config = self.repo.config()?;
match config.get_string("user.name") {
Ok(name) => Ok(Some(name)),
Err(_) => Ok(None),
}
}
/// Check if the working directory is clean (no uncommitted changes).
pub fn is_clean(&self) -> Result<bool> {
let statuses = self.repo.statuses(None)?;
Ok(statuses.is_empty())
}
/// Get the list of staged files.
pub fn staged_files(&self) -> Result<Vec<String>> {
let statuses = self.repo.statuses(None)?;
let mut files = Vec::new();
for entry in statuses.iter() {
let status = entry.status();
if status.is_index_new()
|| status.is_index_modified()
|| status.is_index_deleted()
|| status.is_index_renamed()
{
if let Some(path) = entry.path() {
files.push(path.to_string());
}
}
}
Ok(files)
}
/// Check if there are staged changes ready to commit.
pub fn has_staged_changes(&self) -> Result<bool> {
Ok(!self.staged_files()?.is_empty())
}
/// Create a git commit with the staged changes.
///
/// # Arguments
///
/// * `message` - The commit message
///
/// # Returns
///
/// The hash of the newly created commit.
pub fn commit(&self, message: &str) -> Result<String> {
// Get the signature from git config
let sig = self.repo.signature()?;
// Get the current index
let mut index = self.repo.index()?;
// Write the index as a tree
let tree_id = index.write_tree()?;
let tree = self.repo.find_tree(tree_id)?;
// Get the parent commit (HEAD)
let parent = self.repo.head()?.peel_to_commit()?;
// Create the commit
let commit_id = self
.repo
.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])?;
Ok(commit_id.to_string())
}
/// Create an empty git commit (same tree as parent).
///
/// Used for Journal Entries when no code changes are present.
/// This is equivalent to `git commit --allow-empty`.
///
/// # Arguments
///
/// * `message` - The commit message
///
/// # Returns
///
/// The hash of the newly created (empty) commit.
pub fn commit_empty(&self, message: &str) -> Result<String> {
let sig = self.repo.signature()?;
// Get the parent commit and its tree
let parent = self.repo.head()?.peel_to_commit()?;
let tree = parent.tree()?;
// Create commit with same tree as parent (empty commit)
let commit_id = self
.repo
.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])?;
Ok(commit_id.to_string())
}
/// Stage files matching the given pathspecs.
pub fn stage_files(&self, pathspecs: &[&str]) -> Result<usize> {
let mut index = self.repo.index()?;
let mut count = 0;
index.add_all(
pathspecs.iter(),
git2::IndexAddOption::DEFAULT,
Some(&mut |path, _| {
count += 1;
println!(" add: {}", path.to_string_lossy());
0
}),
)?;
// Handle deleted files
index.update_all(pathspecs.iter(), None)?;
index.write()?;
Ok(count)
}
/// Check if there are changes outside the .agit/ directory.
///
/// This is used to detect "code changes" vs "memory-only changes".
pub fn has_code_changes(&self) -> Result<bool> {
let statuses = self.repo.statuses(None)?;
for entry in statuses.iter() {
if let Some(path) = entry.path() {
// Check if path is NOT under .agit/
if !path.starts_with(".agit/") && !path.starts_with(".agit\\") {
return Ok(true);
}
}
}
Ok(false)
}
/// Check if there are ONLY .agit/ directory changes.
///
/// Returns true if there are changes and ALL of them are under .agit/.
/// Returns false if there are no changes or if any changes are outside .agit/.
pub fn has_agit_only_changes(&self) -> Result<bool> {
let statuses = self.repo.statuses(None)?;
let mut has_agit_changes = false;
for entry in statuses.iter() {
if let Some(path) = entry.path() {
if path.starts_with(".agit/") || path.starts_with(".agit\\") {
has_agit_changes = true;
} else {
// Found a non-.agit change
return Ok(false);
}
}
}
Ok(has_agit_changes)
}
/// Check if we're currently in a merge state.
///
/// This is detected by the presence of .git/MERGE_HEAD file.
pub fn is_merging(&self) -> Result<bool> {
let merge_head_path = self.repo.path().join("MERGE_HEAD");
Ok(merge_head_path.exists())
}
/// Check if we're currently in a rebase state.
///
/// This is detected by the presence of .git/rebase-merge/ or .git/rebase-apply/ directories.
pub fn is_rebasing(&self) -> Result<bool> {
let rebase_merge_path = self.repo.path().join("rebase-merge");
let rebase_apply_path = self.repo.path().join("rebase-apply");
Ok(rebase_merge_path.exists() || rebase_apply_path.exists())
}
/// Check if we're in any conflicted state (merge or rebase in progress).
///
/// When in a conflicted state, Agit commands that modify the graph should be blocked.
pub fn is_in_conflicted_state(&self) -> Result<bool> {
Ok(self.is_merging()? || self.is_rebasing()?)
}
/// Get the MERGE_HEAD hash if in merge state.
///
/// Returns None if not in merge state.
pub fn merge_head_hash(&self) -> Result<Option<String>> {
let merge_head_path = self.repo.path().join("MERGE_HEAD");
if merge_head_path.exists() {
let content = std::fs::read_to_string(&merge_head_path)?;
Ok(Some(content.trim().to_string()))
} else {
Ok(None)
}
}
/// Get metadata for a specific commit.
///
/// This is used for amend detection - comparing commit properties
/// to detect if a commit is a rewritten version of another.
///
/// # Arguments
///
/// * `hash` - The commit hash to get metadata for
///
/// # Returns
///
/// `CommitMetadata` containing author email, first line of message, and timestamp.
pub fn get_commit_metadata(&self, hash: &str) -> Result<CommitMetadata> {
let oid = git2::Oid::from_str(hash)?;
let commit = self.repo.find_commit(oid)?;
let author = commit.author();
let author_email = author.email().unwrap_or("unknown@unknown.com").to_string();
let message = commit.message().unwrap_or("");
let message_first_line = message.lines().next().unwrap_or("").to_string();
let timestamp = author.when().seconds();
Ok(CommitMetadata {
author_email,
message_first_line,
timestamp,
})
}
/// Get the list of files changed between two commits.
///
/// This computes the diff between the trees of two commits and returns
/// the paths of all files that were added, modified, or deleted.
///
/// # Arguments
///
/// * `from_hash` - The starting commit hash (older commit)
/// * `to_hash` - The ending commit hash (newer commit)
///
/// # Returns
///
/// A vector of file paths that changed between the two commits.
pub fn diff_commits(&self, from_hash: &str, to_hash: &str) -> Result<Vec<String>> {
let from_oid = git2::Oid::from_str(from_hash)?;
let to_oid = git2::Oid::from_str(to_hash)?;
let from_commit = self.repo.find_commit(from_oid)?;
let to_commit = self.repo.find_commit(to_oid)?;
let from_tree = from_commit.tree()?;
let to_tree = to_commit.tree()?;
let diff = self
.repo
.diff_tree_to_tree(Some(&from_tree), Some(&to_tree), None)?;
let mut changed_files = Vec::new();
for delta in diff.deltas() {
// Get the new file path (or old path for deletions)
if let Some(path) = delta.new_file().path().or_else(|| delta.old_file().path()) {
if let Some(path_str) = path.to_str() {
changed_files.push(path_str.to_string());
}
}
}
Ok(changed_files)
}
/// Check if `ancestor` is reachable from `descendant` (i.e., ancestor is in history).
///
/// This is used to detect "dangling head" scenarios where Agit points to a
/// commit that no longer exists in Git's history (e.g., after `git reset --hard`).
///
/// # Arguments
///
/// * `ancestor` - The commit hash to check if it's an ancestor
/// * `descendant` - The commit hash to check if ancestor is reachable from
///
/// # Returns
///
/// `true` if ancestor is reachable from descendant, `false` otherwise.
pub fn is_ancestor(&self, ancestor: &str, descendant: &str) -> Result<bool> {
let ancestor_oid = git2::Oid::from_str(ancestor)?;
let descendant_oid = git2::Oid::from_str(descendant)?;
// graph_descendant_of returns true if `descendant` is a descendant of `ancestor`
Ok(self
.repo
.graph_descendant_of(descendant_oid, ancestor_oid)?)
}
/// Get the list of commits between two commit hashes (exclusive of from, inclusive of to).
///
/// This walks the commit history from `to_hash` back to `from_hash` and returns
/// all commit hashes in between (not including `from_hash`).
///
/// # Arguments
///
/// * `from_hash` - The older commit (exclusive - not included in result)
/// * `to_hash` - The newer commit (inclusive - included in result)
///
/// # Returns
///
/// A vector of commit hashes from newest to oldest.
pub fn commits_between(&self, from_hash: &str, to_hash: &str) -> Result<Vec<String>> {
let from_oid = git2::Oid::from_str(from_hash)?;
let to_oid = git2::Oid::from_str(to_hash)?;
let mut revwalk = self.repo.revwalk()?;
revwalk.push(to_oid)?;
revwalk.hide(from_oid)?;
let mut commits = Vec::new();
for oid_result in revwalk {
let oid = oid_result?;
commits.push(oid.to_string());
}
Ok(commits)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn create_test_repo() -> (TempDir, GitRepository) {
let temp = TempDir::new().unwrap();
// Initialize a git repository
Repository::init(temp.path()).unwrap();
// Create initial commit
let repo = Repository::open(temp.path()).unwrap();
// Configure git user for tests (required in CI environments)
let mut config = repo.config().unwrap();
config.set_str("user.name", "Test User").unwrap();
config.set_str("user.email", "test@example.com").unwrap();
let sig = git2::Signature::now("Test", "test@example.com").unwrap();
// Create a file
fs::write(temp.path().join("README.md"), "# Test").unwrap();
// Stage and commit
let mut index = repo.index().unwrap();
index.add_path(Path::new("README.md")).unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
.unwrap();
let git_repo = GitRepository::open(temp.path()).unwrap();
(temp, git_repo)
}
#[test]
fn test_current_branch() {
let (_temp, repo) = create_test_repo();
let branch = repo.current_branch().unwrap();
// Git 2.28+ defaults to "main", older versions use "master"
assert!(branch == "main" || branch == "master");
}
#[test]
fn test_head_commit_hash() {
let (_temp, repo) = create_test_repo();
let hash = repo.head_commit_hash().unwrap();
assert_eq!(hash.len(), 40); // SHA-1 hash
}
#[test]
fn test_is_clean() {
let (temp, repo) = create_test_repo();
assert!(repo.is_clean().unwrap());
// Create an untracked file
fs::write(temp.path().join("new.txt"), "content").unwrap();
assert!(!repo.is_clean().unwrap());
}
#[test]
fn test_commit() {
let (temp, git_repo) = create_test_repo();
// Create and stage a new file
fs::write(temp.path().join("new_file.txt"), "new content").unwrap();
let repo = Repository::open(temp.path()).unwrap();
let mut index = repo.index().unwrap();
index.add_path(Path::new("new_file.txt")).unwrap();
index.write().unwrap();
// Verify we have staged changes
assert!(git_repo.has_staged_changes().unwrap());
// Create a commit
let hash = git_repo.commit("Test commit message").unwrap();
assert_eq!(hash.len(), 40); // SHA-1 hash
// Verify the commit is now HEAD
assert_eq!(git_repo.head_commit_hash().unwrap(), hash);
// Verify no more staged changes
assert!(!git_repo.has_staged_changes().unwrap());
}
}