sluuz 0.10.2

git, but it sleuths - a git superset with cross-repo search, secret scanning, and multi-repo management
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
//! Shared git utility functions used across subcommands.
//!
//! `load` holds the queries behind the interactive views. They live here rather
//! than under `tui/` because reading git is not terminal work, and a plain
//! command may need the same query.

pub mod load;

use std::path::{Path, PathBuf};
use std::process::Command;
use walkdir::WalkDir;

/// ASCII unit separator - a field delimiter that won't appear in git's output,
/// so `--format` strings can split on it without quoting anything.
pub const SEP: char = '\u{1f}';

/// Find all git repositories under `base`, searching up to `max_depth` levels deep.
/// Returns each repo's root directory (the parent of its `.git`, which is a file
/// in a worktree or a submodule). When there are none and `base` sits inside a
/// repo, that repo is the answer, so running from one of its subfolders still
/// finds it.
pub fn find_repos(base: &Path, max_depth: usize) -> Vec<PathBuf> {
    let found: Vec<PathBuf> = WalkDir::new(base)
        .max_depth(max_depth)
        .follow_links(false)
        .into_iter()
        .filter_map(|entry| entry.ok()) // skip permission errors
        .filter(|entry| entry.file_name() == ".git")
        .filter_map(|entry| entry.path().parent().map(Path::to_path_buf))
        .collect();
    if !found.is_empty() {
        return found;
    }
    base.to_str()
        .and_then(|b| git_capture(b, &["rev-parse", "--show-toplevel"]))
        .map(PathBuf::from)
        .into_iter()
        .collect()
}

/// `find_repos` for a command that has nothing to do without one: finding none
/// ends it, on stderr and with a failing exit, since an empty report would read
/// as "all clean".
pub fn repos_or_exit(base: &Path, max_depth: usize) -> Vec<PathBuf> {
    let repos = find_repos(base, max_depth);
    if repos.is_empty() {
        eprintln!("{}", no_repos(base, max_depth));
        std::process::exit(1);
    }
    repos
}

/// What finding no repos says, wherever it is found.
pub fn no_repos(base: &Path, max_depth: usize) -> String {
    format!(
        "no git repos in or under `{}` (looked {max_depth} levels down) - run it inside a repo, or point it at a folder that holds some",
        base.display()
    )
}

/// One repo's working-tree state: what `slu repos` prints as a row and what the
/// repos level of the interactive drill shows.
pub struct RepoStatus {
    pub name: String,
    pub path: String,
    pub branch: String,
    pub has_upstream: bool,
    pub dirty: usize,
    pub ahead: usize,
    pub behind: usize,
    /// `origin`'s URL, shortened to `host:owner/repo`. Empty when the repo has
    /// no origin.
    pub origin: String,
}

impl RepoStatus {
    /// Whether the repo needs attention: uncommitted, unpushed, or unpulled.
    pub fn needs_attention(&self) -> bool {
        self.dirty > 0 || self.ahead > 0 || self.behind > 0
    }

    /// Uncommitted work in the working tree.
    pub fn is_dirty(&self) -> bool {
        self.dirty > 0
    }

    /// Commits that exist here and on no remote.
    pub fn is_unpushed(&self) -> bool {
        self.ahead > 0
    }
}

/// Read one repo's status via `git status --porcelain=2 --branch`, which packs
/// branch, upstream, ahead/behind, and changed files into one machine-readable
/// listing.
pub fn repo_status(repo: &Path) -> RepoStatus {
    let path = repo.to_string_lossy().into_owned();
    let out = git_capture(&path, &["status", "--porcelain=2", "--branch"]).unwrap_or_default();

    let mut branch = "(detached)".to_string();
    let mut has_upstream = false;
    let mut ahead = 0usize;
    let mut behind = 0usize;
    let mut dirty = 0usize;

    for line in out.lines() {
        if let Some(rest) = line.strip_prefix("# branch.head ") {
            branch = rest.to_string();
        } else if line.starts_with("# branch.upstream ") {
            has_upstream = true;
        } else if let Some(rest) = line.strip_prefix("# branch.ab ") {
            // "+<ahead> -<behind>"
            for tok in rest.split_whitespace() {
                if let Some(a) = tok.strip_prefix('+') {
                    ahead = a.parse().unwrap_or(0);
                } else if let Some(b) = tok.strip_prefix('-') {
                    behind = b.parse().unwrap_or(0);
                }
            }
        } else if !line.starts_with('#') && !line.is_empty() {
            // Any non-header line is a changed/untracked entry.
            dirty += 1;
        }
    }

    // Only `origin`: it is what all but a handful of repos call their remote,
    // and a second column of URLs would cost more room than it is worth.
    let origin = git_capture(&path, &["config", "--get", "remote.origin.url"])
        .map(|u| short_remote(&u))
        .unwrap_or_default();

    RepoStatus {
        name: display_name(repo),
        path,
        branch,
        has_upstream,
        dirty,
        ahead,
        behind,
        origin,
    }
}

/// Shorten a remote URL to `host:owner/repo`, the part that identifies it.
/// Handles the scp-like form git uses for SSH (`git@host:owner/repo.git`) and
/// real URLs (`https://host/owner/repo.git`, `ssh://git@host:22/owner/repo`).
/// Anything else (a filesystem path, mostly) comes back as it went in, since
/// there is no host to pull out of it.
pub fn short_remote(url: &str) -> String {
    let url = url.trim();
    let url = url.strip_suffix('/').unwrap_or(url);
    let stripped = url.strip_suffix(".git").unwrap_or(url);

    // A real URL: <scheme>://[user@]host[:port]/path
    if let Some((_, rest)) = stripped.split_once("://") {
        let rest = rest.rsplit('@').next().unwrap_or(rest); // drop any userinfo
        let Some((hostport, path)) = rest.split_once('/') else {
            return stripped.to_string();
        };
        // Keep the host, drop the port: it identifies the server, not the repo.
        let host = hostport.split(':').next().unwrap_or(hostport);
        return format!("{host}:{path}");
    }

    // The scp-like form: [user@]host:path - but not a Windows drive (C:\…).
    if let Some((hostpart, path)) = stripped.split_once(':')
        && !path.starts_with('\\')
        && !path.starts_with('/')
        && hostpart.contains('.')
    {
        let host = hostpart.rsplit('@').next().unwrap_or(hostpart);
        return format!("{host}:{path}");
    }

    // Not a URL at all, so it is a filesystem path: a mirror, a bare repo on a
    // share, a submodule's relative origin. Those identify by their tail, and
    // the head is usually a prefix every repo in the tree shares, so keep the
    // last two components.
    let parts: Vec<&str> = stripped
        .split(['/', '\\'])
        .filter(|p| !p.is_empty() && *p != ".")
        .collect();
    if parts.len() > 2 {
        return format!("…/{}", parts[parts.len() - 2..].join("/"));
    }
    stripped.to_string()
}

/// Get a human-readable repo name from its path (its directory name).
pub fn display_name(repo: &Path) -> String {
    repo.canonicalize()
        .ok()
        .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
        .unwrap_or_else(|| repo.to_string_lossy().into_owned())
}

/// Run `git -C <repo> <args>` and return trimmed stdout, or None if git fails
/// or exits non-zero. For read-only queries where you only want the output.
pub fn git_capture(repo: &str, args: &[&str]) -> Option<String> {
    let output = Command::new("git")
        .arg("-C")
        .arg(repo)
        .args(args)
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
}

/// Like `git_capture`, but returns stdout **untrimmed**. Use it whenever leading
/// whitespace is data - notably `git status --porcelain`, where the first column
/// is a space when a file has no staged change. Trimming would eat that space on
/// the first record and shift the whole line ("Cargo.lock" → "argo.lock").
pub fn git_capture_raw(repo: &str, args: &[&str]) -> Option<String> {
    let output = Command::new("git")
        .arg("-C")
        .arg(repo)
        .args(args)
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    Some(String::from_utf8_lossy(&output.stdout).into_owned())
}

/// Does a fetch in this repo delete remote-tracking refs the remote no longer
/// has? `tidy` reads `[gone]`, which git only sets once that ref is actually
/// absent, so a repo that never prunes hides exactly the branches `tidy` is for
/// and reports "nothing to clean up" as though it were a fact.
///
/// `remote.<name>.prune` overrides `fetch.prune`, so it is asked first and its
/// answer is final when it has one.
pub fn prunes_on_fetch(repo: &str) -> bool {
    for key in ["remote.origin.prune", "fetch.prune"] {
        if let Some(v) = git_capture(repo, &["config", "--get", "--type=bool", key]) {
            return v == "true";
        }
    }
    false
}

/// The first non-blank line of a program's output - what a failure is reported
/// as when the whole of it would not fit.
pub fn first_line(s: &str) -> String {
    s.lines()
        .map(str::trim)
        .find(|l| !l.is_empty())
        .unwrap_or("")
        .to_string()
}

/// How many lines of git's own complaint a box shows.
const GIT_WORDS: usize = 5;

/// What git said, cut to what failed for a box: blank lines and gpg's
/// `[GNUPG:]` status lines go (git passes them on when signing fails), a line
/// repeated at once is kept once, and no more than `GIT_WORDS` lines stay.
pub fn git_words(said: &str) -> String {
    let mut lines: Vec<&str> = said
        .lines()
        .filter(|l| !l.trim().is_empty() && !l.starts_with("[GNUPG:]"))
        .collect();
    lines.dedup();
    lines.truncate(GIT_WORDS);
    lines.join("\n")
}

/// Run `git -C <repo> <args>` and return (success, combined stdout+stderr).
/// For commands like fetch/pull where progress goes to stderr and you want to
/// report what happened regardless of exit status.
pub fn git_run(repo: &str, args: &[&str]) -> (bool, String) {
    match Command::new("git").arg("-C").arg(repo).args(args).output() {
        Ok(output) => {
            let mut combined = String::from_utf8_lossy(&output.stdout).into_owned();
            combined.push_str(&String::from_utf8_lossy(&output.stderr));
            (output.status.success(), combined.trim().to_string())
        }
        Err(e) => (false, e.to_string()),
    }
}

#[cfg(test)]
mod tests {
    use super::{find_repos, git_capture, git_words, prunes_on_fetch, short_remote};
    use std::fs;
    use std::path::PathBuf;
    use std::time::{SystemTime, UNIX_EPOCH};

    /// A throwaway git repo under the system temp dir, named so two runs cannot
    /// collide, and deleted when it goes out of scope - including when an
    /// assertion panics, or a failing test would leave the machine dirtier than
    /// it found it. Nothing outside the temp directory is ever touched.
    struct TempRepo(PathBuf);

    impl TempRepo {
        fn new(tag: &str) -> TempRepo {
            let stamp = SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .map(|d| d.as_nanos())
                .unwrap_or(0);
            let dir = std::env::temp_dir().join(format!("sluuz-{tag}-{stamp}"));
            fs::create_dir_all(&dir).expect("temp dir");
            git_capture(dir.to_str().expect("utf-8 temp path"), &["init", "-q"]).expect("git init");
            TempRepo(dir)
        }

        fn path(&self) -> &str {
            self.0.to_str().expect("utf-8 temp path")
        }
    }

    impl Drop for TempRepo {
        fn drop(&mut self) {
            let _ = fs::remove_dir_all(&self.0);
        }
    }

    fn set(repo: &str, key: &str, value: &str) {
        git_capture(repo, &["config", key, value]).expect("git config");
    }

    #[test]
    fn a_remotes_own_prune_setting_beats_the_general_one() {
        // Which way round this goes decides whether `tidy` tells you your list
        // may be short. Both keys are set in every case, so whatever the
        // machine running the suite has in its own git config cannot change the
        // answer.
        let dir = TempRepo::new("prune");
        let repo = dir.path();

        set(repo, "fetch.prune", "false");
        set(repo, "remote.origin.prune", "true");
        assert!(prunes_on_fetch(repo), "the remote's own setting wins");

        set(repo, "fetch.prune", "true");
        set(repo, "remote.origin.prune", "false");
        assert!(!prunes_on_fetch(repo), "and it wins in the other direction");
    }

    #[test]
    fn the_ssh_form_git_prints_by_default() {
        assert_eq!(
            short_remote("git@github.com:safteinzz/sluuz.git"),
            "github.com:safteinzz/sluuz"
        );
        assert_eq!(
            short_remote("git@gitlab.com:safteinzz/sluuz.git"),
            "gitlab.com:safteinzz/sluuz"
        );
    }

    #[test]
    fn https_urls_lose_the_scheme_and_the_dot_git() {
        assert_eq!(
            short_remote("https://gitlab.com/safteinzz/sluuz.git"),
            "gitlab.com:safteinzz/sluuz"
        );
        assert_eq!(
            short_remote("https://github.com/safteinzz/sluuz"),
            "github.com:safteinzz/sluuz"
        );
    }

    #[test]
    fn a_url_loses_its_credentials_and_its_port() {
        assert_eq!(
            short_remote("https://token@github.com/o/r.git"),
            "github.com:o/r"
        );
        assert_eq!(
            short_remote("ssh://git@host.example:2222/o/r.git"),
            "host.example:o/r"
        );
    }

    #[test]
    fn a_self_hosted_path_keeps_every_segment() {
        assert_eq!(
            short_remote("https://git.example.com/team/group/sub/repo.git"),
            "git.example.com:team/group/sub/repo"
        );
    }

    #[test]
    fn a_local_remote_is_named_by_its_tail() {
        // Every repo in a tree of mirrors shares the head of this path, so the
        // end is the only part that says which one it is.
        assert_eq!(
            short_remote("/home/me/mirrors/team/crates/widget.git"),
            "…/crates/widget"
        );
        assert_eq!(short_remote("../mirror/repo.git"), "…/mirror/repo");
        // Nothing was dropped here, so there is no ellipsis to earn.
        assert_eq!(short_remote("/srv/repo.git"), "/srv/repo");
    }

    #[test]
    fn something_unparseable_comes_back_as_it_went_in() {
        assert_eq!(short_remote("weird"), "weird");
        assert_eq!(short_remote(""), "");
    }

    #[test]
    fn a_scan_from_a_subfolder_finds_the_enclosing_repo() {
        let dir = TempRepo::new("subfolder");
        let deep = dir.0.join("app").join("src");
        fs::create_dir_all(&deep).expect("subfolder");

        let found: Vec<PathBuf> = find_repos(&deep, 3)
            .iter()
            .map(|p| p.canonicalize().expect("found repo exists"))
            .collect();
        let root = dir.0.canonicalize().expect("temp repo exists");
        assert_eq!(
            found,
            [root],
            "a scan from two folders down should find the repo above"
        );
    }

    #[test]
    fn a_worktree_counts_as_a_repo() {
        // A worktree's `.git` is a file pointing back at the main repo.
        let dir = TempRepo::new("worktree");
        let repo = dir.path();
        let author = [
            "-c",
            "user.name=t",
            "-c",
            "user.email=t@t",
            "-c",
            "commit.gpgsign=false",
        ];
        let mut commit = author.to_vec();
        commit.extend(["commit", "-q", "--allow-empty", "-m", "one"]);
        git_capture(repo, &commit).expect("commit");
        let wt = dir.0.join("wt");
        let wt_str = wt.to_str().expect("utf-8 temp path");
        git_capture(repo, &["worktree", "add", "-q", "-b", "side", wt_str]).expect("worktree");

        let found = find_repos(&dir.0, 3);
        assert!(
            found.contains(&wt),
            "the worktree should be listed, got {found:?}"
        );
        assert!(
            found.contains(&dir.0),
            "and so should the repo it belongs to, got {found:?}"
        );
    }

    #[test]
    fn a_box_shows_at_most_five_lines_of_git_and_no_gpg_status() {
        // what `git commit` says when the passphrase prompt is cancelled
        let said = "error: gpg failed to sign the data:\n\
            [GNUPG:] KEY_CONSIDERED 0123456789ABCDEF0123456789ABCDEF01234567 2\n\
            [GNUPG:] BEGIN_SIGNING H10\n\
            gpg: signing failed: Operation cancelled\n\
            [GNUPG:] FAILURE sign 83886179\n\
            gpg: signing failed: Operation cancelled\n\
            \n\
            fatal: failed to write commit object";
        let words = git_words(said);
        assert_eq!(
            words.lines().collect::<Vec<_>>(),
            [
                "error: gpg failed to sign the data:",
                "gpg: signing failed: Operation cancelled",
                "fatal: failed to write commit object",
            ],
            "gpg's status lines, the blank and the repeat should go, and git's last line stay"
        );

        let long: Vec<String> = (1..=9).map(|n| format!("remote: line {n}")).collect();
        let words = git_words(&long.join("\n"));
        assert_eq!(
            words.lines().count(),
            5,
            "a long complaint should be cut to five lines, got:\n{words}"
        );
    }
}