Skip to content

Commit 1fbdd18

Browse files
committed
Add directory collapsing feature
- Closes: #16
1 parent b107c0a commit 1fbdd18

File tree

9 files changed

+504
-6
lines changed

9 files changed

+504
-6
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
## [Unreleased]
9+
10+
### Added
11+
- Directory collapsing feature with `-c, --collapse` option
12+
- Collapses consecutive single-child directories into a single line
13+
- Improves readability for deeply nested structures like Java packages
14+
- Example: `domains/foo/bar/module/Service.java` instead of separate lines
15+
916
### Fixed
1017
- Fixed indentation alignment issue when using custom indent values
1118
- Tree drawing characters now properly align with specified indentation

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ git tree
3131
## Options
3232
```
3333
-i, --indent INDENT Set indentation (2-10 spaces)
34+
-c, --collapse Collapse directories containing only another directory
3435
-v, --version Show version
3536
-h, --help Show help message
3637
```
@@ -74,6 +75,20 @@ git tree
7475
├── DELETEME.txt (D+)
7576
└── README.md (M)
7677
78+
# Example with --collapse option for deeply nested structures
79+
mkdir -p domains/foo/bar/module/api/src/main/java/com/company/service
80+
echo "Service.java" > domains/foo/bar/module/api/src/main/java/com/company/service/Service.java
81+
git add domains
82+
git tree --collapse
83+
.
84+
├── domains/foo/bar/module/api/src/main/java/com/company/service/Service.java (A+)
85+
├── lib/version.rb -> git_tree_version.rb (R+)
86+
├── test/node/test_node_class.rb -> test/node_class_test.rb (R+)
87+
├── test/staged.txt (A+)
88+
├── test/untracked.txt (?)
89+
├── DELETEME.txt (D+)
90+
└── README.md (M)
91+
7792
# reset repo
7893
git reset HEAD --hard
7994
git clean -xdf

bin/git-status-tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ parser = OptionParser.new do |opts|
1414
options[:indent] = indent
1515
end
1616

17+
opts.on('-c', '--collapse', 'Collapse directories containing only another directory') do
18+
options[:collapse] = true
19+
end
20+
1721
opts.on('-v', '--version', 'Show version') do
1822
puts "git-status-tree #{GitStatusTree::VERSION}"
1923
exit 0

lib/node.rb

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,24 @@
11
# frozen_string_literal: true
22

3+
require_relative 'node_collapsing'
4+
35
class NodeNameError < StandardError; end
46
class NodeChildrenError < StandardError; end
57
class NodeTypeError < StandardError; end
68

79
# A Node represents a file or directory in the git-status-tree
810
class Node
11+
include NodeCollapsing
12+
913
class << self
10-
attr_accessor :indent
14+
attr_accessor :indent, :collapse_dirs
1115
end
1216

1317
attr_accessor :status, :name, :children
1418

1519
def initialize(name, children = nil, status = nil)
1620
self.class.indent ||= 4
21+
self.class.collapse_dirs ||= false
1722
validate_name!(name)
1823

1924
msg = '"children" must be a NodesCollection or nil.'
@@ -152,10 +157,15 @@ def to_tree_s(depth = 0, open_parents = [0], last: true)
152157

153158
pre = pre_tree(depth, open_parents, last)
154159

155-
str_tree = "#{pre}#{color_name}\n"
156-
str_tree += children.to_tree_s(depth + 1, open_parents) if children
157-
158-
str_tree
160+
# Handle directory collapsing if enabled
161+
if self.class.collapse_dirs && collapsible?
162+
render_collapsed_tree(pre, depth, open_parents)
163+
else
164+
# Normal rendering
165+
str_tree = "#{pre}#{color_name}\n"
166+
str_tree += children.to_tree_s(depth + 1, open_parents) if children
167+
str_tree
168+
end
159169
end
160170

161171
def modified? = status.include?('M')
@@ -222,7 +232,7 @@ def pre_tree(depth, open_parents, last)
222232
def build_pre_array(depth, open_parents)
223233
spaces = ' ' * self.class.indent
224234
pre_ary = Array.new(depth).fill(spaces)
225-
indent = self.class.indent - 2
235+
indent = self.class.indent - 2
226236

227237
open_parents.each do |idx|
228238
pre_ary[idx] = "│#{' ' * indent} " if pre_ary[idx] == spaces

lib/node_collapsing.rb

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# frozen_string_literal: true
2+
3+
# Module for directory collapsing functionality
4+
module NodeCollapsing
5+
# Check if this directory can be collapsed (only contains one child, either file or directory)
6+
def collapsible?
7+
return false unless dir?
8+
return false unless children && children.nodes.length == 1
9+
return false if name == '.' # Never collapse the root node
10+
11+
true
12+
end
13+
14+
# Get the collapsed path for display
15+
def collapsed_path
16+
return name unless collapsible?
17+
18+
path_parts = [name]
19+
build_collapsed_path_parts(path_parts)
20+
path_parts.join('/')
21+
end
22+
23+
# Get the deepest node in a collapsible chain
24+
def deepest_collapsible_node
25+
current = self
26+
27+
current = current.children.nodes.first while current.collapsible? && current.children.nodes.first.dir?
28+
29+
current
30+
end
31+
32+
# Check if this is a collapsed path ending with a file
33+
def collapsed_with_file?
34+
return false unless collapsible?
35+
36+
deepest = deepest_collapsible_node
37+
deepest.children && deepest.children.nodes.length == 1 && deepest.children.nodes.first.file?
38+
end
39+
40+
private
41+
42+
def build_collapsed_path_parts(path_parts)
43+
current = self
44+
45+
# Traverse through collapsible directories
46+
while current.collapsible? && current.children.nodes.first.dir?
47+
child = current.children.nodes.first
48+
path_parts << child.name
49+
current = child
50+
end
51+
52+
# Add file if the last child is a file
53+
append_file_to_path(current, path_parts)
54+
end
55+
56+
def append_file_to_path(node, path_parts)
57+
return unless node.children && node.children.nodes.length == 1
58+
59+
child = node.children.nodes.first
60+
path_parts << child.name if child.file?
61+
end
62+
63+
def render_collapsed_tree(pre, depth, open_parents)
64+
display_name = collapsed_path
65+
66+
if collapsed_with_file?
67+
render_collapsed_file(pre, display_name)
68+
else
69+
render_collapsed_directory(pre, display_name, depth, open_parents)
70+
end
71+
end
72+
73+
def render_collapsed_file(pre, display_name)
74+
deepest = deepest_collapsible_node
75+
file_node = deepest.children.nodes.first
76+
"#{pre}#{color_collapsed_file(display_name, file_node.status)}\n"
77+
end
78+
79+
def render_collapsed_directory(pre, display_name, depth, open_parents)
80+
str_tree = "#{pre}#{color_collapsed_name(display_name)}\n"
81+
deepest = deepest_collapsible_node
82+
# Only render children if they exist and have nodes
83+
str_tree += deepest.children.to_tree_s(depth + 1, open_parents) if deepest.children&.nodes&.any?
84+
str_tree
85+
end
86+
87+
def color_collapsed_name(display_name)
88+
BashColor::EMB + display_name + BashColor::NONE
89+
end
90+
91+
def color_collapsed_file(display_path, status)
92+
# Split the path to separate directories from the file
93+
parts = display_path.split('/')
94+
file_name = parts.pop
95+
dir_path = parts.join('/')
96+
97+
if dir_path.empty?
98+
# No directories, just the file
99+
color_file_with_status(file_name, status)
100+
else
101+
# Directories in blue, file colored by status
102+
"#{BashColor::EMB}#{dir_path}/#{BashColor::NONE}#{color_file_with_status(file_name, status)}"
103+
end
104+
end
105+
106+
def color_file_with_status(file_name, status)
107+
color = status.include?('+') ? BashColor::G : BashColor::R
108+
"#{color}#{file_name} (#{status})#{BashColor::NONE}"
109+
end
110+
end

src/git_status_tree.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require File.join File.dirname(__FILE__), '../lib/node'
4+
require File.join File.dirname(__FILE__), '../lib/node_collapsing'
45
require File.join File.dirname(__FILE__), '../lib/nodes_collection'
56
require File.join File.dirname(__FILE__), '../lib/bash_color'
67
require File.join File.dirname(__FILE__), '../lib/version'
@@ -11,6 +12,7 @@ class GitStatusTree
1112

1213
def initialize(options = {})
1314
Node.indent = indent(options)
15+
Node.collapse_dirs = options[:collapse] || false
1416
@files = `git status --porcelain`.split("\n")
1517
@nodes = files.map { |file| Node.create_from_string file }
1618
@tree = nodes.reduce { |a, i| (a + i).nodes[0] }
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# frozen_string_literal: true
2+
3+
require_relative '../test_helper'
4+
require 'tempfile'
5+
require 'tmpdir'
6+
7+
class TestCommandLineCollapse < Test::Unit::TestCase
8+
def setup
9+
@original_dir = Dir.pwd
10+
@test_dir = Dir.mktmpdir
11+
Dir.chdir(@test_dir)
12+
system('git init', out: File::NULL, err: File::NULL)
13+
end
14+
15+
def teardown
16+
Dir.chdir(@original_dir)
17+
FileUtils.rm_rf(@test_dir)
18+
end
19+
20+
def test_collapse_option_with_deeply_nested_structure
21+
# Create deeply nested structure
22+
FileUtils.mkdir_p('domains/foo/bar/module/module-api/src/main/java/com/company/api')
23+
FileUtils.mkdir_p('domains/foo/bar/module/module-impl/src/main/java/com/company/impl')
24+
25+
File.write('domains/foo/bar/module/module-api/src/main/java/com/company/api/Service.java',
26+
'public class Service {}')
27+
File.write('domains/foo/bar/module/module-impl/src/main/java/com/company/impl/ServiceImpl.java',
28+
'public class ServiceImpl {}')
29+
30+
system('git add .')
31+
32+
# Test without collapse
33+
output_normal = `#{File.join(__dir__, '../../bin/git-status-tree')}`
34+
assert_match(/^\e\[1;34m\.\e\[0m$/, output_normal) # Root node with color codes
35+
assert_match(/└── \e\[1;34mdomains\e\[0m$/, output_normal)
36+
assert_match(/└── \e\[1;34mfoo\e\[0m$/, output_normal)
37+
assert_match(/└── \e\[1;34mbar\e\[0m$/, output_normal)
38+
assert_match(/└── \e\[1;34mmodule\e\[0m$/, output_normal)
39+
40+
# Test with collapse
41+
output_collapsed = `#{File.join(__dir__, '../../bin/git-status-tree')} --collapse`
42+
assert_match(/^\e\[1;34m\.\e\[0m$/, output_collapsed) # Root node with color codes
43+
assert_match(%r{└── \e\[1;34mdomains/foo/bar/module\e\[0m$}, output_collapsed)
44+
# Now the files are included in the collapsed path
45+
assert_match(
46+
%r{├── \e\[1;34mmodule-api/src/main/java/com/company/api/\e\[0m\e\[0;32mService\.java \(A\+\)\e\[0m},
47+
output_collapsed
48+
)
49+
assert_match(
50+
%r{└── \e\[1;34mmodule-impl/src/main/java/com/company/impl/\e\[0m\e\[0;32mServiceImpl\.java \(A\+\)\e\[0m},
51+
output_collapsed
52+
)
53+
end
54+
55+
def test_collapse_short_option
56+
FileUtils.mkdir_p('a/b/c')
57+
File.write('a/b/c/file.txt', 'content')
58+
system('git add .')
59+
60+
output = `#{File.join(__dir__, '../../bin/git-status-tree')} -c`
61+
# Should show root node and collapsed path
62+
assert_match(/^\e\[1;34m\.\e\[0m$/, output)
63+
assert_match(%r{└── \e\[1;34ma/b/c/\e\[0m\e\[0;32mfile\.txt \(A\+\)\e\[0m}, output)
64+
end
65+
66+
def test_collapse_with_mixed_content
67+
# Directory that should not collapse (has files and subdirs)
68+
FileUtils.mkdir_p('src/main')
69+
File.write('src/file.txt', 'content')
70+
File.write('src/main/Main.java', 'public class Main {}')
71+
72+
# Directory that should collapse
73+
FileUtils.mkdir_p('test/unit/java/com/company')
74+
File.write('test/unit/java/com/company/Test.java', 'public class Test {}')
75+
76+
system('git add .')
77+
78+
output = `#{File.join(__dir__, '../../bin/git-status-tree')} --collapse`
79+
80+
# Should show root node
81+
assert_match(/^\e\[1;34m\.\e\[0m$/, output)
82+
83+
# src should not be collapsed because it contains both files and directories
84+
assert_match(/├── \e\[1;34msrc\e\[0m$/, output)
85+
assert_match(%r{│\s+├── \e\[1;34mmain/\e\[0m\e\[0;32mMain\.java}, output)
86+
assert_match(/│\s+└── \e\[0;32mfile\.txt/, output)
87+
88+
# test should be collapsed, and now includes the file
89+
assert_match(%r{└── \e\[1;34mtest/unit/java/com/company/\e\[0m\e\[0;32mTest\.java \(A\+\)\e\[0m}, output)
90+
end
91+
92+
def test_collapse_with_single_file_in_directory
93+
# Create structure similar to test/node/test_node_class.rb
94+
FileUtils.mkdir_p('test/node')
95+
File.write('test/node/test_node_class.rb', 'class TestNodeClass; end')
96+
system('git add .')
97+
98+
output = `#{File.join(__dir__, '../../bin/git-status-tree')} --collapse`
99+
# Should show root node
100+
assert_match(/^\e\[1;34m\.\e\[0m$/, output)
101+
# The entire path including the file should be collapsed
102+
assert_match(%r{└── \e\[1;34mtest/node/\e\[0m\e\[0;32mtest_node_class\.rb \(A\+\)\e\[0m}, output)
103+
end
104+
end

0 commit comments

Comments
 (0)