Skip to content

Commit 2e18347

Browse files
committed
feat: Add --oneline argument to allow post-processing output
implements #171
2 parents 13e425a + c1d9644 commit 2e18347

File tree

8 files changed

+321
-7
lines changed

8 files changed

+321
-7
lines changed

.claude/agents/code-reviewer.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
---
2+
name: code-reviewer
3+
description: Use this agent when you have written or modified code and need a thorough review before committing. This agent should be called after completing a logical chunk of work but before running git commit. Examples: <example>Context: User has just implemented a new feature for markdown parsing. user: 'I've added support for parsing metadata properties in markdown files. Here's the diff...' assistant: 'Let me use the code-reviewer agent to review this implementation before you commit.' <commentary>Since the user has completed a code change, use the code-reviewer agent to analyze the diff for quality, design, and architectural alignment.</commentary></example> <example>Context: User has refactored a component in the Dioxus UI. user: 'I refactored the file browser component to use better state management' assistant: 'I'll review this refactoring with the code-reviewer agent to ensure it follows best practices.' <commentary>The user has made changes that need review before committing, so use the code-reviewer agent.</commentary></example>
4+
tools: Task, Bash, Glob, Grep, LS, Read, Edit, MultiEdit, Write, NotebookEdit, WebFetch, TodoWrite, WebSearch, BashOutput, KillBash
5+
model: inherit
6+
color: orange
7+
---
8+
9+
You are a Senior Software Architect and Code Quality Expert specializing in Rust development, with deep expertise in the markdown-neuraxis project architecture. Your role is to conduct thorough code reviews focusing on quality, design decisions, and architectural alignment.
10+
11+
YOU ARE FORBIDDEN TO MAKE ANY FILESYSTEM CHANGES
12+
13+
When reviewing code diffs, you will systematically evaluate:
14+
15+
**Code Quality & Craftsmanship:**
16+
- Identify shortcuts, hacks, or technical debt that compromise maintainability
17+
- Flag poor error handling, unsafe code patterns, or resource leaks
18+
- Check for proper use of Rust idioms, ownership patterns, and type safety
19+
- Verify adherence to the project's coding standards (cargo fmt, cargo clippy compliance)
20+
- Assess code readability, documentation, and self-explanatory naming
21+
- Parsing code must NEVER be in the UI code files
22+
23+
**Design & Architecture:**
24+
- Ensure changes align with the project's local-first, markdown-centric philosophy
25+
- Verify compatibility with the Dioxus desktop framework and plugin architecture
26+
- Check that new code follows established patterns for file system access and markdown parsing
27+
- Assess impact on the PARA methodology implementation and fractal outline structure
28+
- Validate that UI changes maintain keyboard-first, split-view design principles
29+
30+
**Test Coverage & Quality:**
31+
- Identify missing test coverage for new functionality
32+
- Suggest integration tests following the outside-in testing strategy
33+
- Flag changes that break existing test contracts
34+
- Recommend test scenarios for edge cases and error conditions
35+
- as a reviewer, if you can't tell from the tests if it works, then the feedback should include that the test coverage is not clear or sufficient
36+
37+
**Project-Specific Concerns:**
38+
- Ensure markdown parsing changes maintain compatibility with pulldown-cmark
39+
- Verify file organization works with flexible folder structures (journal/, assets/ are optional)
40+
- Check that cross-linking and UUID systems remain intact
41+
- Validate performance implications for large markdown file collections
42+
43+
**Review Process:**
44+
1. Analyze the diff context and identify the change's purpose
45+
2. Systematically evaluate each modified file against the criteria above
46+
3. Prioritize findings by severity: Critical (blocks commit), Major (should fix), Minor (consider fixing)
47+
4. Provide specific, actionable feedback with code examples when helpful
48+
5. Suggest concrete improvements or alternative approaches
49+
6. Highlight positive aspects and good practices observed
50+
51+
**Output Format:**
52+
Structure your review as:
53+
- **Summary**: Brief assessment of overall change quality
54+
- **Critical Issues**: Must-fix problems that should block the commit
55+
- **Major Concerns**: Important issues that should be addressed soon
56+
- **Minor Suggestions**: Improvements for consideration
57+
- **Positive Notes**: Well-implemented aspects worth highlighting
58+
- **Recommendation**: APPROVE, APPROVE WITH CHANGES, or NEEDS WORK
59+
60+
Be thorough but constructive. Focus on teaching and improving rather than just finding faults. Consider the change's context within the broader project goals.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
name: feature-implementer
3+
description: Use this agent when you need to implement a specific bug fix or feature request. This agent should be called when you have a clear requirement or issue that needs to be coded and tested. Examples: <example>Context: User wants to add a new markdown parsing feature to handle custom metadata blocks. user: 'I need to add support for parsing custom metadata blocks in markdown files like `property:: value` format' assistant: 'I'll use the feature-implementer agent to implement this markdown metadata parsing feature with proper tests.' <commentary>Since the user has a specific feature request that needs implementation, use the feature-implementer agent to write the code and tests.</commentary></example> <example>Context: User reports a bug where the file browser crashes on empty directories. user: 'The file browser is crashing when I open an empty directory - can you fix this?' assistant: 'I'll use the feature-implementer agent to investigate and fix this file browser crash bug.' <commentary>Since there's a specific bug that needs fixing, use the feature-implementer agent to diagnose and implement the fix.</commentary></example>
4+
tools: Task, Bash, Glob, Grep, LS, ExitPlanMode, Read, Edit, MultiEdit, Write, NotebookEdit, WebFetch, TodoWrite, WebSearch, BashOutput, KillBash
5+
model: opus
6+
color: blue
7+
---
8+
9+
You are a senior software engineer with deep expertise in Rust, Dioxus, and markdown processing systems. You specialize in implementing features and fixing bugs with a focus on clean, maintainable code that follows KISS and YAGNI principles.
10+
11+
Your core responsibilities:
12+
- Implement requested features or bug fixes completely and correctly
13+
- Write comprehensive outside-in tests that verify behavior from the user's perspective
14+
- Ensure all code follows project conventions and architecture patterns
15+
- Address code review feedback thoroughly and make necessary changes
16+
- Always deliver working code with passing tests before considering the task complete
17+
18+
Your approach to implementation:
19+
1. **Understand the requirement**: Analyze the feature/bug request in context of the project's architecture and goals
20+
2. **Design the solution**: Plan the implementation considering existing patterns, KISS principles, and avoiding over-engineering
21+
3. **Implement incrementally**: Write code in small, testable chunks that build toward the complete solution
22+
4. **Test outside-in**: Create integration tests that verify the feature works from the user's perspective, then add unit tests as needed
23+
5. **Verify quality**: Ensure code follows Rust best practices, project conventions, and passes all linting
24+
6. **Handle feedback**: When receiving code review feedback, address all points thoroughly and make necessary improvements
25+
26+
Key technical guidelines:
27+
- Follow the project's file structure and architectural patterns established in CLAUDE.md
28+
- Use Dioxus patterns consistently with existing codebase
29+
- Prefer composition over inheritance and simple solutions over complex ones
30+
- Write tests that focus on behavior rather than implementation details
31+
- Avoid mocking in favor of testing real integrations where practical
32+
- Ensure all code is formatted with `cargo fmt` and passes `cargo clippy`
33+
- Make atomic commits with clear conventional commit messages
34+
- When you are ready to commit, exit and tell the calling agent that the code is ready, do not make any git commits.
35+
36+
Quality standards:
37+
- All tests must pass before considering implementation complete
38+
- Code must be self-documenting with clear variable and function names
39+
- Handle error cases gracefully with appropriate error types
40+
- Consider edge cases and boundary conditions in both code and tests
41+
- Ensure thread safety and performance considerations for file system operations
42+
43+
When you encounter ambiguity or need clarification, ask specific questions rather than making assumptions. Take pride in delivering robust, well-tested code that enhances the project's goals of being a reliable, local-first markdown knowledge management system.

.claude/settings.local.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"$schema": "https://json.schemastore.org/claude-code-settings.json",
3+
"permissions": {
4+
"allow": [
5+
"Bash(find:*)",
6+
"Bash(cat:*)",
7+
"Bash(mkdir:*)",
8+
"Bash(grep:*)",
9+
"Bash(./lint.sh)",
10+
"Bash(cargo build)",
11+
"Bash(cargo test)",
12+
"Bash(cargo fmt:*)",
13+
"Bash(cargo test:*)",
14+
"Bash(cargo clippy:*)",
15+
"Bash(cargo check:*)",
16+
"Bash(cargo insta:*)",
17+
"Bash(cargo doc:*)",
18+
"WebFetch(domain:crates.io)",
19+
"WebFetch(domain:docs.rs)",
20+
"WebFetch(domain:github.com)"
21+
],
22+
"deny": []
23+
}
24+
}

CLAUDE.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Commands
6+
7+
### Building and Testing
8+
- **Build**: `cargo build`
9+
- **Run tests**: `cargo test`
10+
- **Run a specific test**: `cargo test test_name`
11+
- **Run end-to-end tests**: `cargo test --test end_to_end_tests`
12+
- **Run gitopolis tests**: `cargo test --test gitopolis_tests`
13+
14+
### Linting and Formatting
15+
- **Run all lints**: `./lint.sh` (runs cargo fmt, clippy with harsh settings, cargo deny license check, and yamllint for GitHub workflows)
16+
- **Format code**: `cargo fmt`
17+
- **Run clippy**: `./clippy-harsh.sh` or `cargo clippy --all-targets --all-features -- -D warnings`
18+
- **Check licenses**: `cargo deny check licenses`
19+
- **Check unused dependencies**: `cargo machete`
20+
21+
### Development
22+
- **Install CI tools locally**: `./ci-tool-setup.sh` (installs cargo-deny and yamllint)
23+
- **Upgrade dependencies**: `./upgrades.sh` (upgrades both Rust version and crate dependencies)
24+
25+
## Architecture
26+
27+
Gitopolis is a CLI tool for managing multiple git repositories, built in Rust with a clean separation of concerns:
28+
29+
### Core Components
30+
31+
- **main.rs**: CLI interface using clap, bridges between console and logic, injects dependencies
32+
- **lib.rs**: Module aggregation point for the library
33+
- **gitopolis.rs**: Core business logic with injected dependencies (storage, git operations, stdout)
34+
- **repos.rs**: Domain models for repository state management
35+
- **exec.rs**: Command execution across multiple repositories
36+
- **storage.rs**: Trait for state persistence (currently using TOML file `.gitopolis.toml`)
37+
- **git.rs**: Trait for git operations (using git2 crate)
38+
39+
### Testing Strategy
40+
41+
- **End-to-end tests** (`tests/end_to_end_tests.rs`): Test complete workflows with real file system and git repos
42+
- **Unit tests** (`tests/gitopolis_tests.rs`): Test core logic with mocked dependencies (storage, git)
43+
44+
### Key Design Patterns
45+
46+
- Dependency injection through traits for testability (Storage, Git traits)
47+
- Clean separation between CLI parsing and business logic
48+
- State management through `.gitopolis.toml` file with TOML serialization
49+
- Command pattern for different operations (add, remove, exec, tag, clone, list)
50+
51+
### Important Notes
52+
53+
- The tool uses `git2` crate for git operations with vendored OpenSSL
54+
- Windows has special handling for glob expansion (not done by shell)
55+
- Currently only supports "origin" remote (see issue #7 for multiple remote support)
56+
- Rust version is pinned in `.tool-versions` and used by CI workflows

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ gitopolis add *
2929
gitopolis exec -- git pull
3030
```
3131

32+
### Getting output as single lines
33+
34+
For compact, parsable output that's easy to sort and analyze use `--oneline`, this will put all the output on a single line for each repo (removing newlines).
35+
36+
e.g. to see the latest commit for all the repos, with the most recently touched repo first:
37+
38+
```sh
39+
gitopolis exec --oneline -- git log --format=format:'%cd "%s" 📝 %an' --date='format:%Y-%m-%d' -n 1 | awk '{print $3 " " $0}' | sort -r
40+
```
41+
3242
### Tagging
3343

3444
```sh

src/exec.rs

Lines changed: 69 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
use crate::repos::Repo;
22
use std::env;
3-
use std::io::Error;
4-
use std::process::{Child, Command, ExitStatus};
3+
use std::io::{Error, Read};
4+
use std::process::{Child, Command, ExitStatus, Stdio};
55

6-
pub fn exec(mut exec_args: Vec<String>, repos: Vec<Repo>) {
6+
pub fn exec(mut exec_args: Vec<String>, repos: Vec<Repo>, oneline: bool) {
77
let args = exec_args.split_off(1);
88
let cmd = &exec_args[0]; // only cmd remaining after split_off above
99
let mut error_count = 0;
@@ -12,11 +12,24 @@ pub fn exec(mut exec_args: Vec<String>, repos: Vec<Repo>) {
1212
println!("🏢 {}> Repo folder missing, skipped.", &repo.path);
1313
return;
1414
}
15-
let exit_status = repo_exec(&repo.path, cmd, &args).expect("Failed to execute command.");
16-
if !exit_status.success() {
17-
error_count += 1
15+
if oneline {
16+
let (output, success) =
17+
repo_exec_oneline(&repo.path, cmd, &args).expect("Failed to execute command.");
18+
match output {
19+
Some(output_text) => println!("🏢 {}> {}", &repo.path, output_text),
20+
None => println!("🏢 {}> ", &repo.path),
21+
}
22+
if !success {
23+
error_count += 1;
24+
}
25+
} else {
26+
let exit_status =
27+
repo_exec(&repo.path, cmd, &args).expect("Failed to execute command.");
28+
if !exit_status.success() {
29+
error_count += 1
30+
}
31+
println!();
1832
}
19-
println!();
2033
}
2134
if error_count > 0 {
2235
eprintln!("{error_count} commands exited with non-zero status code");
@@ -45,3 +58,52 @@ fn repo_exec(path: &str, cmd: &str, args: &Vec<String>) -> Result<ExitStatus, Er
4558
}
4659
Ok(*exit_code)
4760
}
61+
62+
fn repo_exec_oneline(
63+
path: &str,
64+
cmd: &str,
65+
args: &Vec<String>,
66+
) -> Result<(Option<String>, bool), Error> {
67+
let mut child_process: Child = Command::new(cmd)
68+
.args(args)
69+
.current_dir(path)
70+
.stdout(Stdio::piped())
71+
.stderr(Stdio::piped())
72+
.spawn()?;
73+
74+
let mut stdout = String::new();
75+
if let Some(mut stdout_pipe) = child_process.stdout.take() {
76+
stdout_pipe.read_to_string(&mut stdout)?;
77+
}
78+
79+
let mut stderr = String::new();
80+
if let Some(mut stderr_pipe) = child_process.stderr.take() {
81+
stderr_pipe.read_to_string(&mut stderr)?;
82+
}
83+
84+
let exit_code = child_process.wait()?;
85+
let success = exit_code.success();
86+
87+
// Flatten multi-line output to single line by replacing newlines with spaces
88+
let stdout_clean = stdout.trim().replace('\n', " ");
89+
let stderr_clean = stderr.trim().replace('\n', " ");
90+
91+
// Combine stdout and stderr, with stderr included when command fails
92+
let output = if !success && !stderr_clean.is_empty() {
93+
if stdout_clean.is_empty() {
94+
stderr_clean
95+
} else {
96+
format!("{} {}", stdout_clean, stderr_clean)
97+
}
98+
} else if !stdout_clean.is_empty() {
99+
stdout_clean
100+
} else {
101+
String::new()
102+
};
103+
104+
if output.is_empty() {
105+
Ok((None, success))
106+
} else {
107+
Ok((Some(output), success))
108+
}
109+
}

src/main.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ enum Commands {
4040
Exec {
4141
#[arg(short, long)]
4242
tag: Option<String>,
43+
#[arg(long)]
44+
oneline: bool,
4345
exec_args: Vec<String>,
4446
},
4547
/// Add/remove repo tags. Use tags to organise repos and allow running commands against subsets of the repo list.
@@ -89,13 +91,15 @@ fn main() {
8991
Some(Commands::Clone { tag: tag_name }) => clone(tag_name),
9092
Some(Commands::Exec {
9193
tag: tag_name,
94+
oneline,
9295
exec_args,
9396
}) => {
9497
exec(
9598
exec_args.to_owned(),
9699
init_gitopolis()
97100
.list(tag_name)
98101
.expect("TODO: panic message"),
102+
*oneline,
99103
);
100104
}
101105
Some(Commands::Tag {

tests/end_to_end_tests.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,61 @@ fn exec_invalid_command() {
411411
.failure();
412412
}
413413

414+
#[test]
415+
fn exec_oneline() {
416+
let temp = temp_folder();
417+
add_a_repo(&temp, "repo_a", "git://example.org/test_url");
418+
add_a_repo(&temp, "repo_b", "git://example.org/test_url2");
419+
420+
gitopolis_executable()
421+
.current_dir(&temp)
422+
.args(vec!["exec", "--oneline", "--", "echo", "hello"])
423+
.assert()
424+
.success()
425+
.stdout("🏢 repo_a> hello\n🏢 repo_b> hello\n");
426+
}
427+
428+
#[test]
429+
fn exec_oneline_multiline_output() {
430+
let temp = temp_folder();
431+
add_a_repo(&temp, "repo_a", "git://example.org/test_url");
432+
433+
// Create a test file with multiple lines in the repo
434+
let repo_path = temp.path().join("repo_a");
435+
fs::write(repo_path.join("test.txt"), "line1\nline2\nline3").unwrap();
436+
437+
gitopolis_executable()
438+
.current_dir(&temp)
439+
.args(vec!["exec", "--oneline", "--", "cat", "test.txt"])
440+
.assert()
441+
.success()
442+
.stdout("🏢 repo_a> line1 line2 line3\n");
443+
}
444+
445+
#[test]
446+
fn exec_oneline_non_zero() {
447+
let temp = temp_folder();
448+
add_a_repo(&temp, "repo_a", "git://example.org/test_url");
449+
add_a_repo(&temp, "repo_b", "git://example.org/test_url2");
450+
451+
let expected_stdout = match get_operating_system() {
452+
OperatingSystem::MacOSX => {
453+
"🏢 repo_a> ls: non-existent: No such file or directory\n🏢 repo_b> ls: non-existent: No such file or directory\n"
454+
}
455+
OperatingSystem::Other => {
456+
"🏢 repo_a> ls: cannot access 'non-existent': No such file or directory\n🏢 repo_b> ls: cannot access 'non-existent': No such file or directory\n"
457+
}
458+
};
459+
460+
gitopolis_executable()
461+
.current_dir(&temp)
462+
.args(vec!["exec", "--oneline", "--", "ls", "non-existent"])
463+
.assert()
464+
.success()
465+
.stdout(expected_stdout)
466+
.stderr("2 commands exited with non-zero status code\n");
467+
}
468+
414469
#[test]
415470
fn tag() {
416471
let temp = temp_folder();

0 commit comments

Comments
 (0)