Skip to content
Closed
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: 0 additions & 2 deletions .github/workflows/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,6 @@ jobs:
any::rcmdcheck
upgrade: 'TRUE'
- uses: r-lib/actions/check-r-package@v2
with:
error-on: '"note"'
- name: Check pkgdown
shell: Rscript {0}
run: pkgdown::check_pkgdown()
Expand Down
6 changes: 5 additions & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Roxygen: list(markdown = TRUE)
RoxygenNote: 7.3.3
Imports:
dplyr,
callr,
magrittr (>= 2.0.3),
XML,
utils,
Expand All @@ -25,6 +26,7 @@ Imports:
stringr,
fs,
cli,
here,
rmarkdown,
tinytex,
pmtables,
Expand All @@ -34,7 +36,9 @@ Imports:
htmltools,
shiny,
pdftools,
stringi
stringi,
yaml,
purrr
Suggests:
knitr,
testthat (>= 3.0.0),
Expand Down
1 change: 1 addition & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export(logSummary)
export(renderQCReport)
export(renderQCSummary)
export(repoHistory)
export(runWithOutputs)
export(svnExport)
export(svnLog)
export(with_demoRepo)
Expand Down
7 changes: 5 additions & 2 deletions R/reviewPackage.R
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ globalVariables(
"path",
"page",
"type",
"code_path"
"code_path",
"rel_path",
"modification_time",
"size"
)
)
)
90 changes: 90 additions & 0 deletions R/runWithOutputs.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#' Execute Script and Detect File Changes
#'
#' Runs an R script in an isolated session and identifies files that were created
#' or modified during execution. It detects changes by comparing the file system
#' state (modification time and size) before and after the run.
#'
#' @param script Character. The path to the R script to execute.
#' @param root Character. The project root directory. Defaults to `here::here()`.
#' @param exclude_dirs Character vector. A list of top-level directories relative
#' to `root` to ignore when scanning for changes (e.g., "renv", ".git").
#'
#' @return Invisibly returns a character vector of relative paths for all files
#' that were created or updated.
#' @export
runWithOutputs <- function(
script,
root = here::here(),
exclude_dirs = c("renv", ".svn", ".git")
) {
# Normalize paths and calculate relative path for the UI
script_abs <- fs::path_abs(script)
script_rel <- fs::path_rel(script_abs, start = root)

# 1. Capture Initial State
# Define file state by path, modification time, and size.
before <- fs::dir_info(root, recurse = TRUE, type = "file") %>%
dplyr::select(path, modification_time, size)

# --- UI Header ---
# Shows the relative path in the badge for immediate context.
div_start <- cli::cli_div(theme = list(rule = list(color = "cyan")))
cli::cli_rule(
left = cli::style_bold(cli::bg_cyan(cli::col_white(paste0(
" runWithOutputs('",
script_rel,
"') "
)))),
right = "START"
)
cli::cli_end(div_start)

# 2. Execute Script
# Run in a clean, separate R session to ensure isolation.
callr::rscript(script_abs, wd = root, show = TRUE)

# --- UI Footer ---
# Provides a clean "closing bracket" for the script output.
div_end <- cli::cli_div(theme = list(rule = list(color = "cyan")))
cli::cli_rule(
left = cli::style_bold(cli::bg_cyan(cli::col_white(" runWithOutputs() "))),
right = "COMPLETE"
)
cli::cli_end(div_end)

# 3. Capture Final State
after <- fs::dir_info(root, recurse = TRUE, type = "file") %>%
dplyr::select(path, modification_time, size)

# 4. Compute State Differences
# Identify files where the (path, time, size) tuple in the 'after' snapshot
# does not strictly match the 'before' snapshot.
changed <- dplyr::anti_join(
after,
before,
by = c("path", "modification_time", "size")
) %>%
dplyr::mutate(rel_path = fs::path_rel(path, start = root))

# 5. Apply Exclusions
# Filter out files where the top-level directory matches an exclusion pattern.
if (length(exclude_dirs) > 0 && nrow(changed) > 0) {
changed <- changed %>%
dplyr::filter(
!fs::path_split(rel_path) %>%
purrr::map_lgl(~ .x[1] %in% exclude_dirs)
)
}

# 6. Report Results
out_paths <- sort(changed$rel_path)

if (length(out_paths) > 0) {
cli::cli_alert_success("Files saved by this run:")
cli::cli_code(yaml::as.yaml(list(outputs = out_paths)))
invisible(out_paths)
} else {
cli::cli_alert_info(cli::col_silver("No files were saved."))
invisible(character(0))
}
}
4 changes: 4 additions & 0 deletions _pkgdown.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,7 @@ reference:
- logRemove
- repoHistory
- getTFs
- title: Execution Tools
desc: Run scripts and track side effects
contents:
- runWithOutputs
29 changes: 29 additions & 0 deletions man/runWithOutputs.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

147 changes: 147 additions & 0 deletions tests/testthat/test-runWithOutputs.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
test_that("runWithOutputs detects newly created files", {
with_demoRepo({
# Setup: Create a script that generates a new output file
script_path <- "script/generate_data.R"
output_file <- "data/new_result.txt"

fs::dir_create("script")

writeLines(
text = c(
'dir.create("data", showWarnings = FALSE)',
paste0('writeLines("content", "', output_file, '")')
),
con = script_path
)

# Execution
result <- runWithOutputs(script_path, root = getwd())

# Verification: Ensure only the expected output file is returned
expect_setequal(result, output_file)
})
})

test_that("runWithOutputs detects modified files", {
with_demoRepo({
# Setup: Create an existing file
target_file <- "data/existing_file.txt"
fs::dir_create("data")
writeLines("old content", target_file)

# Backdate the file timestamp to ensure detection without Sys.sleep()
Sys.setFileTime(target_file, Sys.time() - 60)

script_path <- "script/modify_data.R"
fs::dir_create("script")

writeLines(
text = paste0('writeLines("new content", "', target_file, '")'),
con = script_path
)

# Execution
result <- runWithOutputs(script_path, root = getwd())

# Verification
expect_true(target_file %in% result)
})
})

test_that("runWithOutputs detects file 'touch' (overwrite with identical content)", {
with_demoRepo({
target_file <- "data/config.yml"
fs::dir_create("data")
content <- "fixed_settings: true"

# 1. Create file
writeLines(content, target_file)

# 2. Backdate it
Sys.setFileTime(target_file, Sys.time() - 60)

# 3. Script overwrites it with EXACT SAME content
script_path <- "script/refresh_config.R"
fs::dir_create("script")
writeLines(
text = paste0('writeLines("', content, '", "', target_file, '")'),
con = script_path
)

# 4. Execution
result <- runWithOutputs(script_path, root = getwd())

# 5. Verification: Identical size but new timestamp should trigger detection
expect_true(target_file %in% result)
})
})

test_that("runWithOutputs returns empty vector when no files change", {
with_demoRepo({
script_path <- "script/do_nothing.R"
fs::dir_create("script")
writeLines("print('No file changes here')", script_path)

# Execution
result <- runWithOutputs(script_path, root = getwd())

expect_identical(result, character(0))
})
})

test_that("runWithOutputs respects default excluded directories", {
with_demoRepo({
fs::dir_create("renv")
fs::dir_create("scratch")
fs::dir_create("data")
fs::dir_create("script")

script_path <- "script/mixed_outputs.R"

# Script writes to an ignored dir and two included dirs
writeLines(
text = c(
'writeLines("a", "renv/ignored.txt")',
'writeLines("b", "scratch/included.txt")',
'writeLines("c", "data/important.txt")'
),
con = script_path
)

# Execution
result <- runWithOutputs(script_path, root = getwd())

# Verification
expect_true("data/important.txt" %in% result)
expect_true("scratch/included.txt" %in% result)
expect_false("renv/ignored.txt" %in% result)
})
})

test_that("runWithOutputs respects custom excluded directories argument", {
with_demoRepo({
fs::dir_create("custom_folder")
script_path <- "script.R"

writeLines('writeLines("x", "custom_folder/file.txt")', script_path)

# Pass a custom exclusion list
result <- runWithOutputs(
script_path,
root = getwd(),
exclude_dirs = c("custom_folder")
)

expect_false("custom_folder/file.txt" %in% result)
})
})

test_that("runWithOutputs errors if the script execution fails", {
with_demoRepo({
script_path <- "broken.R"
writeLines("stop('Critical error')", script_path)

# Verification: callr should propagate the error to runWithOutputs
expect_error(runWithOutputs(script_path, root = getwd()))
})
})
Loading