rusty-commit 1.0.27

Rust-powered AI commit message generator - Write impressive commits in seconds
Documentation
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
//! Git repository operations and utilities.
//!
//! This module provides functions for interacting with Git repositories,
//! including staging files, getting diffs, and querying repository status.

use anyhow::{Context, Result};
use git2::{DiffOptions, Repository, StatusOptions};
use std::process::Command;

/// Ensures the current directory is within a Git repository.
///
/// # Errors
///
/// Returns an error if the current directory is not within a Git repository.
///
/// # Examples
///
/// ```no_run
/// use rusty_commit::git;
///
/// git::assert_git_repo().expect("Not in a git repository");
/// ```
pub fn assert_git_repo() -> Result<()> {
    Repository::open_from_env().context(
        "Not in a git repository. Please run this command from within a git repository.",
    )?;
    Ok(())
}

/// Returns a list of files that are currently staged for commit.
///
/// # Errors
///
/// Returns an error if the repository cannot be accessed.
///
/// # Examples
///
/// ```no_run
/// use rusty_commit::git;
///
/// let staged = git::get_staged_files().unwrap();
/// for file in staged {
///     println!("Staged: {}", file);
/// }
/// ```
pub fn get_staged_files() -> Result<Vec<String>> {
    let repo = Repository::open_from_env()?;
    let mut status_opts = StatusOptions::new();
    status_opts.include_untracked(false);

    let statuses = repo.statuses(Some(&mut status_opts))?;

    let mut staged_files = Vec::new();
    for entry in statuses.iter() {
        let status = entry.status();
        if status.contains(git2::Status::INDEX_NEW)
            || status.contains(git2::Status::INDEX_MODIFIED)
            || status.contains(git2::Status::INDEX_DELETED)
            || status.contains(git2::Status::INDEX_RENAMED)
            || status.contains(git2::Status::INDEX_TYPECHANGE)
        {
            if let Some(path) = entry.path() {
                staged_files.push(path.to_string());
            }
        }
    }

    Ok(staged_files)
}

/// Returns a list of all changed files (staged, modified, and untracked).
///
/// # Errors
///
/// Returns an error if the repository cannot be accessed.
///
/// # Examples
///
/// ```no_run
/// use rusty_commit::git;
///
/// let changed = git::get_changed_files().unwrap();
/// println!("Found {} changed files", changed.len());
/// ```
pub fn get_changed_files() -> Result<Vec<String>> {
    let repo = Repository::open_from_env()?;
    let mut status_opts = StatusOptions::new();
    status_opts.include_untracked(true);

    let statuses = repo.statuses(Some(&mut status_opts))?;

    let mut changed_files = Vec::new();
    for entry in statuses.iter() {
        let status = entry.status();
        // Include files that are modified in working tree or untracked, but not ignored
        if !status.contains(git2::Status::IGNORED) && !status.is_empty() {
            if let Some(path) = entry.path() {
                changed_files.push(path.to_string());
            }
        }
    }

    Ok(changed_files)
}

/// Stages the specified files for commit.
///
/// # Arguments
///
/// * `files` - A slice of file paths to stage
///
/// # Errors
///
/// Returns an error if the git add command fails.
///
/// # Examples
///
/// ```no_run
/// use rusty_commit::git;
///
/// let files = vec!["src/main.rs".to_string(), "Cargo.toml".to_string()];
/// git::stage_files(&files).unwrap();
/// ```
pub fn stage_files(files: &[String]) -> Result<()> {
    if files.is_empty() {
        return Ok(());
    }

    let output = Command::new("git")
        .arg("add")
        .args(files)
        .output()
        .context("Failed to stage files")?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        anyhow::bail!("Failed to stage files: {}", stderr);
    }

    Ok(())
}

/// Returns the diff of all staged changes.
///
/// This compares the staging area (index) with HEAD to show what will be committed.
///
/// # Errors
///
/// Returns an error if the diff cannot be generated.
///
/// # Examples
///
/// ```no_run
/// use rusty_commit::git;
///
/// let diff = git::get_staged_diff().unwrap();
/// println!("Staged changes:\n{}", diff);
/// ```
pub fn get_staged_diff() -> Result<String> {
    let repo = Repository::open_from_env()?;

    // Get HEAD tree
    let head = repo.head()?;
    let head_tree = head.peel_to_tree()?;

    // Get index (staging area)
    let mut index = repo.index()?;
    let oid = index.write_tree()?;
    let index_tree = repo.find_tree(oid)?;

    // Create diff between HEAD and index
    let mut diff_opts = DiffOptions::new();
    let diff = repo.diff_tree_to_tree(Some(&head_tree), Some(&index_tree), Some(&mut diff_opts))?;

    // Convert diff to string
    let mut diff_text = String::new();
    diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
        // Use lossy conversion to preserve content even with invalid UTF-8
        let content = String::from_utf8_lossy(line.content());
        diff_text.push_str(&content);
        true
    })?;

    Ok(diff_text)
}

/// Returns the absolute path to the repository root.
///
/// # Errors
///
/// Returns an error if not in a Git repository or if the path cannot be determined.
///
/// # Examples
///
/// ```no_run
/// use rusty_commit::git;
///
/// let root = git::get_repo_root().unwrap();
/// println!("Repository root: {}", root);
/// ```
pub fn get_repo_root() -> Result<String> {
    let repo = Repository::open_from_env()?;
    let workdir = repo
        .workdir()
        .context("Could not find repository working directory")?;
    Ok(workdir.to_string_lossy().to_string())
}

/// Returns the current branch name.
///
/// # Errors
///
/// Returns an error if the repository has no HEAD or if the branch name cannot be determined.
///
/// # Examples
///
/// ```no_run
/// use rusty_commit::git;
///
/// let branch = git::get_current_branch().unwrap();
/// println!("Current branch: {}", branch);
/// ```
pub fn get_current_branch() -> Result<String> {
    let repo = Repository::open_from_env()?;
    let head = repo.head()?;
    let branch_name = head
        .shorthand()
        .context("Could not get current branch name")?
        .to_string();
    Ok(branch_name)
}

/// Returns a list of commit hashes and messages between two branches.
///
/// The output format is `"<hash> - <message>"` for each commit, with the hash
/// truncated to 7 characters.
///
/// # Arguments
///
/// * `base` - The base branch/commit (exclusive)
/// * `head` - The head branch/commit (inclusive)
///
/// # Errors
///
/// Returns an error if the branches cannot be parsed or the repository cannot be accessed.
///
/// # Examples
///
/// ```no_run
/// use rusty_commit::git;
///
/// let commits = git::get_commits_between("main", "feature-branch").unwrap();
/// for commit in commits {
///     println!("{}", commit);
/// }
/// ```
pub fn get_commits_between(base: &str, head: &str) -> Result<Vec<String>> {
    let repo = Repository::open_from_env()?;

    let base_commit = repo.revparse_single(base)?;
    let head_commit = repo.revparse_single(head)?;

    let mut revwalk = repo.revwalk()?;
    revwalk.push(head_commit.id())?;
    revwalk.hide(base_commit.id())?;

    let mut commits = Vec::new();
    for oid in revwalk {
        let oid = oid?;
        if let Ok(commit) = repo.find_commit(oid) {
            commits.push(format!(
                "{} - {}",
                commit.id().to_string().chars().take(7).collect::<String>(),
                commit.message().unwrap_or("")
            ));
        }
    }

    Ok(commits)
}

/// Returns the diff between two branches or commits.
///
/// # Arguments
///
/// * `base` - The base branch/commit
/// * `head` - The head branch/commit to compare against base
///
/// # Errors
///
/// Returns an error if the commits cannot be parsed or the diff cannot be generated.
///
/// # Examples
///
/// ```no_run
/// use rusty_commit::git;
///
/// let diff = git::get_diff_between("main", "HEAD").unwrap();
/// println!("{}", diff);
/// ```
pub fn get_diff_between(base: &str, head: &str) -> Result<String> {
    let repo = Repository::open_from_env()?;

    let base_commit = repo.revparse_single(base)?;
    let head_commit = repo.revparse_single(head)?;

    let base_tree = base_commit
        .as_tree()
        .ok_or(anyhow::anyhow!("Failed to get base commit tree"))?;
    let head_tree = head_commit
        .as_tree()
        .ok_or(anyhow::anyhow!("Failed to get head commit tree"))?;

    let mut diff_opts = DiffOptions::new();
    let diff = repo.diff_tree_to_tree(Some(base_tree), Some(head_tree), Some(&mut diff_opts))?;

    let mut diff_text = String::new();
    diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
        // Use lossy conversion to preserve content even with invalid UTF-8
        let content = String::from_utf8_lossy(line.content());
        diff_text.push_str(&content);
        true
    })?;

    Ok(diff_text)
}

/// Returns the remote URL for the specified remote.
///
/// # Arguments
///
/// * `remote_name` - The name of the remote (e.g., "origin", "upstream"). Defaults to "origin" if None.
///
/// # Errors
///
/// Returns an error if the specified remote does not exist or if the URL cannot be retrieved.
///
/// # Examples
///
/// ```no_run
/// use rusty_commit::git;
///
/// // Get URL for "origin" remote (default)
/// let url = git::get_remote_url(None).unwrap();
///
/// // Get URL for "upstream" remote
/// let url = git::get_remote_url(Some("upstream")).unwrap();
/// println!("Remote URL: {}", url);
/// ```
pub fn get_remote_url(remote_name: Option<&str>) -> Result<String> {
    let repo = Repository::open_from_env()?;
    let remote = repo.find_remote(remote_name.unwrap_or("origin"))?;
    let url = remote
        .url()
        .context("Could not get remote URL")?
        .to_string();

    Ok(url)
}

/// Pushes the current branch to the remote repository.
///
/// # Arguments
///
/// * `remote` - The name of the remote to push to (e.g., "origin")
/// * `branch` - The name of the branch to push
///
/// # Errors
///
/// Returns an error if the push fails or if the repository cannot be accessed.
///
/// # Examples
///
/// ```no_run
/// use rusty_commit::git;
///
/// git::git_push("origin", "main").unwrap();
/// ```
pub fn git_push(remote: &str, branch: &str) -> Result<()> {
    let output = Command::new("git")
        .args(["push", remote, branch])
        .output()
        .context("Failed to execute git push")?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        anyhow::bail!("Git push failed: {}", stderr);
    }

    Ok(())
}

/// Pushes the current branch to its upstream remote.
///
/// # Errors
///
/// Returns an error if the push fails, if there is no upstream configured,
/// or if the repository cannot be accessed.
///
/// # Examples
///
/// ```no_run
/// use rusty_commit::git;
///
/// git::git_push_upstream().unwrap();
/// ```
#[allow(dead_code)]
pub fn git_push_upstream() -> Result<()> {
    let output = Command::new("git")
        .args(["push", "--set-upstream"])
        .output()
        .context("Failed to execute git push")?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        anyhow::bail!("Git push failed: {}", stderr);
    }

    Ok(())
}

/// Returns recent commit messages for style analysis.
///
/// # Arguments
///
/// * `count` - Maximum number of recent commit messages to retrieve
///
/// # Errors
///
/// Returns an error if the repository cannot be accessed or has no commits.
///
/// # Examples
///
/// ```no_run
/// use rusty_commit::git;
///
/// let messages = git::get_recent_commit_messages(5).unwrap();
/// for msg in messages {
///     println!("{}", msg);
/// }
/// ```
pub fn get_recent_commit_messages(count: usize) -> Result<Vec<String>> {
    let repo = Repository::open_from_env()?;

    // Get HEAD reference
    let head = repo.head()?;

    // Get the commit
    let commit = head.peel_to_commit()?;

    // Walk back through history, following all parents (handles merge commits)
    let mut commits = Vec::new();
    let mut queue = vec![commit];

    while let Some(c) = queue.pop() {
        if commits.len() >= count {
            break;
        }

        if let Some(msg) = c.message() {
            commits.push(msg.to_string());
        }

        // Add all parents to queue (reverse order to maintain commit order)
        let parents: Result<Vec<_>, anyhow::Error> = (0..c.parent_count())
            .map(|i| c.parent(i).map_err(anyhow::Error::from))
            .collect();
        if let Ok(parents) = parents {
            for parent in parents.into_iter().rev() {
                queue.push(parent);
            }
        }
    }

    Ok(commits)
}