worktrunk 0.35.1

A CLI for Git worktree management, designed for parallel AI agent workflows
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
//! Diagnostic report generation for issue reporting.
//!
//! When unexpected warnings occur (timeouts, git errors, etc.), this module
//! can generate a diagnostic file that users attach to GitHub issues.
//!
//! # When Diagnostics Are Generated
//!
//! Diagnostic files are written when `-vv` is passed. Without `-vv`, the hint
//! simply tells users to run with `-vv`. This ensures the diagnostic file
//! contains useful debug information.
//!
//! # Report Format
//!
//! The report is a markdown file designed for easy pasting into GitHub issues:
//!
//! 1. **Header** — Timestamp, command that was run, and result
//! 2. **Environment** — wt version, OS, git version, shell integration
//! 3. **Worktrees** — Raw `git worktree list --porcelain` output
//! 4. **Config** — User and project config contents
//! 5. **Verbose log** — Debug log output, truncated to ~50KB if large
//!
//! # Privacy
//!
//! The report explicitly documents what IS and ISN'T included:
//!
//! **Included:** worktree paths, branch names, worktree status (prunable, locked),
//! config files, verbose logs, commit messages (in verbose logs)
//!
//! **Not included:** file contents, credentials
//!
//! # File Location
//!
//! Reports are written to `<git-common-dir>/wt/logs/diagnostic.md` (typically
//! `.git/wt/logs/diagnostic.md`). Verbose logs go to `verbose.log` in the same directory.
//!
//! # Usage
//!
//! ```rust,ignore
//! use crate::diagnostic::issue_hint;
//!
//! // Show hint telling user to run with -vv
//! eprintln!("{}", hint_message(issue_hint()));
//! ```
//!
use std::path::PathBuf;

use ansi_str::AnsiStr;
use anyhow::Context;
use color_print::cformat;
use minijinja::{Environment, context};
use worktrunk::git::Repository;
use worktrunk::path::format_path_for_display;
use worktrunk::shell_exec::Cmd;
use worktrunk::styling::{eprintln, hint_message, info_message, warning_message};

use crate::cli::version_str;
use crate::output;

/// Markdown template for the diagnostic report.
///
/// This template makes the report structure immediately visible.
/// Variables are filled in by `format_report()`.
const REPORT_TEMPLATE: &str = r#"## Diagnostic Report

**Generated:** {{ timestamp }}
**Command:** `{{ command }}`
**Result:** {{ context }}

<details>
<summary>Environment</summary>

```
wt {{ version }} ({{ os }} {{ arch }})
git {{ git_version }}
Shell integration: {{ shell_integration }}
```
</details>

<details>
<summary>Worktrees</summary>

```
{{ worktree_list }}
```
</details>
{%- if config_show %}

<details>
<summary>Config</summary>

```
{{ config_show }}
```
</details>
{%- endif %}
{%- if verbose_log %}

<details>
<summary>Verbose log</summary>

```
{{ verbose_log }}
```
</details>
{%- endif %}
"#;

/// Collected diagnostic information for issue reporting.
pub(crate) struct DiagnosticReport {
    /// Formatted markdown content
    content: String,
}

impl DiagnosticReport {
    /// Collect diagnostic information from the current environment.
    ///
    /// # Arguments
    /// * `repo` - Repository to collect worktree info from
    /// * `command` - The command that was run (e.g., "wt list -vv")
    /// * `context` - Context describing the result (error message or success)
    pub fn collect(repo: &Repository, command: &str, context: String) -> Self {
        let content = Self::format_report(repo, command, &context);
        Self { content }
    }

    /// Format the complete diagnostic report as markdown using minijinja template.
    fn format_report(repo: &Repository, command: &str, context: &str) -> String {
        // Strip ANSI codes from context - the diagnostic is a markdown file for GitHub
        let context = strip_ansi_codes(context);

        // Collect data for template
        let timestamp = worktrunk::utils::now_iso8601();
        let version = version_str();
        let os = std::env::consts::OS;
        let arch = std::env::consts::ARCH;
        let git_version = git_version().unwrap_or_else(|_| "(unknown)".to_string());
        let shell_integration = if output::is_shell_integration_active() {
            "active"
        } else {
            "inactive"
        };
        let worktree_list = repo
            .run_command(&["worktree", "list", "--porcelain"])
            .map(|s| s.trim_end().to_string())
            .unwrap_or_else(|_| "(failed to get worktree list)".to_string());

        // Get config show output (if available)
        let config_show = config_show_output(repo);

        // Get verbose log content (if available)
        let verbose_log = crate::verbose_log::log_file_path()
            .and_then(|path| std::fs::read_to_string(&path).ok())
            .map(|content| truncate_log(content.trim()))
            .filter(|s| !s.is_empty());

        // Render template
        let env = Environment::new();
        let tmpl = env.template_from_str(REPORT_TEMPLATE).unwrap();
        tmpl.render(context! {
            timestamp,
            command,
            context,
            version,
            os,
            arch,
            git_version,
            shell_integration,
            worktree_list,
            config_show,
            verbose_log,
        })
        .unwrap()
    }

    /// Write the diagnostic report to a file.
    ///
    /// Called from `write_if_verbose()` when verbose >= 2.
    /// Returns the path if successful, None if write failed.
    pub fn write_diagnostic_file(&self, repo: &Repository) -> Option<PathBuf> {
        let log_dir = repo.wt_logs_dir();
        std::fs::create_dir_all(&log_dir).ok()?;

        let path = log_dir.join("diagnostic.md");
        std::fs::write(&path, &self.content).ok()?;

        Some(path)
    }
}

/// Return hint telling users to run with `-vv` for diagnostics.
///
/// This is a free function (not a method on DiagnosticReport) because it
/// doesn't require collecting diagnostic data - just returns a static hint.
///
/// TODO: Consider showing this hint automatically when any `log::warn!` occurs
/// during command execution, since runtime warnings often indicate unexpected
/// conditions that could be bugs worth reporting.
pub(crate) fn issue_hint() -> String {
    cformat!("To create a diagnostic file, run with <underline>-vv</>")
}

/// Write diagnostic file when -vv is used.
///
/// Called at the end of command execution. If verbose level is >= 2, writes
/// a diagnostic report to `.git/wt/logs/diagnostic.md` for issue filing.
///
/// Silently returns if:
/// - verbose < 2
/// - Not in a git repository
///
/// Warns if diagnostic file write fails.
pub(crate) fn write_if_verbose(verbose: u8, command_line: &str, error_msg: Option<&str>) {
    if verbose < 2 {
        return;
    }

    // Use Repository::current() which honors the -C flag
    let Ok(repo) = Repository::current() else {
        return;
    };

    // Check if we're actually in a git repo
    if repo.current_worktree().git_dir().is_err() {
        return;
    }

    // Build context based on success/error
    let context = match error_msg {
        Some(msg) => format!("Command failed: {msg}"),
        None => "Command completed successfully".to_string(),
    };

    // Collect and write diagnostic
    let report = DiagnosticReport::collect(&repo, command_line, context);
    match report.write_diagnostic_file(&repo) {
        Some(path) => {
            let path_display = format_path_for_display(&path);
            eprintln!(
                "{}",
                info_message(format!("Diagnostic saved: {path_display}"))
            );

            // Only show gh command if gh is installed
            if is_gh_installed() {
                let path_str = format_path_for_display(&path);
                // URL with prefilled body: ## Gist\n\n[Paste URL]\n\n## Description\n\n[Describe the issue]
                let issue_url = "https://github.com/max-sixty/worktrunk/issues/new?body=%23%23%20Gist%0A%0A%5BPaste%20gist%20URL%5D%0A%0A%23%23%20Description%0A%0A%5BDescribe%20the%20issue%5D";
                eprintln!(
                    "{}",
                    hint_message(cformat!(
                        "To report a bug, create a secret gist with <underline>gh gist create --web {path_str}</> and reference it from an issue at <underline>{issue_url}</>"
                    ))
                );
            }
        }
        None => {
            eprintln!("{}", warning_message("Failed to write diagnostic file"));
        }
    }
}

/// Check if the GitHub CLI (gh) is installed.
fn is_gh_installed() -> bool {
    Cmd::new("gh")
        .arg("--version")
        .run()
        .map(|o| o.status.success())
        .unwrap_or(false)
}

/// Strip ANSI escape codes from a string.
///
/// Used to clean terminal-formatted text for markdown output.
fn strip_ansi_codes(s: &str) -> String {
    s.ansi_strip().into_owned()
}

/// Truncate verbose log to ~50KB if it's too large.
///
/// Keeps the last ~50KB of the log, cutting at a line boundary.
fn truncate_log(content: &str) -> String {
    const MAX_LOG_SIZE: usize = 50 * 1024;
    if content.len() <= MAX_LOG_SIZE {
        return content.to_string();
    }

    let start = content.len() - MAX_LOG_SIZE;
    // Find the next newline to avoid cutting mid-line
    let start = content[start..]
        .find('\n')
        .map(|i| start + i + 1)
        .unwrap_or(start);

    format!("(log truncated to last ~50KB)\n{}", &content[start..])
}

/// Get git version string.
fn git_version() -> anyhow::Result<String> {
    let output = Cmd::new("git")
        .arg("--version")
        .run()
        .context("Failed to run git --version")?;

    let stdout = String::from_utf8_lossy(&output.stdout);
    let version = stdout
        .trim()
        .strip_prefix("git version ")
        .unwrap_or(stdout.trim())
        .to_string();

    Ok(version)
}

/// Get config show output for diagnostic.
///
/// Returns a summary of user and project config files.
fn config_show_output(repo: &Repository) -> Option<String> {
    let mut output = String::new();

    // User config
    if let Some(user_config_path) = worktrunk::config::config_path() {
        output.push_str(&format_config_section(&user_config_path, "User config"));
    }

    // Project config
    if let Ok(Some(project_config_path)) = repo.project_config_path() {
        output.push_str(&format!(
            "\n{}",
            format_config_section(&project_config_path, "Project config")
        ));
    }

    if output.is_empty() {
        None
    } else {
        Some(output.trim().to_string())
    }
}

/// Format a config file section for diagnostic output.
fn format_config_section(path: &std::path::Path, label: &str) -> String {
    let mut output = format!("{}: {}\n", label, path.display());
    if path.exists() {
        match std::fs::read_to_string(path) {
            Ok(content) if content.trim().is_empty() => output.push_str("(empty file)\n"),
            Ok(content) => {
                // Include content, but truncate if very long
                let content = if content.len() > 4000 {
                    format!("{}...\n(truncated)", &content[..4000])
                } else {
                    content
                };
                output.push_str(&content);
                if !output.ends_with('\n') {
                    output.push('\n');
                }
            }
            Err(e) => output.push_str(&format!("(read failed: {})\n", e)),
        }
    } else {
        output.push_str("(file not found)\n");
    }
    output
}

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

    #[test]
    fn test_format_config_section_file_not_found() {
        let result = format_config_section(std::path::Path::new("/nonexistent/path.toml"), "Test");
        insta::assert_snapshot!(result, @"
        Test: /nonexistent/path.toml
        (file not found)
        ");
    }

    #[test]
    fn test_format_config_section_empty_file() {
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join("empty.toml");
        std::fs::write(&path, "").unwrap();

        let result = format_config_section(&path, "Test");
        assert!(result.contains("(empty file)"));
    }

    #[test]
    fn test_format_config_section_with_content() {
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join("config.toml");
        std::fs::write(&path, "key = \"value\"\n").unwrap();

        let result = format_config_section(&path, "Test");
        assert!(result.contains("key = \"value\""));
    }

    #[test]
    fn test_format_config_section_adds_trailing_newline() {
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join("config.toml");
        std::fs::write(&path, "no-newline").unwrap();

        let result = format_config_section(&path, "Test");
        assert!(result.ends_with('\n'));
    }

    #[test]
    fn test_format_config_section_truncates_long_content() {
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join("big.toml");
        let content = "x".repeat(5000);
        std::fs::write(&path, &content).unwrap();

        let result = format_config_section(&path, "Test");
        assert!(result.contains("(truncated)"));
        assert!(result.len() < 5000);
    }

    #[test]
    fn test_strip_ansi_codes() {
        // Build ANSI codes programmatically to avoid lint
        let esc = '\x1b';
        let input = format!("{esc}[31mred{esc}[0m and {esc}[32mgreen{esc}[0m");
        let result = strip_ansi_codes(&input);
        assert_eq!(result, "red and green");
    }

    #[test]
    fn test_truncate_log_small_content() {
        let content = "small log content";
        let result = truncate_log(content);
        assert_eq!(result, content);
    }

    #[test]
    fn test_truncate_log_large_content() {
        let content = "x".repeat(60 * 1024); // 60KB
        let result = truncate_log(&content);
        assert!(result.starts_with("(log truncated to last ~50KB)"));
        assert!(result.len() < 55 * 1024);
    }
}