gitkraft-core 0.8.6

Shared core logic for GitKraft — domain models, git operations, repository management
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
//! Branch operations — list, create, delete, checkout, and merge branches.

use anyhow::{bail, Context, Result};
use git2::{BranchType as Git2BranchType, Repository};
use tracing::debug;

use super::types::{BranchInfo, BranchType};

/// List all branches (local and remote) in the repository.
pub fn list_branches(repo: &Repository) -> Result<Vec<BranchInfo>> {
    let mut branches = Vec::new();

    let head_ref = repo.head().ok();
    let head_name = head_ref
        .as_ref()
        .and_then(|r| r.shorthand().map(String::from));

    for branch_result in repo.branches(None)? {
        let (branch, bt) = branch_result?;

        let name = branch.name()?.unwrap_or("<invalid utf-8>").to_string();

        let branch_type = match bt {
            Git2BranchType::Local => BranchType::Local,
            Git2BranchType::Remote => BranchType::Remote,
        };

        let is_head = match branch_type {
            BranchType::Local => head_name.as_deref() == Some(name.as_str()),
            BranchType::Remote => false,
        };

        let target_oid = branch.get().target().map(|oid| oid.to_string());

        branches.push(BranchInfo {
            name,
            branch_type,
            is_head,
            target_oid,
        });
    }

    debug!("listed {} branches", branches.len());
    Ok(branches)
}

/// Create a new local branch at HEAD with the given name.
///
/// Returns the newly created [`BranchInfo`].
pub fn create_branch(repo: &Repository, name: &str) -> Result<BranchInfo> {
    let head_ref = repo
        .head()
        .context("HEAD not found — is this an empty repository?")?;
    let commit = head_ref
        .peel_to_commit()
        .context("HEAD does not point to a commit")?;

    let branch = repo
        .branch(name, &commit, false)
        .with_context(|| format!("failed to create branch '{name}'"))?;

    let target_oid = branch.get().target().map(|oid| oid.to_string());

    debug!(name, "created branch");
    Ok(BranchInfo {
        name: name.to_string(),
        branch_type: BranchType::Local,
        is_head: false,
        target_oid,
    })
}

/// Delete a local branch by name.
///
/// Refuses to delete the currently checked-out branch.
pub fn delete_branch(repo: &Repository, name: &str) -> Result<()> {
    let mut branch = repo
        .find_branch(name, Git2BranchType::Local)
        .with_context(|| format!("local branch '{name}' not found"))?;

    if branch.is_head() {
        bail!("cannot delete the currently checked-out branch '{name}'");
    }

    branch
        .delete()
        .with_context(|| format!("failed to delete branch '{name}'"))?;
    debug!(name, "deleted branch");
    Ok(())
}

/// Checkout an existing local branch by name.
///
/// Sets HEAD to the branch reference and updates the working directory.
pub fn checkout_branch(repo: &Repository, name: &str) -> Result<()> {
    let refname = format!("refs/heads/{name}");

    // Make sure the branch actually exists
    repo.find_branch(name, Git2BranchType::Local)
        .with_context(|| format!("local branch '{name}' not found"))?;

    repo.set_head(&refname)
        .with_context(|| format!("failed to set HEAD to '{refname}'"))?;

    repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force()))
        .with_context(|| format!("failed to checkout branch '{name}'"))?;

    debug!(name, "checked out branch");
    Ok(())
}

/// Merge a source branch into the current HEAD branch.
///
/// If the merge can be fast-forwarded, it does so. If it results in a normal
/// merge (no conflicts), an automatic merge commit is created. If there are
/// conflicts, an error is returned and the repository is left in a merging
/// state so the user can resolve conflicts manually.
pub fn merge_branch(repo: &Repository, source_branch: &str) -> Result<()> {
    // Look up the source branch reference and its annotated commit.
    let branch = repo
        .find_branch(source_branch, Git2BranchType::Local)
        .with_context(|| format!("local branch '{source_branch}' not found"))?;

    let source_ref = branch.get();
    let source_oid = source_ref
        .target()
        .with_context(|| format!("branch '{source_branch}' has no target OID"))?;

    let annotated_commit = repo
        .find_annotated_commit(source_oid)
        .context("failed to find annotated commit for source branch")?;

    // Perform merge analysis.
    let (analysis, _preference) = repo
        .merge_analysis(&[&annotated_commit])
        .context("merge analysis failed")?;

    if analysis.is_up_to_date() {
        debug!(source_branch, "already up to date");
        return Ok(());
    }

    if analysis.is_fast_forward() {
        debug!(source_branch, "fast-forwarding");
        // Fast-forward: just move the current branch reference.
        let refname = format!("refs/heads/{}", head_branch_name(repo)?);
        let msg = format!("Fast-forward merge of '{source_branch}'");
        repo.reference(&refname, source_oid, true, &msg)?;
        repo.set_head(&refname)?;
        repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force()))?;
        return Ok(());
    }

    if analysis.is_normal() {
        debug!(source_branch, "performing normal merge");

        // Perform the actual merge (writes conflicts to index if any).
        repo.merge(&[&annotated_commit], None, None)
            .context("merge failed")?;

        // Check for conflicts.
        let index = repo.index().context("failed to read index after merge")?;
        if index.has_conflicts() {
            bail!(
                "merge of '{source_branch}' resulted in conflicts — resolve them and commit manually"
            );
        }

        // No conflicts — create the merge commit automatically.
        let sig = repo
            .signature()
            .or_else(|_| git2::Signature::now("GitKraft User", "user@gitkraft.local"))
            .context("failed to obtain signature")?;

        let mut index = repo.index().context("failed to read index")?;
        let tree_oid = index.write_tree().context("failed to write merged tree")?;
        let tree = repo
            .find_tree(tree_oid)
            .context("failed to find merged tree")?;

        let head_commit = repo
            .head()?
            .peel_to_commit()
            .context("HEAD does not point to a commit")?;

        let source_commit = repo
            .find_commit(source_oid)
            .context("failed to find source commit")?;

        let message = format!("Merge branch '{source_branch}'");
        repo.commit(
            Some("HEAD"),
            &sig,
            &sig,
            &message,
            &tree,
            &[&head_commit, &source_commit],
        )
        .context("failed to create merge commit")?;

        // Clean up merge state.
        repo.cleanup_state()
            .context("failed to clean up merge state")?;

        debug!(source_branch, "merge commit created");
        return Ok(());
    }

    bail!("merge analysis returned an unexpected result for branch '{source_branch}'");
}

/// Helper: get the short name of the branch HEAD points to.
fn head_branch_name(repo: &Repository) -> Result<String> {
    let head = repo.head().context("HEAD not found")?;
    let name = head
        .shorthand()
        .context("HEAD is not a symbolic reference (detached HEAD?)")?
        .to_string();
    Ok(name)
}

// ── subprocess helper ─────────────────────────────────────────────────────────

fn run_git(workdir: &std::path::Path, args: &[&str]) -> anyhow::Result<()> {
    let output = std::process::Command::new("git")
        .current_dir(workdir)
        .args(args)
        .output()
        .context("failed to spawn git")?;
    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        anyhow::bail!("{}", stderr.trim());
    }
    Ok(())
}

// ── new public functions ──────────────────────────────────────────────────────

/// Rename a local branch.
pub fn rename_branch(repo: &Repository, old_name: &str, new_name: &str) -> Result<()> {
    let mut branch = repo
        .find_branch(old_name, Git2BranchType::Local)
        .with_context(|| format!("branch '{old_name}' not found"))?;
    branch
        .rename(new_name, false)
        .with_context(|| format!("failed to rename '{old_name}' → '{new_name}'"))?;
    debug!(old_name, new_name, "renamed branch");
    Ok(())
}

/// Create a new local branch pointing at a specific commit OID.
pub fn create_branch_at_commit(repo: &Repository, name: &str, oid_str: &str) -> Result<()> {
    let oid = git2::Oid::from_str(oid_str).context("invalid OID")?;
    let commit = repo
        .find_commit(oid)
        .with_context(|| format!("commit {oid_str} not found"))?;
    repo.branch(name, &commit, false)
        .with_context(|| format!("failed to create branch '{name}' at {oid_str}"))?;
    debug!(name, oid_str, "created branch at commit");
    Ok(())
}

/// Push a local branch to a remote using `git push`.
///
/// Uses the system `git` binary so that the user's configured credential
/// helpers (SSH agent, git-credential-manager, etc.) are respected.
pub fn push_branch(workdir: &std::path::Path, branch: &str, remote: &str) -> Result<()> {
    run_git(workdir, &["push", remote, branch])
}

pub fn force_push_branch(workdir: &std::path::Path, branch: &str, remote: &str) -> Result<()> {
    run_git(workdir, &["push", "--force-with-lease", remote, branch])
}

/// Delete a remote branch using `git push <remote> --delete <branch>`.
///
/// `full_name` is the remote-tracking branch name (e.g. `origin/feature-x`).
/// The function extracts the remote and branch parts automatically.
pub fn delete_remote_branch(workdir: &std::path::Path, full_name: &str) -> Result<()> {
    let (remote, branch) = full_name.split_once('/').with_context(|| {
        format!("invalid remote branch name '{full_name}' — expected 'remote/branch'")
    })?;
    run_git(workdir, &["push", remote, "--delete", branch])
}

/// Checkout a remote branch by creating a local tracking branch.
///
/// `full_name` is the remote-tracking branch name (e.g. `origin/feature-x`).
/// Creates a local branch named `feature-x` that tracks `origin/feature-x`.
pub fn checkout_remote_branch(workdir: &std::path::Path, full_name: &str) -> Result<()> {
    let (_remote, branch) = full_name.split_once('/').with_context(|| {
        format!("invalid remote branch name '{full_name}' — expected 'remote/branch'")
    })?;
    run_git(workdir, &["checkout", "-b", branch, "--track", full_name])
}

/// Pull the current branch from a remote with `--rebase`.
pub fn pull_rebase(workdir: &std::path::Path, remote: &str) -> Result<()> {
    run_git(workdir, &["pull", "--rebase", remote])
}

/// Rebase the current HEAD onto `target` (branch name or OID).
pub fn rebase_onto(workdir: &std::path::Path, target: &str) -> Result<()> {
    run_git(workdir, &["rebase", target])
}

/// Create a lightweight Git tag pointing at the given OID.
pub fn create_tag(repo: &Repository, name: &str, oid_str: &str) -> Result<()> {
    let oid = git2::Oid::from_str(oid_str).context("invalid OID")?;
    let object = repo
        .find_object(oid, None)
        .with_context(|| format!("object {oid_str} not found"))?;
    repo.tag_lightweight(name, &object, false)
        .with_context(|| format!("failed to create lightweight tag '{name}'"))?;
    debug!(name, oid_str, "created lightweight tag");
    Ok(())
}

/// Create an annotated Git tag with a tagger signature and message pointing at the given OID.
pub fn create_annotated_tag(
    repo: &Repository,
    name: &str,
    message: &str,
    oid_str: &str,
) -> Result<()> {
    let oid = git2::Oid::from_str(oid_str).context("invalid OID")?;
    let object = repo
        .find_object(oid, None)
        .with_context(|| format!("object {oid_str} not found"))?;
    let sig = repo
        .signature()
        .or_else(|_| git2::Signature::now("GitKraft User", "user@gitkraft.local"))
        .context("failed to obtain signature")?;
    repo.tag(name, &object, &sig, message, false)
        .with_context(|| format!("failed to create annotated tag '{name}'"))?;
    debug!(name, oid_str, "created annotated tag");
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    fn setup_repo_with_commit() -> (TempDir, Repository) {
        let dir = TempDir::new().unwrap();
        let repo = Repository::init(dir.path()).unwrap();
        let mut config = repo.config().unwrap();
        config.set_str("user.name", "Test User").unwrap();
        config.set_str("user.email", "test@example.com").unwrap();
        std::fs::write(dir.path().join("file.txt"), "hello\n").unwrap();
        let mut index = repo.index().unwrap();
        index.add_path(std::path::Path::new("file.txt")).unwrap();
        index.write().unwrap();
        let tree_oid = index.write_tree().unwrap();
        {
            let tree = repo.find_tree(tree_oid).unwrap();
            let sig = repo.signature().unwrap();
            repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
                .unwrap();
        }
        (dir, repo)
    }

    #[test]
    fn list_branches_shows_main() {
        let (_dir, repo) = setup_repo_with_commit();
        let branches = list_branches(&repo).unwrap();
        assert!(!branches.is_empty());
        assert!(branches.iter().any(|b| b.is_head));
    }

    #[test]
    fn create_and_delete_branch() {
        let (_dir, repo) = setup_repo_with_commit();
        let branch = create_branch(&repo, "feature-test").unwrap();
        assert_eq!(branch.name, "feature-test");
        assert!(!branch.is_head);

        delete_branch(&repo, "feature-test").unwrap();
        let branches = list_branches(&repo).unwrap();
        assert!(!branches.iter().any(|b| b.name == "feature-test"));
    }

    #[test]
    fn checkout_branch_switches_head() {
        let (_dir, repo) = setup_repo_with_commit();
        create_branch(&repo, "new-branch").unwrap();
        checkout_branch(&repo, "new-branch").unwrap();
        let branches = list_branches(&repo).unwrap();
        let head = branches.iter().find(|b| b.is_head).unwrap();
        assert_eq!(head.name, "new-branch");
    }

    #[test]
    fn delete_head_branch_fails() {
        let (_dir, repo) = setup_repo_with_commit();
        let branches = list_branches(&repo).unwrap();
        let head = branches.iter().find(|b| b.is_head).unwrap();
        let result = delete_branch(&repo, &head.name);
        assert!(result.is_err());
    }
}