git-loom 0.25.0

A Git CLI tool that weaves together multiple feature branches into integration branches
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
pub mod git_apply;
pub mod git_branch;
pub mod git_commit;
pub mod git_diff;
pub mod git_merge;
pub mod git_rebase;
pub mod git_worktree;

pub use git_apply::{
    Replay, apply_cached_patch, apply_cached_patch_reverse, apply_patch, apply_patch_reverse,
    apply_patch_to_worktree, apply_patch_with_index, apply_patch_with_index_reverse,
    restore_loom_unstaged, restore_or_park_after_abort, restore_staged_after_rebase,
    restore_staged_patch, save_or_warn, save_patch_aside,
};
pub use git_branch::{
    branch_create, branch_delete, branch_force_create, branch_rename, branch_switch,
    branch_switch_create_tracking, branch_switch_detach, branch_validate_name,
};
pub use git_commit::{
    commit, commit_amend, commit_amend_message_unverified, commit_amend_no_edit, commit_captured,
    commit_opts, commit_with_editor, reset_hard, reset_mixed, reset_soft, stage_all,
    stage_all_opts, stage_files, stage_files_opts, stage_from, stage_path,
};
pub use git_diff::{
    diff_cached, diff_cached_display, diff_cached_file, diff_cached_file_is_binary,
    diff_cached_files, diff_commit, diff_commit_file, diff_commit_file_is_binary,
    diff_commit_name_status, diff_file, diff_file_is_binary, diff_head, diff_head_display,
    diff_head_file, diff_head_file_display, diff_head_file_is_binary, diff_head_files,
    diff_head_files_display, diff_head_name_only, diff_range, diff_trees, show_commit_file,
    show_commit_patch,
};
pub use git_merge::{MergeOutcome, continue_merge, merge_abort, merge_is_in_progress, merge_no_ff};
#[cfg(test)]
pub use git_rebase::rebase_onto;
pub use git_rebase::{
    AfterStop, Protected, REPLAYS_EMPTY, RebaseOutcome, StopId, abort_after_failure, auto_merge_id,
    before_rebase_starts, carry_past_known_stops, continue_rebase, continue_rebase_expecting_edit,
    continue_rerere_stops, finished_without_stopping, has_unmerged_paths, rebase, rebase_abort,
    rebase_abort_then_cleanup, rebase_is_in_progress, rebase_is_over, rebase_never_started,
    rebase_outcome, rebase_progress, replayed_empty_hash, stop_id, unmerged_paths,
    verify_paused_at,
};
pub use git_worktree::ensure_not_checked_out_elsewhere;

use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Instant;

use anyhow::{Context, Result, bail};

use crate::trace as loom_trace;

/// Config forced on every git command loom drives itself and reads back or
/// feeds patches to: [`run_git`] and friends, `git apply`, and the rebases.
///
/// A user's gitconfig shapes git's behavior and output, and loom parses that
/// output, replays it as patches, and hands git todo lists to execute. Each key
/// here is one a real gitconfig sets and loom cannot let vary:
///
/// - `commit.verbose` appends the diff to `COMMIT_EDITMSG`, and no editor opens
///   here to strip it, so a `commit-msg` hook reads the diff as the message.
/// - the four `diff.*` prefix keys drop or rename the `a/`…`b/` prefixes, after
///   which `git apply` cannot strip a leading component from a saved patch.
/// - `apply.whitespace=error` makes `git apply` reject a saved patch that adds
///   trailing whitespace; `apply.ignoreWhitespace=change` applies it elsewhere.
/// - `rebase.missingCommitsCheck` refuses (`error`) or complains (`warn`) about
///   the todo lists loom builds that leave commits out on purpose.
///
/// Left alone on purpose: `run_git_interactive` (what the user reads is theirs
/// to configure), `git push` and `git check-ref-format` (no parsed output).
/// Color is not here either: `color.ui` is only a default an explicit
/// `color.diff=always` beats, so the diff helpers pass `--no-color`.
pub const FORCED_CONFIG: &[&str] = &[
    "-c",
    "commit.verbose=false",
    "-c",
    "diff.noprefix=false",
    "-c",
    "diff.mnemonicPrefix=false",
    "-c",
    "diff.srcPrefix=a/",
    "-c",
    "diff.dstPrefix=b/",
    "-c",
    "apply.whitespace=nowarn",
    "-c",
    "apply.ignoreWhitespace=no",
    "-c",
    "rebase.missingCommitsCheck=ignore",
];

/// The usual reason an abort fails, wherever that is reported — rebase or merge.
pub const ABORT_FAILED_CAUSE: &str =
    "a stale `.git/index.lock` or a concurrent git process is the usual cause";

/// Minimum Git version required (`merge-tree --merge-base`, which decides
/// whether a commit replays empty, was added in 2.40; `--update-refs` in 2.38).
const MIN_GIT_VERSION: (u32, u32) = (2, 40);

/// Absolute path of the git dir for `workdir`.
///
/// Always asks git: a linked worktree has a `.git` file pointing into the main
/// repository rather than a directory, and `GIT_DIR` in the environment
/// overrides both. Guessing `workdir/.git` would disagree with git in either
/// case, and loom does run under a git-set environment as the sequence editor.
pub fn absolute_git_dir(workdir: &Path) -> Result<PathBuf> {
    let out = run_git_stdout(workdir, &["rev-parse", "--absolute-git-dir"])?;
    Ok(PathBuf::from(out.trim()))
}

/// Whether `rev` is HEAD or one of its ancestors — whether a command that
/// walks back from HEAD, `loom drop` among them, can still find it.
///
/// A commit a rollback has just orphaned still resolves; it is simply no longer
/// reachable. A revision git cannot resolve at all reports the same.
pub fn reaches_from_head(workdir: &Path, rev: &str) -> bool {
    run_git(workdir, &["merge-base", "--is-ancestor", rev, "HEAD"]).is_ok()
}

/// Run a git command, capture output, trace-log it, and bail on failure.
///
/// Output is piped, so an editor could never work here: `GIT_EDITOR=true`
/// keeps commands like `merge --continue` (which has no `--no-edit`) from
/// opening one and hanging. `GIT_SEQUENCE_EDITOR` falls back to it, so a
/// captured `rebase -i` must set its own sequence editor (`weave` runs its
/// own `Command` and sets both).
fn run_git_captured(workdir: &Path, args: &[&str]) -> Result<std::process::Output> {
    let start = Instant::now();
    let output = Command::new("git")
        .current_dir(workdir)
        .args(FORCED_CONFIG)
        .args(args)
        .env("GIT_EDITOR", "true")
        .output()?;

    let duration_ms = start.elapsed().as_millis();
    let stderr = String::from_utf8_lossy(&output.stderr);
    let cmd = args.join(" ");
    loom_trace::log_command("git", &cmd, duration_ms, output.status.success(), &stderr);

    if !output.status.success() {
        bail!("git {} failed", args[0]);
    }

    Ok(output)
}

/// Run a git command in the given working directory.
pub fn run_git(workdir: &Path, args: &[&str]) -> Result<()> {
    run_git_captured(workdir, args).map(|_| ())
}

/// Run a git command and return its stdout as a string.
pub fn run_git_stdout(workdir: &Path, args: &[&str]) -> Result<String> {
    let output = run_git_captured(workdir, args)?;
    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}

/// Run a git command and return its combined stdout+stderr, trimmed. For
/// commands like `fetch` that print their summary to stderr, so a caller can
/// show git's output after a spinner instead of streaming it live.
pub fn run_git_combined(workdir: &Path, args: &[&str]) -> Result<String> {
    let output = run_git_captured(workdir, args)?;
    let mut combined = String::from_utf8_lossy(&output.stdout).into_owned();
    combined.push_str(&String::from_utf8_lossy(&output.stderr));
    Ok(combined.trim().to_string())
}

/// Check that the installed Git version meets the minimum requirement.
pub fn check_git_version() -> Result<()> {
    let version_str = git_version_output();
    if version_str.is_empty() {
        bail!("Could not run git — is it installed and on PATH?");
    }

    // Parse "git version X.Y.Z..." → (X, Y)
    let (major, minor) = parse_git_version(&version_str)
        .with_context(|| format!("Could not parse Git version from: {}", version_str.trim()))?;

    if (major, minor) < MIN_GIT_VERSION {
        bail!(
            "Git {}.{} is too old, git-loom requires Git {}.{} or later\n\
             Current version: {}",
            major,
            minor,
            MIN_GIT_VERSION.0,
            MIN_GIT_VERSION.1,
            version_str.trim()
        );
    }

    Ok(())
}

/// git's `--empty` value that halts on a commit whose replay came out empty.
///
/// Spelled `ask` before Git 2.45, which renamed it `stop` and kept the old
/// spelling working with a deprecation warning.
pub fn empty_stop_value() -> &'static str {
    static VALUE: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();
    VALUE.get_or_init(|| empty_stop_for(parse_git_version(&git_version_output())))
}

/// `git --version`, asked once per run.
fn git_version_output() -> String {
    static OUTPUT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
    OUTPUT
        .get_or_init(|| {
            Command::new("git")
                .arg("--version")
                .output()
                .ok()
                .map(|out| String::from_utf8_lossy(&out.stdout).into_owned())
                .unwrap_or_default()
        })
        .clone()
}

/// `ask` is the spelling both understand, so an unreadable version falls back
/// to it rather than to one an older git rejects outright.
fn empty_stop_for(version: Option<(u32, u32)>) -> &'static str {
    match version {
        Some(version) if version >= (2, 45) => "stop",
        _ => "ask",
    }
}

/// Parse "git version X.Y.Z..." into (major, minor).
fn parse_git_version(version_str: &str) -> Option<(u32, u32)> {
    let version_part = version_str.trim().strip_prefix("git version ")?;
    let mut parts = version_part.split('.');
    let major = parts.next()?.parse().ok()?;
    let minor = parts.next()?.parse().ok()?;
    Some((major, minor))
}

/// Run a git command with inherited stdio (for interactive commands / pager).
/// stderr is not captured, so the trace log records it empty for these calls.
/// In TUI mode the terminal is handed back to the user for the duration.
pub fn run_git_interactive(workdir: &Path, args: &[&str]) -> Result<()> {
    // A pty-hosted agent must never hang inside `less` — disable the pager.
    let mut full_args: Vec<&str> = Vec::new();
    if crate::core::agent_mode::enabled() {
        full_args.extend(["-c", "core.pager=cat"]);
    }
    full_args.extend(args);

    let start = Instant::now();
    let terminal = crate::core::ui::suspend()?;
    let mut command = Command::new("git");
    command.current_dir(workdir).args(&full_args);
    // The test binary's fake editor is repository config, which an inherited
    // `GIT_EDITOR` outranks. Dropped from this command alone: a test must not
    // mutate the environment the whole process shares.
    #[cfg(test)]
    command.env_remove("GIT_EDITOR");
    let status = command.status()?;
    drop(terminal);

    let duration_ms = start.elapsed().as_millis();
    let cmd = args.join(" ");
    loom_trace::log_command("git", &cmd, duration_ms, status.success(), "");

    if !status.success() {
        bail!("git {} failed", args[0]);
    }

    Ok(())
}

/// Unstage specific files (`git reset HEAD -- <files>`), leaving the working
/// tree alone.
pub fn unstage_files(workdir: &Path, files: &[&str]) -> Result<()> {
    let mut args = vec!["reset", "HEAD", "--"];
    args.extend(files);
    run_git(workdir, &args)
}

/// Restore tracked files in the working tree to their HEAD state
/// (`git checkout HEAD -- <files>`).
pub fn restore_files_to_head(workdir: &Path, files: &[&str]) -> Result<()> {
    let mut args = vec!["checkout", "HEAD", "--"];
    args.extend(files);
    run_git(workdir, &args)
}

/// Restore tracked files in the working tree to their index state
/// (`git checkout-index -f --`).
pub fn checkout_index_force(workdir: &Path, files: &[&str]) -> Result<()> {
    let mut args = vec!["checkout-index", "-f", "--"];
    args.extend(files);
    run_git(workdir, &args)
}

/// The subset of `files` that the index knows about; empty for an empty list.
///
/// Wraps `git ls-files -z -- <files>`. The paths go in as `:(literal)`
/// pathspecs: a real file named `a[12].txt` would otherwise match `a1.txt` as
/// a glob, and the caller would act on a file it never asked about. `-z`
/// because the default `core.quotePath` would otherwise escape and quote a
/// non-ASCII path into something no other git command takes.
pub fn ls_files(workdir: &Path, files: &[&str]) -> Result<Vec<String>> {
    // No pathspec means "every path in the index" to git, which is never what a
    // caller asking about a list of files wants when that list came up empty.
    if files.is_empty() {
        return Ok(Vec::new());
    }
    let literal: Vec<String> = files.iter().map(|f| format!(":(literal){f}")).collect();
    let mut args = vec!["ls-files", "-z", "--"];
    args.extend(literal.iter().map(|f| f.as_str()));
    Ok(run_git_stdout(workdir, &args)?
        .split('\0')
        .filter(|p| !p.is_empty())
        .map(|p| p.to_string())
        .collect())
}

/// Every path the index records as a submodule (gitlink, mode 160000).
///
/// Lists the whole index rather than passing the caller's paths as pathspecs:
/// submodules number in the single digits, and a pathspec per changed file
/// would build an argv no platform accepts on a large change set.
pub fn index_gitlinks(workdir: &Path) -> Result<std::collections::HashSet<String>> {
    let out = run_git_stdout(workdir, &["ls-files", "-s", "-z"])?;
    // Entries are `<mode> <object> <stage>\t<path>`.
    Ok(out
        .split('\0')
        .filter_map(|entry| entry.strip_prefix("160000 "))
        .filter_map(|entry| entry.split_once('\t'))
        .map(|(_, path)| path.to_string())
        .collect())
}

/// The submodule entries (gitlink, mode 160000) `oid`'s own diff touches, each
/// mapped to whether the commit removes it.
///
/// Reads the raw modes from `git diff-tree -r -z` rather than the patch text,
/// which `core.quotePath` escapes and quotes for any non-ASCII path, and in one
/// call rather than one per file. Compares against the first parent, like every
/// other `<oid>^..<oid>` reader here, so `oid` must have one.
pub fn commit_gitlinks(
    workdir: &Path,
    oid: &str,
) -> Result<std::collections::HashMap<String, bool>> {
    let parent = format!("{oid}^");
    let out = run_git_stdout(workdir, &["diff-tree", "-r", "-z", &parent, oid])?;
    let mut gitlinks = std::collections::HashMap::new();
    // Records are `:<srcmode> <dstmode> <srcsha> <dstsha> <status>` then the
    // path, exactly one: only `-M`/`-C`, deliberately not passed, make
    // diff-tree print a rename's two paths and desync every later record.
    let mut fields = out.split('\0').filter(|f| !f.is_empty());
    while let Some(field) = fields.next() {
        let Some(meta) = field.strip_prefix(':') else {
            continue;
        };
        let Some(path) = fields.next() else { break };
        let mut modes = meta.split(' ');
        let src = modes.next().unwrap_or("");
        let dst = modes.next().unwrap_or("");
        if src == "160000" || dst == "160000" {
            gitlinks.insert(path.to_string(), src == "160000" && dst == "000000");
        }
    }
    Ok(gitlinks)
}

/// Drop `path` from the index, whatever its mode, without touching the working
/// tree (`git update-index --force-remove`).
pub fn remove_from_index(workdir: &Path, path: &str) -> Result<()> {
    run_git(workdir, &["update-index", "--force-remove", "--", path])
}

/// Every path whose working-tree content differs from the index, untracked
/// files included — but not ignored ones.
///
/// Wraps `git status --porcelain -z -uall`, keeping the entries whose
/// worktree column is set. A file that is only *staged* is deliberately left
/// out: its working-tree content still matches the index, so a caller
/// comparing two of these sets sees it the moment something writes to it.
/// `-uall` lists the files inside an untracked directory instead of collapsing
/// them into the directory itself, and `-z` stops the default `core.quotePath`
/// from escaping and quoting a non-ASCII path.
pub fn worktree_dirty_paths(workdir: &Path) -> Result<std::collections::HashSet<String>> {
    let out = run_git_stdout(workdir, &["status", "--porcelain", "-z", "-uall"])?;
    let mut paths = std::collections::HashSet::new();
    // A rename entry is `XY <new>\0<original>\0`: the second path stands alone,
    // and names a file the rename left behind, so it is never worktree-dirty.
    let mut rename_source_next = false;
    for entry in out.split('\0').filter(|e| !e.is_empty()) {
        if rename_source_next {
            rename_source_next = false;
            continue;
        }
        let Some((status, path)) = entry.split_at_checked(3) else {
            continue;
        };
        // Columns are `<index><worktree> `; a rename is recorded in either, and
        // its second path follows whether or not this entry is one we keep.
        rename_source_next = status.starts_with(['R', 'C']) || status[1..].starts_with(['R', 'C']);
        if !status[1..].starts_with(' ') {
            paths.insert(path.to_string());
        }
    }
    Ok(paths)
}

/// The index as a tree object (`git write-tree`).
pub fn write_tree(workdir: &Path) -> Result<String> {
    Ok(run_git_stdout(workdir, &["write-tree"])?.trim().to_string())
}

/// Resolve a path inside the git dir, e.g. `index`.
///
/// Wraps `git rev-parse --git-path <name>`, which knows where a linked
/// worktree's own git dir is and, for `index`, honors `GIT_INDEX_FILE`.
pub fn git_path(workdir: &Path, name: &str) -> Result<PathBuf> {
    let out = run_git_stdout(workdir, &["rev-parse", "--git-path", name])?;
    // Only the trailing newline: a git dir path may legitimately begin or end
    // with a space, and git never pads its own output.
    Ok(workdir.join(out.trim_end_matches('\n')))
}

/// The branch HEAD points at, without the `refs/heads/` prefix.
///
/// Errors when HEAD is detached.
pub fn current_branch(workdir: &Path) -> Result<String> {
    Ok(
        run_git_stdout(workdir, &["symbolic-ref", "--quiet", "--short", "HEAD"])?
            .trim()
            .to_string(),
    )
}

/// Resolve a git ref to its full commit hash (`git rev-parse <ref>`, trimmed).
pub fn rev_parse(workdir: &Path, reference: &str) -> Result<String> {
    let out = run_git_stdout(workdir, &["rev-parse", reference])?;
    Ok(out.trim().to_string())
}

/// Truncate a full commit hash to a short display form (7 chars).
pub fn short_hash(hash: &str) -> &str {
    &hash[..7.min(hash.len())]
}

/// Resolve the path to the git-loom binary.
///
/// During `cargo test`, `current_exe()` returns the test harness binary in
/// `target/<profile>/deps/`, while the binary itself sits one level up in
/// `target/<profile>/` — put there by cargo because `tests/bin_is_built.rs`
/// makes the package's binaries part of the test build.
pub fn loom_exe_path() -> Result<PathBuf> {
    resolve_loom_exe(&std::env::current_exe()?)
}

/// Resolve `exe` to the real git-loom binary; see [`loom_exe_path`].
///
/// Erroring beats handing back the harness: the caller gives this to git as the
/// rebase sequence editor, and a harness rejects `--source` with
/// `Unrecognized option` and exit 101, so git aborts with "there was a problem
/// with the editor" and the caller reports nothing but `git rebase failed`.
fn resolve_loom_exe(exe: &Path) -> Result<PathBuf> {
    // A `deps` directory only means a harness under `cargo test`. An installed
    // binary that happens to sit in one is just a binary, and telling its user
    // to run `cargo build` would be nonsense.
    if !cfg!(test) {
        return Ok(exe.to_path_buf());
    }
    let Some(parent) = exe.parent() else {
        return Ok(exe.to_path_buf());
    };
    if parent.file_name().and_then(|n| n.to_str()) != Some("deps") {
        return Ok(exe.to_path_buf());
    }
    let Some(profile_dir) = parent.parent() else {
        return Ok(exe.to_path_buf());
    };

    let actual = profile_dir.join(format!("git-loom{}", std::env::consts::EXE_SUFFIX));
    if !actual.exists() {
        bail!(
            "'{}' does not exist — run `cargo build` before `cargo test`",
            actual.display()
        );
    }
    Ok(actual)
}

#[cfg(test)]
#[path = "mod_test.rs"]
mod tests;