Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.DS_Store
.vscode/
33 changes: 33 additions & 0 deletions cli/bash/lib/file/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# `lib_file.sh`

File-oriented Bash helpers shared by CLI commands.

## Dependency

Source `lib/std/lib_std.sh` before this library so logging and error helpers are available.

## Public API

- `update_file_section`
Idempotently add, replace, or remove a marker-delimited block inside a file.

## Usage

```bash
source "/absolute/path/to/cli/bash/lib/std/lib_std.sh"
source "/absolute/path/to/cli/bash/lib/file/lib_file.sh"

update_file_section ~/.bash_profile "# BEGIN APP" "# END APP" \
"export APP_HOME=/opt/app" \
"alias appctl='app status'"
```

## Behavior Notes

- Returns success when the target file does not exist and there is nothing to remove.
- Replaces only the first matching marked section when markers already exist.
- Appends the marked block when markers are not present.

## Tests

BATS coverage lives in `tests/lib_file.bats`.
162 changes: 162 additions & 0 deletions cli/bash/lib/file/lib_file.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
#
# lib_file.sh - Bash library of generic file manipulation functions.
#

#
# update_file_section - Idempotently manages a block of text within a file,
# demarcated by start and end markers.
#
# This function can add, update, or remove a section of text in a file.
# It is designed to be safe to run multiple times. If the section already
# exists, it will be replaced. If it doesn't exist, it will be appended.
#
# Usage:
# update_file_section [options] <target_file> <start_marker> <end_marker> [content_lines...]
#
# Options:
# -r : Remove the section defined by the markers instead of adding/updating it.
#
# Arguments:
# target_file: The path to the file to be modified.
# start_marker: The exact string that marks the beginning of the section.
# end_marker: The exact string that marks the end of the section.
# content_lines: (Optional) One or more strings, each representing a line of
# content to be placed inside the section.
#
# Examples:
#
# # Add/update a section in .bash_profile
# local commands=("export FOO=bar" "alias myalias='echo hello'")
# update_file_section ~/.bash_profile "# START" "# END" "${commands[@]}"
#
# # Remove the same section
# update_file_section -r ~/.bash_profile "# START" "# END"
#
update_file_section() {
local remove_section=false
local new_content_array=()

if [[ "$1" == "-r" ]]; then
remove_section=true
shift # consume -r
fi

if [[ $# -lt 3 ]]; then
log_error "Insufficient arguments."
if [[ "$remove_section" == true ]]; then
log_info "Usage: update_file_section -r <target_file> <beginning_marker> <end_marker>"
else
log_info "Usage: update_file_section <target_file> <beginning_marker> <end_marker> [new_lines...]"
fi
return 1
fi

local target_file="$1" beginning_marker="$2" end_marker="$3"
shift 3 # consume target_file, beginning_marker, end_marker
if [[ "$remove_section" == true ]]; then
if [[ $# -gt 0 ]]; then
log_error "When -r flag is used, no content arguments should be provided."
log_info "Usage: update_file_section -r <target_file> <beginning_marker> <end_marker>"
return 1
fi
else
new_content_array=("$@") # Capture remaining arguments as new_lines
fi

if [[ ! -f "$target_file" ]]; then
log_debug "Target file '$target_file' does not exist."
return 0
fi

log_info "Updating '$target_file'"
local new_content_string=""
if [[ "$remove_section" == false ]]; then
if [[ ${#new_content_array[@]} -gt 0 ]]; then
# Use printf to join array elements with newlines, adding a final newline.
# This ensures proper multi-line insertion.
printf -v new_content_string '%s\n' "${new_content_array[@]}"
fi
fi

local temp_file
temp_file=$(mktemp "${target_file}.XXXXXX")
if [[ ! -f "$temp_file" ]]; then
log_error "Failed to create temporary file for '$target_file'."
return 1
fi

if grep -qF -- "$beginning_marker" "$target_file" && grep -qF -- "$end_marker" "$target_file"; then
if [[ "$remove_section" == true ]]; then
awk -v START_M="$beginning_marker" -v END_M="$end_marker" '
BEGIN { in_section = 0 }
$0 == START_M { in_section = 1; next }
$0 == END_M { in_section = 0; next }
{
if (in_section == 0) {
print $0
}
}
' "$target_file" > "$temp_file"
else
# FIX: This awk script now correctly handles multiple sections. It only replaces the first one.
export AWK_NEW_TEXT="$new_content_string"
awk -v START_M="$beginning_marker" -v END_M="$end_marker" '
BEGIN {
processed = 0 # 0 = not yet processed, 1 = processing, 2 = done
}
$0 == START_M && processed == 0 {
print START_M
printf "%s", ENVIRON["AWK_NEW_TEXT"] # Insert new content
processed = 1 # We are now inside the section to be replaced
next
}
$0 == END_M && processed == 1 {
print END_M
processed = 2 # We are done with the replacement
next
}
processed != 1 { # Print the line if we are not inside the section being replaced
print $0
}
' "$target_file" > "$temp_file"

unset AWK_NEW_TEXT
fi

if [[ $? -eq 0 ]]; then
mv -f "$temp_file" "$target_file"
return 0
else
log_error "Failed to process sections in '$target_file'."
rm -f "$temp_file"
return 1
fi
else
# Markers not found in the file
if [[ "$remove_section" == true ]]; then
rm -f "$temp_file"
return 0
else
cp "$target_file" "$temp_file"

if [[ $(tail -c 1 "$temp_file" 2>/dev/null | wc -l) -eq 0 ]]; then
echo "" >> "$temp_file"
fi

{
echo "$beginning_marker"
printf "%s" "$new_content_string"
echo "$end_marker"
} >> "$temp_file"

if [[ $? -eq 0 ]]; then
mv -f "$temp_file" "$target_file"
return 0
else
log_error "Failed to add new section to '$target_file'."
rm -f "$temp_file"
return 1
fi
fi
fi
}
67 changes: 67 additions & 0 deletions cli/bash/lib/file/tests/lib_file.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
#!/usr/bin/env bats

load ../../../tests/test_helper.bash

setup() {
setup_test_tmpdir
source "$BANYAN_BASH_DIR/lib/std/lib_std.sh"
source "$BANYAN_BASH_DIR/lib/file/lib_file.sh"
}

@test "update_file_section appends a new marked block when markers are absent" {
local target="$TEST_TMPDIR/config.txt"
printf 'line-one' > "$target"

update_file_section "$target" "# BEGIN" "# END" "first" "second"

[ "$(cat "$target")" = $'line-one\n# BEGIN\nfirst\nsecond\n# END' ]
}

@test "update_file_section replaces the first matching section" {
local target="$TEST_TMPDIR/config.txt"
cat <<'EOF' > "$target"
before
# BEGIN
old
# END
after
EOF

update_file_section "$target" "# BEGIN" "# END" "new"

[ "$(cat "$target")" = $'before\n# BEGIN\nnew\n# END\nafter' ]
}

@test "update_file_section removes a marked block with -r" {
local target="$TEST_TMPDIR/config.txt"
cat <<'EOF' > "$target"
before
# BEGIN
remove-me
# END
after
EOF

update_file_section -r "$target" "# BEGIN" "# END"

[ "$(cat "$target")" = $'before\nafter' ]
}

@test "update_file_section is a no-op for a missing target file" {
local target="$TEST_TMPDIR/missing.txt"

bats_run update_file_section "$target" "# BEGIN" "# END" "value"

[ "$status" -eq 0 ]
[ ! -e "$target" ]
}

@test "update_file_section rejects content arguments when removing a section" {
local target="$TEST_TMPDIR/config.txt"
touch "$target"

bats_run update_file_section -r "$target" "# BEGIN" "# END" "unexpected"

[ "$status" -eq 1 ]
[[ "$output" == *"When -r flag is used"* ]]
}
41 changes: 41 additions & 0 deletions cli/bash/lib/git/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# `lib_git.sh`

Git helpers for Bash commands that need lightweight repository inspection or update behavior.

## Dependency

Source `lib/std/lib_std.sh` before this library so logging and shared error handling are available.

## Public API

- `git_update_repo`
Update a repository on branch `master`, optionally allowing tracked changes in one specific path.
- `git_get_current_branch`
Return the current branch name through a caller-provided variable, or `detached head`.
- `check_script_up_to_date`
Check whether a tracked script appears current relative to its configured upstream.

## Internal Helper

- `_git_only_path_dirty`
Internal predicate used by `git_update_repo` when an allowed dirty path is provided.

## Usage

```bash
source "/absolute/path/to/cli/bash/lib/std/lib_std.sh"
source "/absolute/path/to/cli/bash/lib/git/lib_git.sh"

branch=""
git_get_current_branch "$PWD" branch
log_info "Current branch: $branch"
```

## Behavior Notes

- `git_update_repo` currently only attempts updates when the checked-out branch is `master`.
- `check_script_up_to_date` treats missing git state, untracked scripts, or missing upstreams as skip conditions rather than hard failures.

## Tests

BATS coverage lives in `tests/lib_git.bats`.
Loading
Loading